Đây là một trong những cái bẫy hiệu năng khó chịu nhất trong lập trình mạng: một vòng gửi-nhận lẽ ra chỉ tốn vài chục micro giây bỗng kẹt đúng khoảng 40 mili giây, lặp đi lặp lại. Không phải mạng chậm, không phải máy chủ chậm — mà là hai cơ chế tối ưu của TCP, mỗi cái đều tốt khi đứng riêng, gặp nhau lại tạo thành một thế chờ chết. Bài này dựng đúng tình huống đó trong container và đo con số 40 mili giây tận mắt.

Nagle và ACK trễ

Hai tối ưu, mỗi cái đều hợp lý

Thuật toán Nagle sinh ra để chống rải gói tí hon. Như bài về các tầng header đã đo, mỗi gói TCP/IP cõng khoảng 40-66 byte header cố định, nên gửi từng byte một là cực kỳ phí. Nagle đặt một quy tắc đơn giản: khi đang có dữ liệu nhỏ chưa được ACK trên đường, đừng gửi gói nhỏ tiếp theo — hãy chờ cho tới khi gom đủ một segment đầy, hoặc cho tới khi dữ liệu cũ được xác nhận. Nó gộp nhiều lần ghi nhỏ thành một gói lớn hơn, hợp lý cho băng thông.

Delayed ACK (ACK trễ) sinh ra để chống gửi gói ACK trống. Thay vì xác nhận ngay mỗi gói nhận được, bên nhận chờ tới khoảng 40 mili giây, hy vọng trong lúc đó ứng dụng có dữ liệu trả lời để ghép ACK vào cùng gói dữ liệu (piggyback), hoặc nhận thêm một gói thứ hai để ACK gộp một thể. Cũng hợp lý: đỡ được những gói ACK 40 byte đi lẻ.

Từng cái một, cả hai đều làm mạng hiệu quả hơn. Vấn đề nằm ở chỗ chúng gặp nhau.

Thế chờ chết, từng bước

Hãy hình dung một mẫu rất phổ biến: khách gửi một request bằng hai lần write() (ví dụ ghi header rồi ghi body), rồi chờ response. Chuỗi sự kiện diễn ra thế này:

  1. Khách write(nửa 1) — gửi ngay, vì lúc này chưa có gì đang chờ ACK.
  2. Khách write(nửa 2) — nhưng Nagle giữ lại, vì nửa 1 chưa được ACK.
  3. Chủ nhận nửa 1, nhưng đó chưa phải request đầy đủ nên nó chưa có gì để trả lời. Và vì chưa có dữ liệu để ghép, delayed ACK giữ ACK lại chờ.
  4. Bế tắc: khách chờ ACK để thả nửa 2; chủ chờ đủ request để trả lời (và để ghép ACK). Không ai nhúc nhích cho tới khi bộ đếm delayed ACK hết giờ (~40 ms), ACK trống mới bật ra, Nagle mới thả nửa 2, chủ mới nhận đủ request và trả lời.

Một vòng đáng lẽ vài chục micro giây bị kéo thành 40 mili giây, chỉ vì hai bên lịch sự chờ nhau.

Đo con số 40 mili giây

Tôi dựng một máy chủ đọc đủ một request 200 byte rồi mới trả lời (đúng kiểu request/response thật), và một máy khách chạy 50 vòng gửi-nhận, đo độ trễ mỗi vòng. Ba cấu hình, mỗi cái lấy trung vị của ba lần chạy:

Cấu hình Trễ mỗi vòng (trung vị)
Nagle bật, ghi request bằng 2 write 43 ms
TCP_NODELAY (tắt Nagle), 2 write 0,05 ms
Nagle bật, ghi request bằng 1 write 0,04 ms

Con số nói thẳng vào mặt. Cấu hình đầu — mặc định của mọi socket, cộng với thói quen ghi request thành nhiều mảnh — cho 43 mili giây mỗi vòng. Hai cấu hình sau, chỉ khác ở một cái cờ hoặc cách ghép các lần ghi, cho 0,04-0,05 mili giây. Cùng đúng 200 byte đó đi qua, không đổi một byte, mà chênh nhau gần 900 lần.

Hai cách chữa hiện rõ ngay trong bảng. Cách thứ nhất là TCP_NODELAY — tắt Nagle, cho gói nhỏ đi ngay không chờ ACK. Cách thứ hai là gộp lần ghi: dựng trọn request trong bộ nhớ rồi write() đúng một lần, thì không có "nửa 2" nào để Nagle giữ, và bế tắc không hình thành. Cả hai đều đưa độ trễ về đúng mức lẽ ra phải có.

Một lần tôi đo hớ vì máy chủ quá nhanh nhẹn

Lần đầu dựng phép đo, tôi viết một máy chủ echo đơn giản: nhận được mẩu nào trả lại ngay mẩu đó. Chạy với Nagle bật và hai lần ghi, tôi đo ra 0,04 mili giây — nhanh tinh. Tôi suýt kết luận "Nagle chẳng gây trễ gì cả, câu chuyện 40 ms chỉ là truyền thuyết".

May là kết quả quá đẹp khiến tôi nghi. Vấn đề nằm ở máy chủ tôi tự viết: khi nó trả dữ liệu ngay sau mẩu đầu tiên, gói dữ liệu trả lời đó mang theo luôn ACK cho mẩu đầu (piggyback). Khách nhận được ACK tức thì, Nagle thả nửa 2 ngay, và bế tắc không bao giờ hình thành. Delayed ACK chỉ giữ ACK lại khi bên nhận không có dữ liệu để gửi kèm — nên một máy chủ trả lời eager sẽ vô tình giấu mất cả cái bug.

Cách sửa là cho máy chủ chờ nhận đủ request rồi mới trả lời — đúng như một dịch vụ request/response thật làm. Ngay khi đổi vậy, 43 mili giây hiện ra. Bài học đo lường: hành vi của máy chủ mình tự dựng cũng là một biến của phép đo. Bế tắc Nagle/delayed-ACK chỉ xuất hiện khi bên nhận có một khoảng "chưa thể trả lời" giữa lúc nhận mảnh đầu và lúc nhận đủ; một máy chủ trả lời từng mảnh sẽ che nó đi hoàn toàn. Đây cũng chính là lý do bug này khét tiếng khó tái hiện: nó phụ thuộc vào cả cách bên gửi ghi lẫn cách bên nhận trả lời.

Vì sao điều này quan trọng khi lập trình

Hệ quả trực tiếp: các giao thức request/response trên kết nối bền là nạn nhân điển hình — RPC, truy vấn cơ sở dữ liệu, lệnh Redis, một số dạng HTTP giữ kết nối. Nếu client của bạn ghi một yêu cầu thành nhiều lần write() rồi chờ phản hồi, và server chưa trả lời được tới khi nhận đủ, bạn có thể đang trả 40 mili giây cho mỗi lệnh mà không biết. Triệu chứng rất đặc trưng: độ trễ đúng bằng ~40 ms (hoặc bội số của nó), phẳng lì, không phụ thuộc kích thước dữ liệu — dấu vân tay của bộ đếm delayed ACK chứ không phải của mạng.

Lời khuyên "bật TCP_NODELAY" mà bạn nghe khắp nơi chính là để chữa cái này, và với đa số dịch vụ request/response độ trễ thấp thì bật nó là đúng. Nhưng đừng bật một cách máy móc: Nagle tồn tại có lý do — với truyền dữ liệu lớn theo dòng (tải tệp, sao lưu), Nagle giúp gộp gói và giảm số gói tí hon, nên tắt nó ở đó là tự làm mình phí băng thông. Quy tắc thực dụng: tắt Nagle cho lưu lượng "nhỏ, nhạy độ trễ, hỏi-đáp"; giữ Nagle cho lưu lượng "lớn, nặng thông lượng".

Có một cách chữa thứ ba thường tốt hơn cả hai: gộp lần ghi ở tầng ứng dụng. Thay vì write(header) rồi write(body), hãy dựng cả thông điệp trong một bộ đệm rồi ghi một lần (hoặc dùng writev/scatter-gather). Cách này vừa diệt bế tắc Nagle, vừa giảm số lời gọi hệ thống, mà không cần đụng tới cờ socket — và nó đúng đắn bất kể phía bên kia hành xử ra sao.

Con số mang theo: một vòng request/response có thể kẹt đúng ~40 ms khi Nagle và delayed ACK chờ nhau, và nó chỉ hiện ra khi bên gửi ghi thành nhiều mảnh còn bên nhận chưa thể trả lời ngay. Thấy một độ trễ phẳng lì đúng 40 ms trong một giao thức hỏi-đáp, hãy nghĩ tới cặp đôi này trước khi đổ lỗi cho đường truyền.

Thử ba mươi giây

Nếu bạn có một dịch vụ request/response nghi bị dính, đo độ trễ mỗi request và xem nó có bu quanh 40 ms một cách đáng ngờ không. Trong code, thử hai thứ: đặt TCP_NODELAY trên socket (trong Python là sock.setsockopt(IPPROTO_TCP, TCP_NODELAY, 1)), hoặc gộp các lần ghi thành một write() duy nhất. Nếu độ trễ rơi từ hàng chục mili giây xuống dưới một mili giây, bạn vừa bắt tận tay cặp Nagle và delayed ACK.