Ở bài 1, khi mở/đóng nhiều kết nối test, ta thoáng thấy hàng nghìn socket ở trạng thái TIME_WAIT. Đó không phải lỗi — nhưng nó, cùng với người anh em CLOSE_WAIT, là hai trạng thái socket gây bối rối và sự cố nhiều nhất trong vận hành. Đọc đúng trạng thái socket giúp bạn phân biệt "hệ đang churn kết nối ngắn" (cần tái dùng kết nối) với "code có bug quên đóng kết nối" (rò rỉ tài nguyên). Bài này (phần 9 loạt Mạng) chạy thật, đọc /proc/net/tcp để đo và phân biệt hai trường hợp.

Cơ chế: vòng đời và hai trạng thái đáng ngờ

Một kết nối TCP đi qua nhiều trạng thái, đọc được ở cột st (hex) trong /proc/net/tcp:

Trạng thái hex ý nghĩa
LISTEN 0A server đang nghe
ESTABLISHED 01 kết nối đang hoạt động
FIN_WAIT1/2 04/05 đang đóng, đã gửi FIN
TIME_WAIT 06 bên đóng chủ động giữ ~2×MSL sau khi đóng
CLOSE_WAIT 08 đã nhận FIN của bên kia nhưng chưa gọi Close()

TIME_WAIT xảy ra ở bên đóng chủ động (gửi FIN trước). Nó giữ socket khoảng 2×MSL (~60s trên Linux) để dọn gói lạc còn sót trên đường và đảm bảo ACK cuối tới bên kia (đóng sạch). Đây là hành vi đúng của TCP — nhưng mở/đóng nhiều kết nối ngắn để lại hàng nghìn TIME_WAIT cùng lúc, ăn hết cổng cục bộ (ephemeral port), gây lỗi "cannot assign requested address".

CLOSE_WAIT thì khác: khi bên kia đóng (gửi FIN), socket của ta vào CLOSE_WAIT, chờ ta gọi Close(). Nếu code quên Close, nó kẹt CLOSE_WAIT mãi — rò rỉ file descriptor.

// đọc trạng thái từ /proc/net/tcp: cột 4 = state hex
ff := strings.Fields(line); st := ff[3]
switch st { case "06": timeWait++; case "08": closeWait++ }

Ảnh chụp đoạn mã Go nền tối minh hoạ trạng thái socket TCP, vòng đời và các trạng thái chính cột st hex trong proc net tcp LISTEN 0A server đang nghe ESTABLISHED 01 kết nối đang hoạt động FIN_WAIT 04 05 đang đóng đã gửi FIN TIME_WAIT 06 bên đóng chủ động giữ 2 MSL 60s sau khi đóng CLOSE_WAIT 08 đã nhận FIN của bên kia nhưng chưa gọi Close, TIME_WAIT bình thường nhưng nhiều bằng churn kết nối ngắn bên gửi FIN trước đóng chủ động vào TIME_WAIT giữ 60s để dọn gói lạc đảm bảo ACK cuối tới bên kia mở đóng nhiều kết nối ngắn hàng nghìn TIME_WAIT ăn hết cổng cục bộ ephemeral cannot assign requested address, CLOSE_WAIT dấu hiệu bug quên Close bên kia đóng gửi FIN socket ta vào CLOSE_WAIT chờ ta gọi Close nếu code quên Close kẹt CLOSE_WAIT mãi rò rỉ file descriptor too many open files conn ln Accept quên conn Close CLOSE_WAIT tích lũy không tự giảm, đọc trạng thái từ proc net tcp ff strings Fields line st ff 3 cột 4 state hex switch st case 06 timeWait case 08 closeWait

Hình 1: Vòng đời TCP với các trạng thái đọc từ /proc/net/tcp; TIME_WAIT ở bên đóng chủ động (giữ ~60s, bình thường); CLOSE_WAIT khi nhận FIN mà chưa Close (bug quên đóng).

Đo thật: đếm TIME_WAIT và CLOSE_WAIT

Mình dựng hai kịch bản: (1) mở/đóng nhiều kết nối ngắn; (2) server quên Close khi client đóng:

Ảnh chụp bảng kết quả chạy thật trạng thái socket output thật, một TIME_WAIT mở đóng 3000 kết nối ngắn client chủ động đóng TIME_WAIT trước 0 sau 3091 tăng 3091 mỗi kết nối ngắn đóng chủ động để lại 1 TIME_WAIT 60s churn hàng nghìn TIME_WAIT cùng lúc ăn cổng cục bộ lỗi cannot assign requested address khi hết cổng ephemeral, hai CLOSE_WAIT server quên Close sau khi client đóng số client đã đóng 200 CLOSE_WAIT server chưa Close 200 client gửi FIN socket server vào CLOSE_WAIT chờ server Close server quên Close 200 CLOSE_WAIT kẹt mãi không tự giảm rò rỉ file descriptor too many open files, phân biệt bình thường vs bug TIME_WAIT nhiều churn kết nối ngắn bình thường về cơ chế nhưng nên tránh dùng keep-alive pool bài 5 6 CLOSE_WAIT nhiều bug code quên gọi Close sửa code không chỉnh kernel kiểm mọi nhánh kể cả lỗi đều Close, kết luận proc net tcp cho đếm trạng thái không cần cài ss TIME_WAIT là hệ quả tự nhiên của đóng chủ động nhiều churn CLOSE_WAIT tăng và không giảm luôn là bug ứng dụng quên Close

Hình 2: Chạy thật — mở/đóng 3000 kết nối ngắn để lại 3091 TIME_WAIT (từ 0); server quên Close sau khi 200 client đóng để lại đúng 200 CLOSE_WAIT kẹt mãi.

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

  • 3000 kết nối ngắn → 3091 TIME_WAIT: mỗi kết nối client mở rồi chủ động đóng để lại một socket TIME_WAIT ở phía client, giữ ~60s. Con số nhảy từ 0 lên hơn 3000. Đây là hành vi đúng của TCP, nhưng ở quy mô lớn (dịch vụ mở kết nối mới cho mỗi request) sẽ tích lũy hàng chục nghìn TIME_WAIT, ăn hết dải cổng cục bộ (~28k cổng ephemeral mặc định) và gây lỗi "cannot assign requested address".
  • Quên Close → đúng 200 CLOSE_WAIT: 200 client đóng kết nối, nhưng server không gọi Close() — kết quả là đúng 200 socket ở CLOSE_WAIT, và chúng không tự giảm. Đây là chữ ký của một bug rò rỉ: mỗi kết nối bị bỏ quên tốn một file descriptor, tích lũy dần tới "too many open files".
  • Phân biệt bình thường vs bug: TIME_WAIT tự giảm sau ~60s và là hệ quả tự nhiên của đóng chủ động — nhiều TIME_WAIT nghĩa là churn kết nối ngắn, nên tránh bằng cách tái dùng kết nối. CLOSE_WAIT không tự giảm và luôn là dấu hiệu code quên Close — phải sửa code, không phải chỉnh kernel.

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

TIME_WAIT nhiều: sửa bằng tái dùng kết nối, không phải bằng "mẹo kernel". Cám dỗ phổ biến là bật net.ipv4.tcp_tw_reuse hay giảm tcp_fin_timeout để "dọn" TIME_WAIT. Nhưng đó là chữa triệu chứng và có rủi ro (gói cũ lẫn vào kết nối mới). Nguyên nhân gốc là churn kết nối ngắn — lời giải đúng là keep-alive và connection pool (bài 5, 6) để bắt tay ít lần hơn, sinh ra ít kết nối cần đóng hơn. TIME_WAIT là cái giá của đóng kết nối; đừng đóng nhiều thế.

Ai đóng chủ động thì mang TIME_WAIT — thiết kế để nó rơi vào đúng chỗ. TIME_WAIT nằm ở bên gửi FIN trước. Trong mô hình client-server, nếu để server đóng chủ động, server sẽ tích lũy TIME_WAIT — nguy hiểm vì server dùng một cổng cố định, dễ cạn tài nguyên hơn. Thường tốt hơn khi để client đóng chủ động (client có nhiều cổng ephemeral hơn để phân tán). Đây là một quyết định thiết kế giao thức ứng dụng, không chỉ là chi tiết kỹ thuật.

CLOSE_WAIT luôn là bug — kiểm mọi nhánh thoát. Không có "CLOSE_WAIT bình thường ở mức cao". Nếu thấy CLOSE_WAIT tăng và không giảm, chắc chắn có đường code quên Close(). Cái bẫy hay gặp: chỉ Close ở nhánh thành công mà quên ở nhánh lỗi/exception. Trong Go, defer conn.Close() ngay sau khi mở là cách an toàn nhất; và với resp.Body, luôn defer resp.Body.Close() kể cả khi request lỗi (nếu resp không nil).

Ba ý mang về

  1. Đọc /proc/net/tcp phân loại trạng thái không cần công cụ ngoài: cột st hex cho biết LISTEN/ESTABLISHED/TIME_WAIT/CLOSE_WAIT — đo thật đếm được số socket mỗi trạng thái ngay trong Go.
  2. TIME_WAIT nhiều là churn kết nối ngắn, không phải lỗi: đo thật 3000 kết nối ngắn để lại 3091 TIME_WAIT (~60s mỗi cái) ăn cổng cục bộ; sửa bằng keep-alive/pool để tái dùng kết nối, không phải mẹo kernel.
  3. CLOSE_WAIT tăng không giảm luôn là bug quên Close: đo thật server quên Close cho 200 client đóng để lại đúng 200 CLOSE_WAIT kẹt mãi (rò rỉ fd); sửa ở code (defer Close mọi nhánh), không chỉnh kernel.

Nguồn

Phần sau ta làm rõ hai khái niệm hay bị nhầm: băng thông và độ trễ — throughput không phải RTT, và bandwidth-delay product giải thích vì sao đường "nhanh" (băng thông cao) vẫn có thể truyền chậm khi độ trễ lớn; đo thật để tách hai thứ này.