Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 46.526 phút đọc

TCP là dòng byte

Sau bài này bạn sẽ làm được

  • Tái hiện hiện tượng gộp gói
  • Giải thích vì sao TCP không bảo toàn ranh giới thông điệp
  • Kể ba cách tự định nghĩa ranh giới
  • Nhận ra lỗi này trong mã người khác

Đây là bài quan trọng nhất của chương. Hầu hết lỗi mạng khó chịu nhất đều bắt nguồn từ một câu: TCP không bảo toàn ranh giới thông điệp. Bài này chứng minh điều đó bằng hai chương trình, rồi chỉ ba cách chữa.

#Ba lần gửi, một lần nhận

Ba lần send năm byte, và bên nhận thấy một khối mười lăm byte.
ranh_gioi.c, phần gửi
static DWORD WINAPI luong_gui(LPVOID a) {
    (void)a;
    Sleep(200);

    SOCKET s = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in sv;
    memset(&sv, 0, sizeof sv);
    sv.sin_family = AF_INET;
    sv.sin_port   = htons(18081);
    inet_pton(AF_INET, "127.0.0.1", &sv.sin_addr);

    if (connect(s, (struct sockaddr *)&sv, sizeof sv) < 0) return 1;

    /* BA lan send rieng biet, moi lan 5 byte */
    send(s, "Hello", 5, 0);
    send(s, "World", 5, 0);
    send(s, "12345", 5, 0);

    printf("client: da goi 3 lan, moi lan 5 byte\n");

    Sleep(500);
    closesocket(s);
    return 0;
}
ranh_gioi.c, phần nhận
SOCKET c = accept(sv, NULL, NULL);

Sleep(300);                     /* cho ca ba goi toi noi */

char bo_dem[64];
int lan = 0;
for (;;) {
    int n = recv(c, bo_dem, (int)sizeof bo_dem - 1, 0);
    if (n <= 0) break;
    bo_dem[n] = '\0';
    printf("server: recv lan %d duoc %d byte: \"%s\"\n", ++lan, n, bo_dem);
}

printf("server: tong cong %d lan recv cho 3 lan send\n", lan);
terminal
gcc -std=c11 -O2 -Wall -Wextra -D_WIN32_WINNT=0x0600 ranh_gioi.c -o ranh_gioi.exe -lws2_32
./ranh_gioi.exe
client: da goi 3 lan, moi lan 5 byte
server: recv lan 1 duoc 15 byte: "HelloWorld12345"
server: tong cong 1 lan recv cho 3 lan send

#Một lần gửi, hai mươi lăm lần nhận

Chiều ngược lại cũng đúng, và cũng hay bị quên: một lần send lớn sẽ được đọc ra bằng nhiều lần recv.

chia_nho.c
#define CO 200000

/* Ben gui: MOT lan send voi 200000 byte */
char *dem = malloc(CO);
memset(dem, 'A', CO);
int n = send(s, dem, CO, 0);
printf("client: goi send MOT lan voi %d byte, tra ve %d\n", CO, n);

/* Ben nhan: dem xem can bao nhieu lan recv */
char bo_dem[8192];
long tong = 0; int lan = 0;
int nho_nhat = 1 << 30, lon_nhat = 0;
for (;;) {
    int n = recv(c, bo_dem, (int)sizeof bo_dem, 0);
    if (n <= 0) break;
    ++lan; tong += n;
    if (n < nho_nhat) nho_nhat = n;
    if (n > lon_nhat) lon_nhat = n;
    if (lan <= 3) printf("server: recv lan %d duoc %d byte\n", lan, n);
    if (tong >= CO) break;
}
printf("server: tong %ld byte qua %d lan recv\n", tong, lan);
printf("server: lan nho nhat %d byte, lon nhat %d byte\n", nho_nhat, lon_nhat);
terminal
./cn.exe
server: recv lan 1 duoc 8192 byte
server: recv lan 2 duoc 8192 byte
server: recv lan 3 duoc 8192 byte
client: goi send MOT lan voi 200000 byte, tra ve 200000
server: tong 200000 byte qua 25 lan recv
server: lan nho nhat 3392 byte, lon nhat 8192 byte

#Vì sao TCP làm vậy

Dòng byte
Trừu tượng mà TCP cung cấp: một chuỗi byte có thứ tự, không có cấu trúc. Nó giống một tệp hơn là một hộp thư. Bạn ghi byte vào một đầu, byte đó ra ở đầu kia đúng thứ tự, và không có gì đánh dấu chỗ nào là một lần ghi.
Chỗ nào gộp hoặc chiaVì sao
Bộ đệm gửi của hệ điều hànhsend chỉ chép vào bộ đệm rồi trả về; tầng TCP quyết định gửi khi nào
Thuật toán NagleCố tình giữ dữ liệu nhỏ lại để gộp, giảm số gói
MTU của đường truyềnEthernet thường 1500 byte; dữ liệu lớn hơn bắt buộc bị chia
Bộ định tuyến trên đườngCó thể phân mảnh thêm nếu MTU chặng sau nhỏ hơn
Bộ đệm nhậnrecv trả về bất kỳ lượng nào đang có sẵn, không chờ đủ

#Ba cách tự đặt ranh giới

CáchVí dụ thậtƯuNhược
Tiền tố độ dàiNhiều giao thức nhị phân, khung WebSocketĐơn giản, nhanh, biết trước cần cấp bao nhiêu bộ nhớPhải chọn kích thước trường độ dài và kiểm tra giới hạn
Ký tự phân cáchHTTP dùng dòng trống, SMTP dùng dấu chấmĐọc được bằng mắt, dễ gỡ lỗi bằng telnetPhải thoát ký tự phân cách trong dữ liệu; chậm hơn
Thông điệp cố địnhMột số giao thức công nghiệpĐơn giản nhấtLãng phí, và không mở rộng được
Cách 1: tiền tố độ dài, cách nên dùng mặc định
/* Khung tin: [do dai 4 byte, thu tu mang][du lieu N byte] */

#define TIN_TOI_DA (1024 * 1024)        /* GIOI HAN, xem ben duoi */

static int gui_khung(Sock s, const void *du_lieu, uint32_t n) {
    uint32_t do_dai = htonl(n);
    if (send_all(s, &do_dai, 4) != 0) return -1;
    if (send_all(s, du_lieu, n) != 0) return -1;
    return 0;
}

static int nhan_khung(Sock s, void **ra, uint32_t *ra_n) {
    uint32_t do_dai;
    if (recv_all(s, &do_dai, 4) != 0) return -1;
    do_dai = ntohl(do_dai);

    if (do_dai > TIN_TOI_DA) return -1;     /* KHONG duoc bo dong nay */

    char *dem = malloc(do_dai + 1);
    if (dem == NULL) return -1;
    if (recv_all(s, dem, do_dai) != 0) { free(dem); return -1; }
    dem[do_dai] = '\0';

    *ra = dem;
    *ra_n = do_dai;
    return 0;
}
Cách 2: ký tự phân cách, giống HTTP
/* Doc toi khi gap '\n'. Phai co bo dem TICH LUY, vi mot lan recv
   co the chua nua dong, hai dong ruoi, hay khong dong nao. */

typedef struct {
    char   dem[8192];
    size_t co;                  /* so byte dang co trong dem */
} BoDem;

/* Tra ve 1 neu lay duoc mot dong, 0 neu can doc them, -1 neu loi. */
static int lay_dong(BoDem *b, char *ra, size_t ra_co) {
    char *nl = memchr(b->dem, '\n', b->co);
    if (nl == NULL) return 0;               /* chua du, doc them */

    size_t dai = (size_t)(nl - b->dem);
    if (dai >= ra_co) return -1;            /* dong qua dai */

    memcpy(ra, b->dem, dai);
    ra[dai] = '\0';

    /* Don phan da dung ra khoi bo dem */
    size_t con = b->co - dai - 1;
    memmove(b->dem, nl + 1, con);
    b->co = con;
    return 1;
}

/* Vong lap dung: */
for (;;) {
    char dong[1024];
    int r = lay_dong(&b, dong, sizeof dong);
    if (r == 1) { xu_ly(dong); continue; }   /* con dong nua trong dem */
    if (r < 0)  break;

    int n = recv(s, b.dem + b.co, sizeof b.dem - b.co, 0);
    if (n <= 0) break;
    b.co += (size_t)n;
}

#Nhận ra lỗi này

Không nên
/* Bon dau hieu cua ma sai */ /* 1. Goi recv mot lan roi coi la ca thong diep */ char dem[1024]; int n = recv(s, dem, sizeof dem, 0); dem[n] = '\0'; xu_ly(dem); /* 2. Gia dinh moi recv la mot lenh */ recv(s, lenh, sizeof lenh, 0); if (strcmp(lenh, "LIST") == 0) ... /* 3. Gui thong diep bang nhieu lan send roi tuong ben kia nhan duoc tung cai */ send(s, tieu_de, 8, 0); send(s, than, than_co, 0); /* 4. Doc "cho toi khi ket noi dong" cho giao thuc hoi dap */ while ((n = recv(s, dem, sizeof dem, 0)) > 0) tich_luy(dem, n); /* Voi HTTP keep-alive thi vong lap nay khong bao gio ket thuc. */
Nên
/* Bon cach lam dung */ /* 1. Luon co ham doc DU N byte */ if (recv_all(s, dem, can) != 0) return -1; /* 2. Luon co khung tin ro rang */ uint32_t n; recv_all(s, &n, 4); n = ntohl(n); /* 3. Ghep thanh MOT lan gui, hoac dung send_all cho tung phan. Ranh gioi do KHUNG quyet dinh, khong do so lan send. */ gui_khung(s, than, than_co); /* 4. Doc dung so byte ma giao thuc noi la con lai */ long con = content_length; while (con > 0) { int n = recv(s, dem, min(sizeof dem, con), 0); if (n <= 0) return -1; con -= n; }

Tự làm thử

  1. Chạy ranh_gioi.c, rồi bỏ Sleep(300) và chạy lại mười lần, đếm xem có bao nhiêu kết quả khác nhau.
  2. Sửa chia_nho.c để dùng bộ đệm 100 byte và đếm lại số lần recv.
  3. Cài lay_dong đầy đủ và test với dữ liệu chia nhỏ từng byte.
  4. Cài gui_khung và nhan_khung, gửi mười khung liên tiếp và kiểm tra bên nhận tách đúng mười.
  5. Bỏ dòng kiểm tra TIN_TOI_DA rồi gửi một khung có độ dài 0xFFFFFFFF, quan sát chuyện gì xảy ra.
  6. Tìm trong một dự án mã nguồn mở bất kỳ một chỗ gọi recv một lần rồi coi là xong, và đánh giá xem đó có phải lỗi không.

Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.

Tóm tắt

  • TCP là dòng byte: số lần send và số lần recv không liên quan gì tới nhau.
  • Đã chứng minh cả hai chiều: ba lần gửi thành một lần nhận, và một lần gửi thành hai mươi lăm lần nhận.
  • TCP_NODELAY giảm độ trễ nhưng không làm TCP giữ ranh giới.
  • Ba cách tự đặt ranh giới: tiền tố độ dài, ký tự phân cách, kích thước cố định. Mặc định chọn tiền tố độ dài.
  • Luôn kiểm tra giới hạn độ dài trước khi malloc theo số mà bên kia gửi.