TIME_WAIT, CLOSE_WAIT & vì sao server hết port
4-way termination tháo từng chiều độc lập, TIME_WAIT giữ port 60 giây để nuốt gói trễ, CLOSE_WAIT là bug ứng dụng không gọi close() — phân biệt hai cái này giúp bạn debug hết port đúng chỗ.
TL;DR: Đóng kết nối TCP cần 4 bước vì full-duplex — mỗi chiều gửi FIN riêng và được xác nhận riêng. Phía chủ động đóng rơi vào TIME_WAIT trong 2 × MSL (thường ~60 giây trên Linux) để nuốt gói trễ và đảm bảo ACK cuối tới đích. TIME_WAIT là bình thường và cần thiết. CLOSE_WAIT thì khác — nó xảy ra khi ứng dụng nhận FIN của đầu kia nhưng không gọi close(), nghĩa là có bug code. Trên proxy hoặc client tải cao mở nhiều kết nối outbound, TIME_WAIT tích luỹ nhanh có thể làm cạn dải ephemeral port (~32 768–60 999), khiến hệ thống không mở thêm kết nối mới được.
Bạn đã thấy ở bài 01 — TCP 3-way handshake rằng mở kết nối tốn 1 RTT và để lại cả hai phía ở ESTABLISHED. Bài này mổ chiều ngược lại: đóng kết nối.
1. Analogy — Cuộc điện thoại hai đường độc lập
Tưởng tượng bạn và đồng nghiệp kết nối qua một hệ thống nội bộ có hai đường thoại riêng: đường A (bạn nói) và đường B (họ nói). Để kết thúc cuộc gọi sạch sẽ:
- Bạn nói: "Tôi xong rồi, cúp máy đường A của tôi."
- Họ đáp: "Nghe rõ, đường A đã cúp."
- Họ nói: "Phần tôi cũng xong, cúp đường B của tôi."
- Bạn đáp: "Nghe rõ, đường B đã cúp."
Đây đúng là 4-way TCP termination: hai đường (chiều) đóng độc lập, mỗi chiều cần một FIN và một ACK.
| Cuộc điện thoại | TCP |
|---|---|
| "Tôi xong, cúp đường A" | FIN từ phía chủ động đóng |
| "Nghe rõ đường A" | ACK từ phía bị động |
| "Phần tôi cũng xong" | FIN từ phía bị động |
| "Nghe rõ đường B" | ACK cuối từ phía chủ động đóng |
Handshake mở 3 bước vì bước 2 gộp SYN+ACK (server xác nhận + gửi ISN của mình trong một gói). Đóng kết nối cần 4 bước vì hai chiều không kết thúc cùng một lúc — phía bị động có thể còn dữ liệu để gửi trước khi nó cũng gửi FIN.
2. 4-way termination — đọc từng cờ
TCP full-duplex nên việc đóng diễn ra từng chiều bằng cờ FIN (finish). Phía nào gửi FIN trước gọi là phía chủ động đóng (active closer).
sequenceDiagram
participant C as Client (chu dong dong)
participant S as Server
Note over C: ESTABLISHED
Note over S: ESTABLISHED
C->>S: FIN seq=u
Note over C: FIN_WAIT_1
S-->>C: ACK ack=u+1
Note over S: CLOSE_WAIT
Note over C: FIN_WAIT_2
S-->>C: FIN seq=v
Note over S: LAST_ACK
C->>S: ACK ack=v+1
Note over C: TIME_WAIT (2xMSL)
Note over S: CLOSED- Bước 1 —
FIN(client → server): client báo hiệu đã gửi hết dữ liệu chiều client→server. Chiều server→client vẫn mở hoàn toàn. - Bước 2 —
ACK(server → client): server xác nhận. Server rơi vàoCLOSE_WAIT— nó biết client không còn gửi nữa, nhưng bản thân server có thể vẫn còn dữ liệu để gửi (vd: đang flush buffer hoặc cần thông báo kết quả). Ứng dụng server phải chủ động gọiclose()khi sẵn sàng. - Bước 3 —
FIN(server → client): khi ứng dụng server xong, nó gọiclose(), kernel gửiFIN. Server chuyển sangLAST_ACK— chờ xác nhận cuối. - Bước 4 —
ACK(client → server): client xác nhậnFINcủa server. Client rơi vàoTIME_WAITvà chờ một khoảng thời gian trước khi thực sựCLOSED. Server khi nhậnACK→CLOSEDngay.
Sau khi server ACK cho FIN của client (bước 2), server ứng dụng có thể cần thêm thời gian để hoàn tất I/O và flush buffer. Khoảng trống này có thể kéo dài từ vài millisecond tới vài giây — nó là half-close: client không gửi được nữa, nhưng server vẫn gửi được cho client. Đây là lý do FIN/ACK của server (bước 3) không gộp với ACK bước 2 như SYN-ACK ở handshake mở.
3. State machine — phân biệt TIME_WAIT và CLOSE_WAIT
Hai trạng thái này nằm ở hai phía khác nhau và có ý nghĩa hoàn toàn khác nhau:
TIME_WAIT — phía chủ động đóng, bình thường · CLOSE_WAIT — phía bị động, BUG nếu tồn tại lâu
TIME_WAIT là gì và vì sao cần?
TIME_WAIT kéo dài 2 × MSL (Maximum Segment Lifetime — thời gian sống tối đa của một gói TCP trong mạng). Trên Linux, MSL mặc định là 30 giây → TIME_WAIT tổng cộng 60 giây.
Hai lý do TIME_WAIT phải tồn tại đủ lâu:
Lý do 1 — Đảm bảo ACK cuối tới đích: nếu ACK (bước 4) bị mất trên đường, server sẽ gửi lại FIN. Nếu client đã CLOSED, nó không có trạng thái để xử lý FIN này — sẽ trả RST, làm server báo lỗi. TIME_WAIT giữ trạng thái để client xử lý FIN retransmit đúng cách.
Lý do 2 — Nuốt gói "ma" từ kết nối cũ: sau khi kết nối đóng, có thể có gói trễ lang thang trong mạng (do định tuyến lặp). Nếu một kết nối mới với cùng {src IP, src port, dst IP, dst port} được mở ngay, gói trễ đó có thể chui vào kết nối mới và làm hỏng dữ liệu. TIME_WAIT chặn việc tái dùng tuple này trong 2 × MSL — đủ để mọi gói từ kết nối cũ hết hạn sống.
CLOSE_WAIT là gì — và tại sao là bug?
CLOSE_WAIT xảy ra ở phía server khi kernel đã nhận FIN của client và gửi ACK, nhưng ứng dụng chưa gọi close() trên socket. Đây là bug ứng dụng, không phải hành vi bình thường của giao thức.
Khi bạn thấy server có hàng trăm kết nối ở CLOSE_WAIT:
ss -tan state close-wait | wc -l
# Output: 347 <- connection leak!
Nghĩa là code đang giữ socket mở sau khi client đã đóng — thường do quên đóng trong khối finally, hoặc connection pool không release đúng cách. Mỗi CLOSE_WAIT là một file descriptor bị leak.
4. Cạn ephemeral port — khi TIME_WAIT tích luỹ quá nhiều
Mỗi kết nối TCP được định danh bởi tuple 4 thành phần: {src IP, src port, dst IP, dst port}. Phía client hoặc proxy khi mở kết nối outbound, kernel phải cấp một ephemeral port (port tạm) cho src port. Dải ephemeral port trên Linux mặc định là:
cat /proc/sys/net/ipv4/ip_local_port_range
# 32768 60999
Vậy có khoảng 28 231 port có thể dùng cho outbound connections tới một đích (cùng dst IP + dst port). Với proxy tải cao đóng 1 000 kết nối/giây tới cùng một backend, sau 28 giây tất cả port đang ở TIME_WAIT và không mở thêm được. Hệ thống trả lỗi EADDRINUSE hoặc treo request.
Với 1 000 kết nối/giây × 60s TIME_WAIT = 60 000 port bị giữ — vượt dải 28 231 → hết port
Các cách xử lý cạn port
Cách 1 — Keep-alive và connection pool (ưu tiên): tái dùng kết nối thay vì mở/đóng liên tục. Đây là giải pháp kiến trúc, xem chi tiết ở bài 06 — Connection pooling & keep-alive.
Cách 2 — Mở rộng dải ephemeral port:
# Them vao /etc/sysctl.conf
net.ipv4.ip_local_port_range = 1024 65535
sysctl -p
Tăng từ ~28 000 lên ~64 000 port — gấp đôi, nhưng không giải quyết gốc rễ.
Cách 3 — net.ipv4.tcp_tw_reuse:
net.ipv4.tcp_tw_reuse = 1
Cho phép tái dùng socket ở TIME_WAIT cho kết nối outbound nếu timestamp TCP đủ mới (RFC 1323). Linux kernel sẽ không đợi hết 60 giây. Chỉ áp dụng cho SO_REUSEADDR phía client.
Cách 4 — SO_REUSEADDR: tùy chọn socket cho phép bind port đang ở TIME_WAIT — thường dùng phía server để restart ngay sau crash mà không cần chờ.
5. Quan sát với ss
# Dem TIME_WAIT
ss -tan state time-wait | wc -l
# Dem CLOSE_WAIT (canh bug)
ss -tan state close-wait | wc -l
# Xem chi tiet ket noi theo trang thai
ss -tan | awk 'NR>1 {print $1}' | sort | uniq -c | sort -rn
# Output mau:
# 1823 TIME-WAIT
# 247 ESTABLISHED
# 103 CLOSE-WAIT <- co van de
ss nhanh hơn netstat vì đọc trực tiếp từ kernel socket table, không parse /proc/net/tcp. Dùng -t (TCP), -a (all states), -n (no DNS lookup) cho output nhanh nhất.
6. Pitfall — hiểu nhầm thường gặp
❌ Nhầm 1: "Nhiều TIME_WAIT là dấu hiệu có vấn đề, cần tắt đi."
✅ TIME_WAIT là bình thường và cần thiết — nó bảo vệ tính toàn vẹn của kết nối mới. Tắt bằng tcp_tw_recycle (đã bỏ trong Linux 4.12 vì gây bug với NAT) là sai. Nếu TIME_WAIT đang làm cạn port, giải pháp đúng là dùng connection pool / keep-alive để giảm số kết nối tạo ra, không phải tắt TIME_WAIT.
❌ Nhầm 2: "CLOSE_WAIT và TIME_WAIT là cùng một vấn đề."
✅ Hoàn toàn khác: TIME_WAIT ở phía chủ động đóng, là hành vi giao thức bình thường; CLOSE_WAIT ở phía bị động, là bug ứng dụng không gọi close(). Debug cũng khác nhau: TIME_WAIT nhìn vào lượng kết nối và port range; CLOSE_WAIT nhìn vào code path xử lý socket có gọi close() đúng không.
❌ Nhầm 3: "Chỉ server mới bị TIME_WAIT."
✅ Bất kỳ phía nào gửi FIN trước đều là phía chủ động đóng và sẽ ở TIME_WAIT. Trong thực tế, client hoặc proxy thường là phía đóng trước (sau khi nhận xong response), nên TIME_WAIT tích luỹ ở client/proxy, không phải server backend. Đây là lý do proxy tải cao dễ bị cạn ephemeral port.
7. Liên hệ các bài khác
- Bài 01 — TCP 3-way handshake: đối xứng — bài đó mổ mở kết nối 3 bước, bài này mổ đóng kết nối 4 bước; cùng cơ chế sequence number và ACK.
- Bài 02 — TCP reliability:
FINcũng được đánh số sequence và phải được ACK — cùng cơ chế đảm bảo tin cậy áp dụng cho việc đóng kết nối. - Bài 06 — Connection pooling & keep-alive: giải pháp kiến trúc cho TIME_WAIT quá nhiều — tái dùng kết nối thay vì mở/đóng liên tục, giảm lượng TIME_WAIT tích luỹ.
- Bài 07 — Lỗi mạng thường gặp:
EADDRINUSEkhi hết ephemeral port là một trong các lỗi mạng phổ biến — bài đó có bảng chẩn đoán.
8. 📚 Deep Dive — tài liệu gốc
Đọc khi muốn đi tới gốc:
- RFC 9293 — Transmission Control Protocol — mục 3.6 mô tả chính xác 4-way termination và state machine đóng kết nối; mục 3.3.2 giải thích TIME_WAIT và MSL.
/proc/sys/net/ipv4/kernel docs — danh sách tất cả TCP sysctl tunable, bao gồmtcp_tw_reuse,ip_local_port_range,tcp_fin_timeout.
Ghi chú: RFC 9293 mục 3.6 "Closing a Connection" là nơi tra cứu khi cần biết chính xác trạng thái nào phải ở bên nào. tcp_tw_recycle (đã đề cập trong nhiều blog cũ) bị bỏ hoàn toàn từ Linux kernel 4.12 — đừng dùng.
9. Tóm tắt
- Đóng kết nối TCP cần 4 bước (FIN/ACK/FIN/ACK) vì full-duplex — mỗi chiều đóng độc lập.
TIME_WAITở phía chủ động đóng, kéo dài 2 × MSL (~60 giây trên Linux) — bình thường và cần thiết để nuốt gói trễ và đảm bảo ACK cuối đến đích.CLOSE_WAITở phía bị động — tồn tại lâu là bug ứng dụng không gọiclose(), gây file descriptor leak.- Proxy tải cao mở hàng nghìn kết nối outbound/giây có thể làm cạn ephemeral port (dải ~32 768–60 999) khi TIME_WAIT tích luỹ quá nhiều.
- Giải pháp đúng: connection pool / keep-alive (giảm số kết nối) → mở rộng port range →
tcp_tw_reuse— theo thứ tự ưu tiên. - Quan sát bằng
ss -tan state time-wait | wc -lvàss -tan state close-wait | wc -l. tcp_tw_recycleđã bị bỏ từ Linux 4.12 — không dùng.
10. Tự kiểm tra
Q1Vì sao đóng kết nối TCP cần 4 bước trong khi mở chỉ cần 3 bước?▸
Q2TIME_WAIT kéo dài bao lâu và vì sao phải chờ đúng khoảng đó, không phải ngắn hơn?▸
TIME_WAIT kéo dài 2 × MSL (Maximum Segment Lifetime — thời gian sống tối đa của gói TCP trong mạng), thường tổng ~60 giây trên Linux. Phải chờ đủ vì hai lý do: (1) nếu ACK cuối bị mất, server sẽ gửi lại FIN — client cần còn trạng thái để trả lời đúng; (2) phải đợi đủ để mọi gói "ma" từ kết nối cũ hết thời gian sống trước khi tuple port đó được tái dùng. Rút ngắn TIME_WAIT vi phạm cả hai bảo đảm này.Q3Bạn thấy server có 500 kết nối ở CLOSE_WAIT. Đây là bình thường hay có vấn đề? Nguyên nhân thường là gì?▸
CLOSE_WAIT xảy ra khi kernel đã nhận FIN của client và gửi ACK, nhưng ứng dụng chưa gọi close() trên socket. 500 kết nối ở CLOSE_WAIT nghĩa là 500 socket đang bị leak — thường do quên close() trong khối finally, hoặc connection pool không trả connection về đúng cách sau khi client ngắt. Mỗi socket leak tiêu tốn một file descriptor — có thể dẫn tới lỗi EMFILE (too many open files).Q4Proxy tải cao mở 1 000 kết nối outbound mới mỗi giây tới cùng một backend. Vì sao sau khoảng 30 giây có thể bắt đầu gặp lỗi kết nối?▸
TIME_WAIT trong ~60 giây, giữ port không cho tái dùng. Với 1 000 kết nối/giây × 60 giây = 60 000 port cần đồng thời — vượt xa dải 28 231. Kernel báo EADDRINUSE khi không còn port để cấp. Giải pháp đúng là connection pool / keep-alive để tái dùng kết nối thay vì mở mới mỗi request.Q5Lệnh nào bạn dùng để đếm nhanh số TIME_WAIT và CLOSE_WAIT hiện tại? Vì sao dùng ss thay netstat?▸
ss -tan state time-wait | wc -l— đếm TIME_WAITss -tan state close-wait | wc -l— đếm CLOSE_WAIT
ss (socket statistics) đọc thẳng từ kernel netlink socket, không parse /proc/net/tcp như netstat, nên nhanh hơn nhiều khi có hàng nghìn kết nối. -t chỉ TCP, -a tất cả state, -n không giải DNS (tránh chậm vì DNS lookup).
Q6Vì sao tcp_tw_recycle không còn là giải pháp hợp lệ nữa? Thay vào đó dùng gì?▸
tcp_tw_recycle bị bỏ hoàn toàn từ Linux kernel 4.12 vì gây lỗi nghiêm trọng khi kết hợp với NAT: nhiều client phía sau cùng một NAT có thể bị block vì timestamp TCP không nhất quán. Kernel hiện đại không còn sysctl này. Thay vào đó, dùng theo thứ tự ưu tiên: (1) connection pool / keep-alive để giảm số kết nối tạo ra, (2) mở rộng ip_local_port_range, (3) bật tcp_tw_reuse = 1 (an toàn hơn, chỉ cho outbound).Q7Ai sẽ ở trạng thái TIME_WAIT — client hay server? Điều này phụ thuộc vào điều gì?▸
FIN trước sẽ là phía chủ động đóng và sẽ rơi vào TIME_WAIT. Không nhất thiết là client hay server — phụ thuộc vào luồng ứng dụng. Trong thực tế, sau khi nhận xong HTTP response, client hoặc reverse proxy thường là phía đóng trước (vì đã xong việc), nên TIME_WAIT tích luỹ ở đó. Đây là lý do proxy giữa client và backend dễ bị cạn ephemeral port hơn backend server.Bài tiếp theo: TCP vs UDP — khi nào bỏ độ tin cậy đổi tốc độ
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên