慢启动在 TCP 发送端探索路径可承受的未确认数据量时,逐步扩大拥塞窗口。数据充足且确认模式合适时,经典行为是在多个往返间近似指数增长。“每个 RTT 翻倍”是有用的简化模式,不是无条件计时器规则。窗口变化依赖传输反馈和实现的增长条件,不能只根据连接已经打开多久,就推断它获得了多少发送预算。
坏掉的样子
connect 之后等待,不会自动把拥塞窗口预热。应用没有发送数据时,就没有支持这种增长的数据确认。很小的交换,也可能在用满已有发送预算之前便结束。期待每个短请求都出现大幅翻倍序列,是把受应用数据量限制的传输误认成持续积压的发送端。缺少明显增长,并不自动说明慢启动没有工作或连接存在故障。
现代系统初始窗口也不统一为一到两个段。RFC 6928 描述了允许更大初始窗口的实验性扩展,本次 Linux 发送端起步为十个段。单位同样重要:Linux TCP_INFO 的 snd_cwnd 以段为单位,许多协议说明则用字节描述拥塞窗口。推算未确认数据量之前,应同时查看发送 MSS,避免将段数直接当成字节数或当前实际在途段数。
慢启动并非总要持续到第一次丢失,之后统一彻底缩回起点。慢启动阈值帮助决定启动增长与拥塞避免,具体实现还可能采用额外启动检测;重传超时恢复也不同于快速恢复。把同一条示意曲线套到所有算法、丢失信号和工作负载,即使读到了真实指标,也可能作出错误诊断。首先需要确认当前连接处于哪个阶段。
快速验证
将脚本保存为 check-slow-start.py,准备 Docker 后运行 python3 check-slow-start.py。一次性环境使用 Python 3.11.16 和 iproute2 6.1.0,在 Linux 6.12.76 上验证。NET_ADMIN 与 SYS_ADMIN 支持嵌套命名空间。客户端和目标通过新建 veth 相连,netem 在两个方向的出口各添加十毫秒延迟,全部变更仅作用于虚拟端点:
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
info_code=r'''
import struct
def tcp_info(stream):
raw=stream.getsockopt(socket.IPPROTO_TCP,socket.TCP_INFO,104);assert len(raw)==104
offsets={'snd_mss':16,'unacked':24,'rtt_us':68,'snd_ssthresh':76,'snd_cwnd':80,'total_retrans':100}
return {'ca_state':raw[1],**{key:struct.unpack_from('=I',raw,offset)[0] for key,offset in offsets.items()}}
'''
with lab(['client','target']) as (root,stack,setting):
wire('client','c0','target','t0',['192.0.2.1/30'],['192.0.2.2/30'])
for name,interface in [('client','c0'),('target','t0')]:
ns(name,'tc','qdisc','add','dev',interface,'root','netem','delay','10ms','limit','10000')
server_code=r'''
import socket,json,hashlib,sys
size=int(sys.argv[1]);listener=socket.socket();listener.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
listener.bind(('192.0.2.2',18080));listener.listen();print('listening',flush=True)
with listener.accept()[0] as connection:
connection.settimeout(6);remaining=size;digest=hashlib.sha256()
while remaining:
chunk=connection.recv(min(65536,remaining));assert chunk;remaining-=len(chunk);digest.update(chunk)
assert digest.hexdigest()==hashlib.sha256(b'x'*size).hexdigest()
connection.sendall((digest.hexdigest()+'\n').encode())
'''
client_code=info_code+r'''
import socket,json,time,threading,sys,hashlib
size=int(sys.argv[1]);stream=socket.socket();stream.settimeout(6)
stream.setsockopt(socket.IPPROTO_TCP,socket.TCP_CONGESTION,b'reno');stream.connect(('192.0.2.2',18080))
initial=tcp_info(stream);time.sleep(.06);idle=tcp_info(stream);assert idle['snd_cwnd']==initial['snd_cwnd']
done=threading.Event();errors=[];samples=[initial,idle];reply=[]
def transfer():
try:
stream.sendall(b'x'*size)
with stream.makefile('rb') as incoming:reply.append(incoming.readline().decode().strip())
except BaseException as error:errors.append(repr(error))
finally:done.set()
worker=threading.Thread(target=transfer);worker.start();deadline=time.monotonic()+7
while not done.wait(.005):
assert time.monotonic()<deadline,'transfer deadline exceeded'
snapshot=tcp_info(stream)
if snapshot!=samples[-1]:samples.append(snapshot)
worker.join(timeout=1);assert not worker.is_alive() and not errors,errors
samples.append(tcp_info(stream));assert reply==[hashlib.sha256(b'x'*size).hexdigest()]
assert all(row['total_retrans']==0 for row in samples),samples
if size>1:assert max(row['snd_cwnd'] for row in samples)>initial['snd_cwnd']*2,samples
else:assert max(row['snd_cwnd'] for row in samples)==initial['snd_cwnd'],samples
print(json.dumps({'bytes':size,'algorithm':stream.getsockopt(socket.IPPROTO_TCP,socket.TCP_CONGESTION,16).split(b'\0')[0].decode(),
'before_data':initial,'after_idle_without_data':idle,'samples':samples,'sha256':reply[0]}));stream.close()
'''
results={}
for label,size in [('short',1),('bulk',1048576)]:
service=spawn(stack,['ip','netns','exec','target','python3','-u','-c',server_code,str(size)]);assert line(service).strip()=='listening'
results[label]=json.loads(ns('client','python3','-c',client_code,str(size),timeout=10).stdout)
service.wait(timeout=2);assert service.returncode==0
print(json.dumps({'egress_delay_each_direction_ms':10,'transfers':results}))
"""
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)
客户端明确选择 Reno,避免实验悄悄受不同默认拥塞控制器影响。发送之前记录 TCP_INFO,在没有数据的情况下等待六十毫秒,然后再次记录。两次观察窗口均为十个段。这直接检验“新连接只要保持打开,拥塞预算就会按时间增长”的错误预期。增加等待时间与提供可确认的数据,属于不同输入,不能互相替代。
第一次传输发送一个字节,并接收服务器返回的摘要。采样中未确认段数先为一、随后为零,拥塞窗口始终保持十。这个负载成功交付,并不需要可见的指数增长,因为原先允许的在途量已足以容纳整个请求。应用提供的数据太少,没有驱动大流量启动爬升;不能从这种短小成功交换里期待完整带宽探测曲线。
第二次发送一 MiB。独立线程执行阻塞交换,主线程持续采样套接字状态,避免 sendall 等待期间丢失观察。记录包含十、二十、四十、八十个段,随后随着确认聚合和传输进展出现不那么整齐的数值。测试要求窗口显著增长,没有规定唯一采样序列,也不假定每个样本恰好对应一个 RTT。具体数值需要连同采样方法解释。
服务器与客户端通过 SHA-256 验证载荷,所有采样的累计重传数保持零。本次阈值远高于观察到的窗口。这些事实支持由确认驱动的启动演示,但没有刻意验证丢失恢复,也没有测量共享瓶颈。人工添加的延迟使过程容易观察,不代表真实广域网容量或公平性。读者不能把隔离虚拟链路当作生产网络性能基准。
结合负载解释增长轨迹
一起记录算法、MSS、拥塞窗口、阈值、未确认数据、RTT 与重传证据。窗口大于当前未确认段数完全可能,因为应用或接收端还会施加其他限制。采样窗口代表发送端当前允许量,不保证路径上始终存在恰好那么多段。把预算与实际占用区分开,才能判断停顿来自拥塞控制、数据不足还是接收方向约束。
采样本身也影响图景。用户态观察者可能错过中间变化,五毫秒间隔并未与 ACK 到达同步。延迟确认、卸载行为和调度都会改变可见序列。脚本检查数据能够支持的关系,不会仅因少了某个中间值,就宣称拥塞控制器损坏。判断异常之前,应先确认观测精度是否足以支持所期待的逐步变化。
对短操作,在应用协议允许时减少无谓的新连接,可以避免反复使用全新的拥塞预算。连接复用后,空闲重启策略仍可能减小窗口,不能保证此前形成的发送预算一直保留。对批量传输,修改启动设置之前应测试代表性 RTT、丢失、接收行为和竞争流量。本例把空闲时间、微小成功交换与持续数据区分开,为判断慢启动是否与观察到的延迟有关提供具体依据。优化需要针对真实约束,而不是只追求曲线看起来翻倍整齐。
来源
- RFC 5681 Congestion Control,由确认驱动的慢启动、拥塞避免、阈值切换与空闲重启,已核验 2026-09-12。
- RFC 6928 Initial Window,允许更大初始拥塞窗口的实验性扩展,已核验 2026-09-12。
- Linux 6.12.76 TCP_INFO fields,拥塞窗口、阈值、未确认段与重传字段,已核验 2026-09-12。