Ở 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++ }

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:

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ề
- Đọ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.
- 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.
- 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 Closemọi nhánh), không chỉnh kernel.
Nguồn
- RFC 9293 — TCP (State Machine, TIME-WAIT): https://www.rfc-editor.org/rfc/rfc9293#name-state-machine-overview
- Kernel docs — proc/net/tcp và các trạng thái: https://docs.kernel.org/networking/proc_net_tcp.html
- Vincent Bernat — Coping with the TCP TIME-WAIT state: https://vincent.bernat.ch/en/blog/2014-tcp-time-wait-state-linux
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.