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

Ảnh chụp đoạn mã nền tối kết nối chết TCP không báo ai phải báo với Go 1.23 gorilla websocket 1.5.3 server dist-lab client go-lab rút cáp bằng iptables DROP không FIN RST, hai kiểu chết khác hẳn nhau gồm tiến trình client chết kernel của nó vẫn sống gửi FIN RST server đọc được EOF ngay lập tức, máy hoặc mạng client chết không còn ai gửi gì socket server ESTABLISHED mãi im lặng tuyệt đối, TCP chỉ phát hiện khi có gói cần ACK kết nối idle không có gói nào không bao giờ biết, sáu cấu hình server cùng một client im lặng gồm none tắt TCP keepalive không deadline không ping để mặc định của người không biết, godef mặc định net http Go keepalive idle 15 s 15 s mỗi probe 9 probe bằng 15 cộng 9 nhân 15 bằng 150 s, ka TCP keepalive chỉnh net.KeepAliveConfig idle 5 s 3 probe cách 2 s bằng 5 cộng 3 nhân 2 bằng 11 s, ping WebSocket ping mỗi 5 s pong phải về trong 8 s ReadDeadline gia hạn ở PongHandler, deadline chỉ ReadDeadline 10 s không ping client khoẻ nhưng im cũng bị giết, push server đẩy 20 byte mỗi giây không keepalive có gửi là biết retransmit tới tcp_retries2, mã phía server cho cấu hình ping mẫu hay dùng gồm c.SetReadDeadline time.Now Add pongWait 8 s, c.SetPongHandler func string error c.SetReadDeadline time.Now Add pongWait return nil, go func for range time.Tick pingPeriod 5 s phải nhỏ hơn pongWait c.WriteControl websocket.PingMessage nil time.Now Add 2 giây, vòng đọc ReadMessage trả i o timeout khi quá pongWait không có gì về kết nối chết, đo gì gồm A client khoẻ im lặng 20 s rồi thoát không Close cấu hình nào giết nhầm nhận ra thoát sau bao lâu, B 6 kết nối mở iptables DROP ở server lúc t bằng 0 hỏi status tới 300 s mỗi cấu hình nhận ra sau bao lâu, ss -ti ở 60 s và 240 s xem TCP retransmit thế nào byte heartbeat trên một kết nối

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

Ảnh chụp output thật nền tối ping 4,9 s keepalive 8,4 s Go mặc định 150 s với server dist-lab 8200 client go-lab iptables DROP hai chiều status hỏi tới t bằng 300 s, A client khoẻ im lặng 20 s rồi thoát không Close server thấy gì gồm none godef ka ping push đóng sau 20,003 s kể từ mở nhận ra tiến trình chết sau khoảng 3 ms 1006 EOF, deadline đóng sau 10,005 s i o timeout giết nhầm client đang sống chỉ vì nó im, heartbeat 20 s cấu hình ping nhận 8 B 4 ping nhân 2 B gửi 24 B 4 pong nhân 6 B mask 8 B mỗi nhịp, B rút cáp lúc t bằng 0 server nhận ra sau bao lâu kể từ mốc rút gồm ping 4,934 s i o timeout bằng 8 s kể từ pong cuối pongWait, deadline 6,936 s i o timeout bằng 10 s kể từ mở A cho thấy nó giết nhầm, ka 8,422 s connection timed out bằng 11,49 s từ gói cuối xấp xỉ idle 5 cộng 3 nhân 2 s, godef 150,247 s connection timed out bằng 15 cộng 9 nhân 15 s đúng như tính 2,5 phút, none sống tới 300 s chỉ đóng khi gỡ DROP EOF không gói nào TCP không bao giờ biết, push sống tới 300 s 300 lần ghi đều thành công ss rto 201 backoff 10 chờ tcp_retries2 bằng 15, ss -ti trên server kết nối push gồm t bằng 60 s ESTAB rto 201 backoff 8 lastsnd 346 ms lastrcv 63.352 ms 63 s không nhận gì, t bằng 240 s ESTAB rto 201 backoff 10 lastsnd 643 ms lastrcv 243.649 ms vẫn ESTAB, WriteMessage trả nil suốt 300 lần dữ liệu nằm trong send buffer của kernel ứng dụng tưởng đã gửi, nói thẳng Go mặc định có keepalive 150 s không phải mãi mãi nhưng là 2,5 phút gửi vào hư không số 9 probe là sysctl tcp_keepalive_probes đổi máy là đổi số ping pong là thứ ứng dụng kiểm soát trọn

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ề

  1. 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.
  2. 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.
  3. 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

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.