Đâ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.
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:
- Khách
write(nửa 1)— gửi ngay, vì lúc này chưa có gì đang chờ ACK. - Khách
write(nửa 2)— nhưng Nagle giữ lại, vì nửa 1 chưa được ACK. - 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ờ.
- 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ó.
Cú vấp 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. Điều rút ra: 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
Nạn nhân trực tiếp là các giao thức request/response trên kết nối bền — 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.
Cái bẫy này — hai cơ chế đều tối ưu khi đứng riêng nhưng chặn nhau khi kết hợp, và một trong hai cứ chờ thay vì hành động — là một khuôn lỗi tương tác lặp lại khắp lập trình, không riêng TCP. Trong Java, đây đúng là mô hình một deadlock nhẹ hoặc một cặp buffer chờ nhau: một BufferedWriter không được flush() giữ dữ liệu chờ đầy buffer trong khi bên kia chờ dữ liệu để phản hồi — y hệt Nagle giữ "nửa 2"; và cái vân tay "độ trễ phẳng lì đúng một hằng số" cũng là dấu hiệu một timeout/poll interval nào đó đang chi phối chứ không phải công việc thật. Đây cũng chính xác là lý do JDBC driver và Redis client (Jedis/Lettuce) mặc định bật TCP_NODELAY — chúng biết mình là request/response nhỏ nhạy trễ. Go bật TCP_NODELAY mặc định trên mọi net.TCPConn chính vì cái bug này quá phổ biến với dịch vụ Go, và bufio.Writer quên Flush() tạo ra đúng kiểu "dữ liệu kẹt chờ buffer đầy". Cơ sở dữ liệu gặp nó ở tầng protocol: nhiều driver DB từng dính 40ms-stall khi gửi query thành nhiều mảnh, và fix chuẩn là NODELAY + gộp gói protocol. Mẫu số chung: khi hai thành phần mỗi cái tối ưu bằng cách chờ (chờ buffer đầy, chờ ghép, chờ gộp), chúng có thể rơi vào thế mỗi bên chờ hành động của bên kia — và triệu chứng luôn là một độ trễ phẳng lì bằng đúng một hằng số timeout, không phải một đường cong theo tải — nên khi thấy độ trễ neo cứng vào một con số tròn (40ms, 100ms, 1s) bất kể dữ liệu, hãy nghi một cặp cơ-chế-chờ đang khóa nhau, và cách gỡ thường là làm một bên hành động ngay (flush, NODELAY) thay vì chờ.
Muốn tự thấy thì 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.