Phần 3 xong phần byte trên dây. Phần này về một câu hỏi mà mọi kết nối sống lâu đều gặp và TCP cố tình không trả lời: đầu kia còn đó không? Khi tiến trình client chết, kernel của nó gửi FIN hoặc RST và server biết ngay. Nhưng khi máy client mất điện, điện thoại chuyển từ Wi-Fi sang 4G, hay một NAT giữa đường quên mất kết nối, thì không có gói nào được gửi cả. Socket phía server vẫn ESTABLISHED, goroutine vẫn chờ đọc, bộ đếm "người đang online" vẫn tính người đó. Bài này dựng đúng tình huống đó bằng iptables DROP hai chiều, chạy song song sáu cấu hình server, và đo từng cấu hình nhận ra kết nối chết sau bao nhiêu giây.
TCP chỉ phát hiện khi có gói cần ACK

Hình 1: Hai kiểu chết, sáu cấu hình server, và mẫu ping/pong với ReadDeadline gia hạn trong PongHandler.
Có hai kiểu chết, và chúng khác nhau hoàn toàn. Tiến trình chết: kernel của máy client vẫn sống, nó đóng socket thay tiến trình, gửi FIN hoặc RST, và server đọc được EOF ngay. Máy hoặc mạng chết: không còn ai gửi gì. TCP chỉ phát hiện lỗi khi có gói cần được ACK mà không được; một kết nối idle không có gói nào nên không bao giờ biết. Ngay cả khi server đang gửi, TCP sẽ retransmit theo luỹ thừa cho tới tcp_retries2 (mặc định 15 lần, khoảng 15 đến 30 phút) rồi mới báo lỗi, và trong suốt thời gian đó mọi lời gọi ghi đều trả về thành công vì dữ liệu chỉ nằm trong send buffer của kernel.
Có ba công cụ để phát hiện sớm hơn. TCP keepalive: kernel tự gửi gói thăm dò khi kết nối idle; Go bật sẵn cho mọi listener của net/http với idle 15 s và khoảng cách 15 s, số probe lấy từ sysctl (9 trên máy này), nên theo lý thuyết là 15 + 9 × 15 = 150 s. Go 1.23 có net.KeepAliveConfig để chỉnh cả ba tham số. ReadDeadline: hạn đọc ở tầng ứng dụng, nhưng nếu đứng một mình thì một client khoẻ mà im lặng cũng bị cắt. Ping/pong WebSocket: server gửi khung ping định kỳ, client (kể cả trình duyệt) tự trả pong, và server gia hạn ReadDeadline trong PongHandler; không pong về đúng hạn là kết nối chết. Sáu cấu hình trong bài là: none (tắt keepalive, không gì cả), godef (mặc định Go), ka (keepalive idle 5 s, 3 probe cách 2 s), ping (ping 5 s, pongWait 8 s), deadline (ReadDeadline 10 s, không ping), push (server đẩy 20 byte mỗi giây, không keepalive).
Công cụ là một chương trình Go với server có endpoint /status liệt kê từng kết nối, lúc mở, lúc đóng và lỗi đọc, cùng endpoint /mark ghi mốc rút cáp. Client mở một kết nối cho mỗi cấu hình, đọc để PingHandler mặc định của gorilla trả pong, đếm byte, và thoát bằng os.Exit không gửi Close.
Đo thật: tiến trình chết, rồi rút cáp

Hình 2: Tiến trình chết được thấy sau 3 ms ở mọi cấu hình; rút cáp thì ping 4,9 s, keepalive chỉnh 8,4 s, Go mặc định 150 s, tắt keepalive và đẩy dữ liệu đều "sống" hết 300 s.
| phép đo | kết quả |
|---|---|
| tiến trình client thoát không Close, mọi cấu hình trừ deadline | nhận ra sau ~3 ms (close 1006) |
| deadline 10 s với client khoẻ nhưng im | giết nhầm sau 10,005 s |
| rút cáp: ping 5 s / pongWait 8 s | 4,934 s |
| rút cáp: TCP keepalive idle 5 s, 3 probe × 2 s | 8,422 s (11,49 s từ gói cuối) |
| rút cáp: keepalive mặc định của Go | 150,247 s |
| rút cáp: tắt keepalive, idle | còn sống tới 300 s |
| rút cáp: server đẩy 20 B/s, không keepalive | 300 lần ghi thành công, còn sống tới 300 s, backoff 10 |
| chi phí heartbeat (ping 2 B + pong 6 B) | 8 B mỗi nhịp |
Tiến trình chết thì ai cũng thấy, trong 3 mili giây. Pha A: client giữ sáu kết nối, im lặng 20 giây rồi os.Exit. Năm cấu hình đều ghi "đóng sau 20,003 s kể từ mở" với lỗi close 1006 (abnormal closure): unexpected EOF: kernel client gửi FIN thay tiến trình, server biết sau 3 ms. Đây là lý do nhiều hệ thống "chạy ổn ở local": khi dev bấm Ctrl-C ở client, server luôn thấy; bài test không bao giờ dựng được kiểu chết thứ hai. Cấu hình deadline thì khác: nó đóng kết nối ở giây thứ 10, khi client vẫn đang sống và chỉ đơn giản là không có gì để nói. Một ReadDeadline không kèm ping là bộ đếm tử thần cho mọi client im lặng.
Rút cáp: ping/pong thắng với 4,9 giây, và con số đó nằm trong tay bạn. Pha B: DROP hai chiều ở server lúc t = 0, không FIN không RST. Cấu hình ping nhận ra sau 4,934 s: pong cuối về trước mốc rút khoảng 3 s, cộng pongWait 8 s. Chặn trên luôn là pongWait, chặn dưới là pongWait trừ pingPeriod; bạn chỉnh hai số đó là chỉnh được thời gian phát hiện, không phụ thuộc hệ điều hành. Cấu hình ka nhận ra sau 8,422 s, tức 11,49 s kể từ gói cuối, khớp idle 5 + 3 × 2 = 11 s. TCP keepalive hoạt động tốt khi bạn chỉnh được nó, nhưng Go chỉ cho chỉnh đủ ba tham số từ 1.23, và trên nhiều nền tảng hay qua load balancer, keepalive của bạn dừng ở hop đầu tiên.
Mặc định của Go là 150 giây, đúng tới từng giây như công thức. godef nhận ra sau 150,247 s: 15 s idle cộng 9 probe cách 15 s. Không phải "mãi mãi", nhưng với một ứng dụng realtime đó là hai phút rưỡi server vẫn fan-out vào một kết nối chết, vẫn đếm người đó online, vẫn giữ goroutine và buffer. Và con số 9 là sysctl tcp_keepalive_probes của máy chủ chứ không phải của Go; đổi máy, đổi container runtime là đổi số.
Tắt keepalive thì không bao giờ; và "có gửi là biết" cũng không. none còn ESTABLISHED suốt 300 s, chỉ đóng khi gỡ DROP và kill client, với EOF. push thú vị hơn: server ghi 20 byte mỗi giây, 300 lần đều trả nil, trong khi ss -ti cho thấy backoff:8 ở giây 60 và backoff:10 ở giây 240, lastrcv tăng đều tới 243 giây. Dữ liệu nằm trong send buffer của kernel, TCP retransmit theo luỹ thừa, và ứng dụng tin rằng mình đã gửi. Với tcp_retries2 = 15, lỗi ghi sẽ đến sau khoảng 15 đến 30 phút. Việc server có gửi dữ liệu không làm nó phát hiện nhanh hơn; chỉ có thứ cần phản hồi trong hạn mới làm được.
Chi phí của ping/pong: 8 byte mỗi nhịp. Trong 20 s, kết nối ping nhận 8 byte (4 ping × 2 byte) và gửi 24 byte (4 pong × 6 byte, vì client phải mask). Ping mỗi 30 s cho 10.000 kết nối là 10.000 × 8 / 30 ≈ 2,7 KB/s cho cả hệ thống: không đáng kể so với bất kỳ payload nào.
Đánh đổi cần cân nhắc
pingPeriod và pongWait là đánh đổi giữa phát hiện nhanh và báo động giả. Mạng di động có những khoảng im 5 đến 10 giây mà kết nối vẫn sống; pongWait 8 s ở đây sẽ cắt nhầm trong tình huống đó. Giá trị hay gặp trên production là ping 20 đến 30 s, pongWait 1,5 đến 2 lần ping. Nhớ rằng phát hiện ở server chỉ là một nửa: client cũng cần phát hiện server chết để reconnect, chủ đề của phần 8.
TCP keepalive và ping/pong không thay thế nhau. Keepalive miễn phí về CPU và không cần code ở client, nhưng dừng ở proxy hoặc load balancer đầu tiên (nó chỉ biết TCP tới hop kế), tham số phụ thuộc hệ điều hành, và không hoạt động với client WebSocket trong trình duyệt theo cách bạn kiểm soát. Ping/pong đi xuyên mọi proxy hiểu WebSocket, trình duyệt tự trả pong, và tham số nằm trong code của bạn. Dùng cả hai cũng được: keepalive ngắn như lưới an toàn cho tầng TCP, ping/pong là cơ chế chính.
Phạm vi đo. Mạng Docker, DROP tuyệt đối hai chiều; mạng thật có những kiểu chết "một nửa" (chỉ mất một chiều) mà ping/pong vẫn bắt được nhưng số đo sẽ khác. Chưa đo: ping từ phía client, chi phí CPU của 10.000 timer ping (phần 6), và hành vi qua nginx với proxy_read_timeout, nơi một pong không về kịp còn có thể do proxy chứ không do client.
Ba ý mang về
- TCP không báo khi đầu kia biến mất mà không gửi FIN: tắt keepalive thì kết nối idle sống mãi, và server đẩy dữ liệu liên tục vẫn "ghi thành công" 300 lần vào hư không. Phát hiện sớm là việc của tầng trên.
- Ping/pong với pongWait là cơ chế duy nhất bạn kiểm soát trọn vẹn: 4,9 s ở đây, chi phí 8 byte mỗi nhịp, đi xuyên proxy và trình duyệt. TCP keepalive chỉnh tay cho 8,4 s nhưng phụ thuộc hệ điều hành và dừng ở hop đầu; mặc định của Go là 150 s.
- ReadDeadline không có ping là giết nhầm: nó cắt client khoẻ sau đúng 10 s chỉ vì im lặng. Deadline phải luôn đi kèm thứ gì đó định kỳ buộc đầu kia phải trả lời.
Nguồn
- RFC 6455 — mục 5.5.2 Ping và 5.5.3 Pong: https://www.rfc-editor.org/rfc/rfc6455#section-5.5.2
- Go 1.23 —
net.KeepAliveConfigvàTCPConn.SetKeepAliveConfig: https://pkg.go.dev/net#KeepAliveConfig - Linux
tcp(7)—tcp_keepalive_time,tcp_keepalive_intvl,tcp_keepalive_probes,tcp_retries2: https://man7.org/linux/man-pages/man7/tcp.7.html - gorilla/websocket —
Conn.SetPongHandler,Conn.WriteControl, ví dụ chat vớipingPeriod/pongWait: https://pkg.go.dev/github.com/gorilla/websocket ss(8)— các trườngrto,backoff,lastsnd,lastrcvtrongss -ti: https://man7.org/linux/man-pages/man8/ss.8.html
Phần sau đổi vai: client còn sống nhưng đọc chậm. Server gửi nhanh hơn client nhận được thì byte đi đâu, bộ nhớ server phình thế nào, và ba cách ứng xử (chặn, bỏ bớt, ngắt) đo trên cùng một kịch bản.