Có một loại lỗi hiệu năng khiến kỹ sư mất hàng giờ gãi đầu: một dịch vụ request-response bỗng chậm đúng ~40ms mỗi lần gọi, không phải do CPU, không phải do mạng, không phải do database. Thủ phạm là hai tối ưu TCP hoàn toàn hợp lý — thuật toán Nagle và delayed ACK — nhưng khi gặp nhau chúng chờ lẫn nhau và tạo ra một khoảng dừng cố định 40ms. Bài này (phần 3 loạt Mạng) chạy thật để bắt tận tay cú trễ này và hiểu cách tránh.

Cơ chế: hai tối ưu tốt gặp nhau thành xấu

  • Nagle (phía gửi): nếu còn dữ liệu nhỏ chưa được ACK, đừng gửi gói nhỏ mới — hãy giữ lại và gom, để không phí một header TCP 20+ byte cho vài byte dữ liệu. Ý tốt: gộp nhiều mẩu tí hon thành một gói.
  • Delayed ACK (phía nhận): đừng gửi ACK ngay — trì hoãn tới ~40ms để có thể gộp ACK vào gói dữ liệu trả về, tiết kiệm một gói ACK riêng. Cũng là ý tốt.

Vấn đề xảy ra khi cả hai cùng bật trong mẫu write-write-read: bên gửi giữ gói thứ hai để chờ ACK gói thứ nhất; bên nhận lại giữ ACK để chờ dữ liệu trả về. Hai bên chờ nhau cho tới khi bộ đếm delayed-ACK hết hạn — ~40ms trôi qua vô ích.

c.Write(mau1)   // gói 1 gửi ngay
c.Write(mau2)   // Nagle GIỮ: mau1 chưa được ACK -> chờ
c.Read(reply)   // server nhận mau1, delayed-ACK giữ ~40ms rồi mới ACK
// -> mau2 kẹt tới khi ACK về -> mỗi vòng dính ~40ms

Cách tắt Nagle là đặt TCP_NODELAY — trong Go là SetNoDelay(true). Điều đáng chú ý: Go mặc định đã bật NoDelay (tức tắt Nagle), vì phần lớn ứng dụng Go là request-response cần độ trễ thấp.

tc := c.(*net.TCPConn)
tc.SetNoDelay(true)   // TẮT Nagle -> gửi ngay (mặc định của Go)
tc.SetNoDelay(false)  // BẬT Nagle -> gom gói nhỏ

Ảnh chụp đoạn mã Go nền tối minh hoạ Nagle và TCP_NODELAY, cơ chế hai tối ưu tốt gặp nhau thành xấu Nagle bên gửi còn dữ liệu nhỏ chưa được ACK giữ gói nhỏ mới gom lại chờ gửi 1 lần đỡ phí header delayed ACK bên nhận trì hoãn gửi ACK 40ms để gộp ACK vào gói dữ liệu trả về gặp nhau gửi giữ gói chờ ACK nhận giữ ACK chờ dữ liệu cả hai chờ nhau stall tới 40ms, mẫu kinh điển kích hoạt write write read c Write mau1 gói 1 gửi ngay c Write mau2 Nagle giữ mau1 chưa ACK chờ c Read reply server nhận mau1 delayed ACK giữ 40ms rồi mới ACK mau2 kẹt tới khi ACK về mỗi vòng dính 40ms, tắt Nagle bằng SetNoDelay Go mặc định true đã tắt tc SetNoDelay true tắt Nagle gửi ngay không gom mặc định Go tc SetNoDelay false bật Nagle gom gói nhỏ Go bật NoDelay sẵn vì phần lớn app là request-response nhỏ cần độ trễ thấp

Hình 1: Nagle (giữ gói nhỏ chờ ACK) gặp delayed ACK (giữ ACK chờ dữ liệu) tạo stall ~40ms trong mẫu write-write-read; SetNoDelay(true) tắt Nagle — Go bật sẵn mặc định.

Đo thật: bắt tận tay cú trễ 40ms

Mình dựng một mẫu write-write-read (gửi hai mẩu 4 byte rồi chờ hồi) và đo với Nagle bật vs tắt:

Ảnh chụp bảng kết quả chạy thật Nagle output thật, độ trễ mỗi vòng request-response Nagle BẬT SetNoDelay false 40.918 ms mỗi vòng Nagle TẮT SetNoDelay true 5 µs mỗi vòng mặc định của Go Nagle bật chậm hơn 8769 lần, vì sao đúng 40ms 40ms bằng đúng bộ đếm delayed-ACK của Linux write mẩu 1 gửi write mẩu 2 Nagle giữ mẩu 1 chưa ACK server nhận mẩu 1 nhưng chờ 40ms mới ACK delayed ACK ACK về mẩu 2 mới được gửi vòng lặp dính 40ms mỗi lần tái hiện được ngay trên localhost với mẫu write-write-read, kết luận Go mặc định SetNoDelay true đa số app không gặp bẫy này nếu bật Nagle cộng mẫu write-write-read dính 40ms mỗi vòng đo thật sửa gốc gộp dữ liệu thành 1 Write bufio Writer rồi mới đọc Nagle không phải kẻ xấu nó gom nhiều mẩu nhỏ thành 1 gói tiết kiệm header khi ghi liên tục mà không cần độ trễ thấp

Hình 2: Chạy thật — mẫu write-write-read: Nagle bật (SetNoDelay=false) dính 40,918ms/vòng, Nagle tắt (SetNoDelay=true, mặc định Go) chỉ 5µs/vòng — chậm hơn ~8769 lần; 40ms đúng bằng bộ đếm delayed-ACK của Linux.

Đọc kết quả đo được:

  • Cú trễ 40ms có thật và đo được ngay trên localhost: với Nagle bật, mỗi vòng request-response dính 40,918ms — con số này không phải ngẫu nhiên, nó đúng bằng bộ đếm delayed-ACK mặc định của Linux (~40ms). Nhiều người tưởng cú trễ này chỉ xuất hiện trên mạng thực; thực ra chỉ cần đúng mẫu write-write-read là tái hiện được cả trên loopback.
  • Tắt Nagle: 5µs/vòng — nhanh hơn ~8769 lần: khi SetNoDelay(true), gói thứ hai được gửi ngay lập tức, không chờ ACK, nên không có stall. Chênh lệch gần 9000 lần cho thấy đây không phải tối ưu vi mô mà là khác biệt sống-còn cho hệ nhạy độ trễ.
  • Nguyên nhân gốc là mẫu write-write-read: hai lời Write liên tiếp rồi mới Read. Nếu bạn gộp dữ liệu thành một Write (dùng bufio.Writer hay net/http vốn đã gộp), Nagle không có gì để giữ và cú trễ biến mất — kể cả khi Nagle bật.

Đánh đổi cần cân nhắc

Sửa gốc là gộp ghi, không chỉ tắt Nagle. Tắt Nagle (SetNoDelay(true)) chữa triệu chứng, nhưng nguyên nhân thật thường là code ghi dữ liệu thành nhiều mẩu nhỏ rời rạc. Cách sạch hơn: gom một thông điệp hoàn chỉnh vào một buffer rồi ghi một lần (bufio.Writer + Flush). Vừa tránh Nagle, vừa giảm số syscall write. net/http của Go đã làm việc này nên request HTTP thường không dính bẫy.

Nagle không phải kẻ xấu — nó đúng cho một loại tải khác. Đừng vội tắt Nagle ở mọi nơi. Với ứng dụng ghi liên tục nhiều mẩu nhỏ mà không cần độ trễ thấp từng gói (ví dụ đẩy log, ghi dữ liệu dạng luồng mà bên nhận không chờ từng mẩu), Nagle gom chúng thành ít gói hơn, tiết kiệm băng thông và giảm tải header. Tắt Nagle ở đó làm tăng số gói nhỏ vô ích. Chọn theo mẫu giao tiếp, không theo mặc định máy móc.

Delayed ACK ở phía server, bạn không luôn điều khiển được. Cú trễ này là tương tác giữa Nagle (phía bạn) và delayed ACK (phía kia). Bạn thường chỉ chỉnh được phía mình (tắt Nagle hoặc gộp ghi). Trên Linux có TCP_QUICKACK để giảm delayed ACK nhưng nó không bền (kernel reset lại). Vì vậy hướng thực dụng nhất luôn là: sửa mẫu ghi ở ứng dụng của bạn.

Ba ý mang về

  1. Nagle + delayed ACK tạo cú trễ đúng ~40ms trong mẫu write-write-read: đo thật Nagle bật dính 40,918ms/vòng vs tắt 5µs/vòng (~8769 lần) — tái hiện ngay trên localhost, 40ms đúng bằng bộ đếm delayed-ACK của Linux.
  2. Go mặc định tắt Nagle (SetNoDelay=true): nên đa số ứng dụng Go không gặp bẫy; chỉ khi bạn cố bật Nagle hoặc dùng ngôn ngữ/socket khác mặc định bật, cộng mẫu write-write-read, mới dính.
  3. Sửa gốc là gộp ghi thành một Write, và biết khi nào Nagle có ích: dùng bufio.Writer gộp thông điệp rồi mới đọc; đừng tắt Nagle máy móc — nó tiết kiệm header cho tải ghi-liên-tục-nhiều-mẩu-nhỏ không cần độ trễ thấp.

Nguồn

Phần sau ta xuống tầng đệm: socket buffer và listen backlog — SO_RCVBUF/SO_SNDBUF quyết định giữ được bao nhiêu dữ liệu chưa đọc, và hàng đợi backlog quyết định chịu được bao nhiêu kết nối tới dồn dập trước khi từ chối; đo thật khi nào chúng gây nghẽn.