TIME-WAIT 是 TCP 在某些有序关闭之后继续保留的协议状态。它为必要时重发最后确认、完成关闭交换留出时间,也帮助避免早先连接实例的延迟报文被误认成新连接数据。相关身份包含两端地址和端口,不能把一个 TIME-WAIT 条目简单理解成对某个本地端口号的全局锁。连接四元组和单独端口,是不同范围的概念。
坏掉的样子
普通关闭中,首先发起关闭的一端通常在确认对端 FIN 后进入 TIME-WAIT。这一端可能是客户端,也可能是服务器;同时关闭还可能涉及双方。假定 TIME-WAIT 永远属于服务器,会掩盖真正制造连接周转的工作负载。应查看状态及两端四元组,不能从应用角色推断谁先关闭,也不能把客户端保留该状态直接视为异常。
高频连接周转可能限制到某个目的地的新连接,但 TIME-WAIT 数量很大,本身不证明资源耗尽或应用文件描述符泄漏。应用关闭套接字之后,操作系统仍可保留协议状态。源地址、可用端口、目的地多样性、显式绑定、复用选项与内核行为,都会影响实际限制。诊断资源耗尽之前,应同时测量连接失败与状态分布,而不是只盯一个总数。
混淆 TIME-WAIT 和 FIN-WAIT-2 会导致无效调参。Linux 的 tcp_fin_timeout 控制另一种孤立套接字情形,不是通用的 TIME-WAIT 时长开关。关于 tcp_tw_recycle 的旧建议,也不适用于当前 Linux。复用受到协议安全条件约束,其中包含时间戳相关检查;如果不了解真实套接字路径,仅修改一个参数,就可能产生错误的行为预期。
快速验证
将脚本保存为 check-time-wait.py,准备 Docker 后运行 python3 check-time-wait.py。一次性环境使用 Python 3.11.16 和 iproute2 6.1.0,在 Linux 6.12.76 上验证。NET_ADMIN 与 SYS_ADMIN 支持嵌套命名空间及临时 proc 挂载。客户端与目标通过新建 veth 相连,参数变更只作用于这些范围,不影响宿主服务或接口:
from pathlib import Path
from tempfile import TemporaryDirectory
import json,subprocess,uuid
recipe='FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84\nRUN apt-get update && apt-get install -y --no-install-recommends iproute2=6.1.0-3 iputils-ping=3:20221126-1+deb12u1 nftables=1.0.6-2+deb12u2 tcpdump=4.99.3-1 traceroute=1:2.1.2-1 procps=2:4.0.2-3 && rm -rf /var/lib/apt/lists/*\nCMD ["python3"]\n'
program=r"""
from contextlib import ExitStack,contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import json,os,select,signal,subprocess,time
def run(*args,check=True,input=None,timeout=8):
result=subprocess.run(args,input=input,capture_output=True,text=True,timeout=timeout)
if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr)
return result
def ns(name,*args,**kwargs):return run('ip','netns','exec',name,*args,**kwargs)
def ip(name,*args,**kwargs):return ns(name,'ip',*args,**kwargs)
def stop(process):
if process.poll() is None:
process.terminate()
try:process.wait(timeout=2)
except subprocess.TimeoutExpired:process.kill();process.wait(timeout=2)
def line(process,timeout=3):
assert select.select([process.stdout],[],[],timeout)[0],'child response deadline exceeded'
value=process.stdout.readline();assert value,('child exited without a response',process.stderr.read())
return value
def spawn(stack,command):
process=subprocess.Popen(command,stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
stack.callback(stop,process);return process
@contextmanager
def lab(names):
with TemporaryDirectory(prefix='net-path-') as directory,ExitStack() as stack:
root=Path(directory);proc=root/'proc';proc.mkdir();run('mount','-t','proc','proc',str(proc))
stack.callback(run,'umount',str(proc))
for name in names:
run('ip','netns','add',name);stack.callback(run,'ip','netns','delete',name,check=False)
ip(name,'link','set','lo','up')
def setting(name,key,value):
assert name in names
path=proc/'sys'/key.replace('.','/')
return ns(name,'python3','-c',f'from pathlib import Path; p=Path({str(path)!r}); p.write_text({str(value)!r}); print(p.read_text().strip())').stdout.strip()
yield root,stack,setting
def wire(left,left_if,right,right_if,left_addresses,right_addresses):
run('ip','link','add',left_if,'type','veth','peer','name',right_if)
for name,interface,addresses in [(left,left_if,left_addresses),(right,right_if,right_addresses)]:
run('ip','link','set',interface,'netns',name)
for address in addresses:ip(name,'address','add',address,'dev',interface,*(['nodad'] if ':' in address else []))
ip(name,'link','set',interface,'up')
def nft(name,script):return ns(name,'nft','-f','-',input=script)
def counters(name):
data=json.loads(ns(name,'nft','-j','list','ruleset').stdout);found={}
for entry in data['nftables']:
rule=entry.get('rule',{})
if 'comment' in rule:
for expression in rule['expr']:
if 'counter' in expression:found[rule['comment']]=expression['counter']['packets']
return found
with lab(['client','target']) as (root,stack,setting):
wire('client','c0','target','t0',['192.0.2.1/30'],['192.0.2.2/30'])
setting('client','net.ipv4.tcp_tw_reuse',0)
setting('client','net.ipv4.tcp_timestamps',0);setting('target','net.ipv4.tcp_timestamps',0)
server_code=r'''
import socketserver,threading
class Handler(socketserver.BaseRequestHandler):
def handle(self):
while self.request.recv(4096):pass
class Server(socketserver.ThreadingTCPServer):allow_reuse_address=True;daemon_threads=True
servers=[Server(('192.0.2.2',port),Handler) for port in [18080,18081]]
for server in servers:threading.Thread(target=server.serve_forever,daemon=True).start()
print('ready',flush=True);threading.Event().wait()
'''
service=spawn(stack,['ip','netns','exec','target','python3','-u','-c',server_code]);assert line(service).strip()=='ready'
client_code=r'''
import json,socket
def exchange(destination):
with socket.socket() as s:
s.settimeout(2);s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1);s.bind(('192.0.2.1',40200))
s.connect(('192.0.2.2',destination));s.sendall(b'closed cleanly');s.shutdown(socket.SHUT_WR)
assert s.recv(4096)==b''
exchange(18080)
try:exchange(18080);same={'ok':True}
except OSError as error:same={'ok':False,'errno':error.errno,'type':type(error).__name__}
assert not same['ok'] and same['errno']==99,same
exchange(18081)
print(json.dumps({'same_tuple':same,'same_source_port_new_destination_port':True}))
'''
result=json.loads(ns('client','python3','-c',client_code).stdout)
rows=ns('client','ss','-nHto','state','time-wait').stdout
assert '192.0.2.1:40200' in rows and '192.0.2.2:18080' in rows and '192.0.2.2:18081' in rows,rows
target=ns('target','ss','-nHto','state','time-wait').stdout;assert ':40200' not in target,target
print(json.dumps({'client_active_close':result,'client_time_wait':rows.strip(),'target_time_wait':target.strip(),
'timestamps_disabled_in_both_namespaces':True,'client_tcp_tw_reuse':ns('client','sysctl','-n','net.ipv4.tcp_tw_reuse').stdout.strip()}))
"""
name='xbcatm-tcpflow-'+uuid.uuid4().hex[:10]
def docker(*args,**kwargs):return subprocess.run(['docker',*args],capture_output=True,text=True,timeout=300,**kwargs)
try:
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory);(root/'Dockerfile').write_text(recipe)
build=docker('build','-t',name,str(root));assert build.returncode==0,build.stderr
result=docker('run','--rm','-i','--name',name,'--network','none','--cap-add','NET_ADMIN','--cap-add','SYS_ADMIN',
'--memory','256m','--pids-limit','128',name,'python3','-',input=program)
assert result.returncode==0,result.stderr
print(json.dumps(json.loads(result.stdout),sort_keys=True))
finally:
docker('rm','-f',name);docker('image','rm',name)
为稳定复现立即重用同一四元组被拒绝,实验在两端关闭时间戳,并把客户端 tcp_tw_reuse 设为零。这些是明确的实验控制条件,不是建议用于生产的调参组合。启用时间戳的套接字可能走不同安全复用路径,同版本内核源码中也有相关检查。因此,脚本没有声称一个复用参数就决定所有可能结果,读者重现时应保留这些条件。
目标监听端口 18080 和 18081。客户端在绑定源端口 40200 前设置 SO_REUSEADDR,连接第一个目的端口,发送数据,并首先关闭写方向。目标读取完字节流后关闭,客户端读到 EOF,再关闭自己的描述符。这里通过真实协议流量确立客户端为主动关闭端,不是仅凭变量名称把它标成某种连接角色。
紧接着,客户端从相同源地址和端口连接相同目标地址与端口,在上述控制条件下得到 Linux EADDRNOTAVAIL,错误号九十九。只把目的端口改成 18081,仍保留源端口 40200,连接便成功。两次套接字都使用 SO_REUSEADDR,所以比较的是连接四元组差异,没有在尝试之间偷偷改变绑定选项来制造另一个结果。
随后客户端套接字列表显示两个 TIME-WAIT 条目,共用本地端口 40200,分别对应两个目标端口。本次目标端没有相应 TIME-WAIT 条目。观察时客户端两个计时器都接近六十秒。脚本记录这些计时器,没有等待它们到期,也没有把当前 Linux 实测时长称为所有 TCP 实现通用的常数。计时器可见与完整到期行为已经执行,需要明确区分。
先减少无谓周转,再判断是否需要修改复用策略
应用协议允许时,复用连接可以减少反复建立与拆除。连接池仍需要容量边界、失效连接处理和适当截止时间;保留死连接并不能解决周转问题。检查是否每个小操作都主动关闭,或者代理超时、服务器策略迫使客户端不断重连。修复应针对真实生命周期原因,而不是只让状态数量在监控图上变得更小。
调查上限时,记录失败系统调用与错误、两端四元组、活跃连接数和 TIME-WAIT 分布。绑定失败、连接失败与应用等待超时是不同观察。本例有意绑定源端口,让内核自动选择临时端口的应用会走不同分配路径,需要自己的代表性测试。不能把这个受控源端口实验直接换算成所有客户端的并发连接容量。
诊断应保留协议对延迟报文和关闭交换的保护。实际目标是让所需负载下的新连接可靠工作,而不是消灭 TCP 有意保留的状态。任何拟议变更,都要针对部署内核、套接字选项和流量模式验证,并同时检查应用交互与连接状态计数。这样才能确认性能改善没有以丢失正确性边界为代价,也不会把正常协议行为误当泄漏。
来源
- RFC 9293 TCP,连接终止、TIME-WAIT 与延迟到达报文,已核验 2026-09-12。
- Linux tcp(7),套接字复用控制与独立的 FIN-WAIT-2 超时,已核验 2026-09-12。
- Linux 6.12.76 TCP IPv4 implementation,复用 TIME-WAIT 连接时与时间戳相关的检查,已核验 2026-09-12。