Đây là bài về một lỗi hiệu năng có tính chất khó chịu nhất: nó không tồn tại trên máy lập trình viên và trong CI, rồi làm gấp đôi độ trễ khi lên môi trường thật.
Bốn phép đo trên mạng nhanh
Hai container trên cùng một máy. Máy chủ vọng lại, máy khách gửi 200 byte rồi chờ trả lời. Hai biến: bật Nagle hay TCP_NODELAY, và ghi 200 byte trong một lời gọi send hay chia thành hai lời gọi 100 byte.
| Trung vị | p95 | |
|---|---|---|
| Nagle bật, ghi một lần | 41,0 µs | 48,0 µs |
| Nagle bật, ghi hai lần | 42,1 µs | 51,5 µs |
NODELAY, ghi một lần |
41,4 µs | 47,2 µs |
NODELAY, ghi hai lần |
43,2 µs | 53,4 µs |
Bốn dòng giống hệt nhau. Kết luận hợp lý: Nagle không ảnh hưởng gì, chuyện chia mấy lần ghi cũng không.
Kết luận đó sai.
Cùng mã nguồn, thêm 20 ms độ trễ
tc qdisc add dev eth0 root netem delay 20ms
Không sửa một dòng nào trong chương trình:
| Trung vị | p95 | |
|---|---|---|
| Nagle bật, ghi một lần | 21.219 µs | 24.647 µs |
| Nagle bật, ghi hai lần | 42.228 µs | 45.994 µs |
NODELAY, ghi một lần |
21.019 µs | 23.266 µs |
NODELAY, ghi hai lần |
21.187 µs | 23.080 µs |
Dòng thứ hai gấp đôi ba dòng còn lại. Ghi 200 byte thành hai lần thay vì một lần tốn thêm nguyên một vòng khứ hồi — và chỉ khi Nagle bật.
Hai thuật toán hợp lý, gặp nhau thì hỏng
Nagle (RFC 896): khi đang có dữ liệu đã gửi mà chưa được báo nhận, giữ lại gói nhỏ tiếp theo cho tới khi ACK về hoặc gom đủ một gói đầy. Mục đích: chặn việc gửi hàng nghìn gói một byte, mỗi gói kéo theo 40 byte tiêu đề.
Delayed ACK (RFC 1122): bên nhận chờ tới 40 ms trước khi gửi ACK, hy vọng có dữ liệu của chính nó để gửi kèm. Mục đích: giảm số gói chỉ chứa ACK.
Cả hai đều đúng khi đứng riêng. Gặp nhau trong mẫu ghi – ghi – đọc thì thành khoá chéo:
- Ứng dụng gửi mẩu thứ nhất. Nó đi ngay.
- Ứng dụng gửi mẩu thứ hai. Nagle giữ lại — mẩu một chưa được ACK.
- Bên nhận có mẩu một, nhưng chưa đủ để trả lời. Delayed ACK giữ ACK lại chờ dữ liệu đi kèm.
- Bế tắc, cho tới khi hết hạn hẹn giờ.
Vì sao nó biến mất trên mạng nhanh
Trên cùng một máy, ACK của lần ghi thứ nhất quay về trong vài chục micro giây — trước khi chương trình kịp gọi send lần thứ hai. Khi mẩu hai tới, không còn dữ liệu nào chưa ACK, nên Nagle không giữ gì cả.
Trên mạng có độ trễ, mẩu hai được ghi trước khi ACK về. Lúc đó Nagle mới có việc để làm, và nó làm đúng thiết kế.
Nói lại lần thứ hai vì đây là điều đáng nhớ nhất: độ trễ mạng không làm lộ ra một lỗi có sẵn — nó là điều kiện để lỗi tồn tại. Không có cách nào thấy nó trong môi trường phát triển trên một máy.
Ba nơi nó xuất hiện thật:
- Dịch vụ gọi nhau qua nhiều vùng sẵn sàng (5–2 ms) hoặc nhiều vùng địa lý (20–150 ms).
- Máy khách di động và Wi-Fi.
- Bất cứ đâu có một tầng proxy hoặc lưới dịch vụ chen vào giữa.
Cách sửa: đừng ghi làm hai lần
Đây là điểm gây tranh cãi. Cách chữa được nhắc nhiều nhất là bật TCP_NODELAY:
int on = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on));
conn.(*net.TCPConn).SetNoDelay(true) // Go: da mac dinh bat
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
Và bảng đo ở trên cho thấy nó có tác dụng. Nhưng nó chữa triệu chứng.
Nguyên nhân là chương trình gọi send hai lần cho một thông điệp. Ghép tiêu đề và thân vào một bộ đệm rồi ghi một lần thì Nagle không có gì để giữ, và bảng trên cho thấy dòng "Nagle bật, ghi một lần" nhanh đúng bằng NODELAY.
/* thay vi */
write(fd, header, 100);
write(fd, body, 100);
/* dung */
struct iovec v[2] = {{header,100},{body,100}};
writev(fd, v, 2); /* mot goi, mot loi goi he thong */
Hoặc dùng bộ đệm ở tầng trên, đúng như phần 26 đã bàn — và cách đó còn giải quyết luôn chi phí lời gọi hệ thống.
Làm cả hai là tốt nhất: TCP_NODELAY để không bao giờ gặp lại, và ghi một lần để không phụ thuộc vào nó.
Khi nào không nên bật TCP_NODELAY
Với luồng dữ liệu lớn liên tục — tải tệp, sao chép dữ liệu — Nagle có ích thật: nó gom các mẩu cuối thành gói đầy. Bật NODELAY ở đó làm tăng số gói mà không cải thiện gì.
Quy tắc: NODELAY cho giao thức hỏi–đáp, để mặc định cho luồng dữ liệu. Phần lớn thư viện hiện đại đã bật sẵn cho HTTP và RPC.
TCP_QUICKACK là nút chỉ dùng được một lần
Phía nhận có một nút tương ứng để tắt delayed ACK:
setsockopt(fd, IPPROTO_TCP, TCP_QUICKACK, &on, sizeof(on));
Bẫy: nó không dính. Nhân tự đặt lại về 0 sau vài gói, nên muốn giữ phải gọi lại liên tục. Đó là một API kỳ lạ và là lý do gần như không ai dùng nó trong sản phẩm.
Nagle thì TCP_NODELAY đặt một lần là xong. Nên chữa ở phía gửi luôn là lựa chọn đúng.
Giới hạn của phép đo này
Độ trễ do tc netem tạo ra là độ trễ đều, không có mất gói, không có biến thiên. Mạng thật có cả ba, và với mất gói thì mọi con số ở trên đều tệ hơn.
Tôi cũng không quan sát được cú đình trệ 40 ms kinh điển — con số thường được nhắc khi nói về Nagle. Phép đo cho đúng một vòng khứ hồi thêm vào (21 ms), không phải 40 ms. Nhân Linux hiện đại có nhiều cải tiến làm delayed ACK thích ứng hơn so với thời con số 40 ms ra đời, và tôi không đo được điều kiện để nó nổ đủ 40 ms.
Ghi lại đây để nếu bạn tìm dấu hiệu "đúng 40 ms" trong biểu đồ của mình mà không thấy, đừng vội kết luận là không có vấn đề. Dấu hiệu thật là độ trễ bằng bội số nguyên của vòng khứ hồi.
Thử ba mươi giây
Xem kết nối của bạn có bật TCP_NODELAY không, và tìm mẫu ghi–ghi–đọc:
# dem so lan send cho moi lan recv
strace -f -e trace=sendto,send,write,recvfrom,recv,read -p <pid> 2>&1 | \
head -60 | grep -cE '^\[?.*(send|write)\('
# xem do tre co phai boi so cua RTT khong
ss -tim state established | grep -A1 ':<cong-cua-ban>' | grep -o 'rtt:[0-9.]*'
Nếu bạn thấy hai lời gọi write liên tiếp rồi tới một read, và độ trễ đo được xấp xỉ hai lần giá trị rtt mà ss báo, bạn đang nhìn thẳng vào lỗi này.
Phần sau: bắt gói và đọc gói — tcpdump, ss, và những gì chúng cho biết.