TCP 拥塞控制限制发送端向容量有限、可能被共享的网络注入数据。拥塞窗口约束未确认数据量,采用 pacing 时还会控制发送在时间上的分布。接收端通告窗口施加的是另一类流量控制限制。这些机制相互作用,却描述不同约束,不能笼统合并成一个“发送速度设置”,否则很难判断真正限制当前连接的是哪一部分。

坏掉的样子

两端不需要选择完全相同的拥塞控制算法。每端负责控制自己发送的流量,因此 Reno 客户端能够与 CUBIC 服务器通信。TCP 建立过程协商传输选项,没有要求双方发送算法名称一致。把不同控制器名称诊断成协议不匹配,会分散注意力,掩盖真正的路由、缓冲或应用故障。算法选择需要按方向和具体套接字理解。

算法是否可用,也是需要核对的运行时事实。配置字符串写了某个控制器,不证明内核支持它或允许调用进程选择它。Linux 的 TCP_CONGESTION 按套接字设置,受可用和允许算法约束。应检查调用结果,并回读实际名称,不能默认请求标签已生效。选择失败与选择成功后性能不合适,属于不同问题,需要不同处理。

丢失恢复不存在适用于所有情况的统一曲线。重传超时、快速恢复、显式拥塞通知和基于模型的估计,会因控制器不同而产生不同响应。丢失可能来自拥塞,也可能来自其他原因,发送端仍须按算法规则作出反应。增加缓冲并不能自动修复持续丢包或等待确认的连接,也不能替代对具体恢复状态和触发信号的检查。

快速验证

将脚本保存为 check-congestion-control.py,准备 Docker 后运行 python3 check-congestion-control.py。一次性环境使用 Python 3.11.16、iproute2 6.1.0 和 nftables 1.0.6,在 Linux 6.12.76 上验证。NET_ADMIN 与 SYS_ADMIN 支持嵌套命名空间。新建 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


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'])
    available=ns('client','sysctl','-n','net.ipv4.tcp_available_congestion_control').stdout.split();assert {'reno','cubic'}<=set(available),available
    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
listener=socket.socket();listener.setsockopt(socket.IPPROTO_TCP,socket.TCP_CONGESTION,b'cubic')
listener.bind(('192.0.2.2',18080));listener.listen();print('listening',flush=True)
with listener.accept()[0] as connection:
    connection.settimeout(8)
    print(json.dumps({'algorithm':connection.getsockopt(socket.IPPROTO_TCP,socket.TCP_CONGESTION,16).split(b'\0')[0].decode()}),flush=True)
    for size in [65536,16384]:
        data=b''
        while len(data)<size:
            chunk=connection.recv(size-len(data));assert chunk;data+=chunk
        assert data==b'x'*size
        connection.sendall((hashlib.sha256(data).hexdigest()+'\n').encode())
'''
    service=spawn(stack,['ip','netns','exec','target','python3','-u','-c',server_code]);assert line(service).strip()=='listening'
    client_code=info_code+r'''
import socket,json,sys,time,hashlib
with socket.socket() as unsupported:
    try:unsupported.setsockopt(socket.IPPROTO_TCP,socket.TCP_CONGESTION,b'xbcatm_absent');raise AssertionError('unexpected available algorithm')
    except OSError as error:selection_failure={'errno':error.errno,'type':type(error).__name__}
assert selection_failure['errno']==2,selection_failure
stream=socket.socket();stream.settimeout(8);stream.setsockopt(socket.IPPROTO_TCP,socket.TCP_CONGESTION,b'reno')
stream.bind(('192.0.2.1',40600));stream.connect(('192.0.2.2',18080))
with stream.makefile('rb') as incoming:
    stream.sendall(b'x'*65536);assert incoming.readline().decode().strip()==hashlib.sha256(b'x'*65536).hexdigest()
    baseline=tcp_info(stream);assert baseline['snd_cwnd']>1
    algorithm=stream.getsockopt(socket.IPPROTO_TCP,socket.TCP_CONGESTION,16).split(b'\0')[0].decode()
    print(json.dumps({'baseline':baseline,'algorithm':algorithm,'unavailable_algorithm':selection_failure}),flush=True)
    assert sys.stdin.readline().strip()=='drop';stream.sendall(b'x'*16384)
    deadline=time.monotonic()+4;samples=[]
    while time.monotonic()<deadline:
        state=tcp_info(stream);samples.append(state)
        if state['total_retrans']>baseline['total_retrans'] and state['snd_cwnd']==1 and state['ca_state']==4:break
        time.sleep(.01)
    else:raise AssertionError(('no timeout recovery observed',samples))
    print(json.dumps({'during_blackout':state}),flush=True)
    assert sys.stdin.readline().strip()=='restore'
    assert incoming.readline().decode().strip()==hashlib.sha256(b'x'*16384).hexdigest()
    print(json.dumps({'after_restore':tcp_info(stream),'verified_bytes':65536+16384}),flush=True)
stream.close()
'''
    client=spawn(stack,['ip','netns','exec','client','python3','-u','-c',client_code])
    baseline=json.loads(line(client));server_result=json.loads(line(service));assert baseline['algorithm']=='reno' and server_result['algorithm']=='cubic'
    nft('target','''table ip blackout {
 chain input { type filter hook input priority 0; policy accept;
  tcp sport 40600 tcp dport 18080 ip length > 60 counter drop comment "data_dropped"
 }
}
''')
    client.stdin.write('drop\n');client.stdin.flush();loss=json.loads(line(client,5))
    count=counters('target');assert count['data_dropped']>0
    nft('target','delete table ip blackout');client.stdin.write('restore\n');client.stdin.flush()
    recovery=json.loads(line(client,8));client.wait(timeout=2);service.wait(timeout=2);assert client.returncode==service.returncode==0
    print(json.dumps({'available':available,'client':baseline,'server':server_result,'loss':loss,'drop_counter':count,'recovery':recovery}))
"""
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 与 CUBIC 可用。刻意选择一个不存在的控制器会失败,返回 ENOENT,错误号二,Python 表现为 FileNotFoundError。这个异常指控制器选择不可用,不是应用数据文件丢失。真实客户端明确选择 Reno,服务器监听套接字选择 CUBIC,接受后的套接字报告继承了该选择,分别验证配置意图和最终生效状态。

第一阶段成功发送65536字节,并校验服务器摘要。两端报告不同算法,但通信正常。客户端记录的基线拥塞窗口为五十六个段,观察时没有重传或未确认数据。这既证明名称无需相同,也在引入丢失前建立健康对照,避免把本来就无法通信的配置拿来解释后续过滤造成的结果。

随后目标安装输入规则,丢弃该连接的数据。客户端再发送16384字节,在确认不能返回时采样 TCP_INFO。重传超时后,内核进入丢失状态:本次窗口降至一个段,慢启动阈值变为二十八,重传计数增加。目标规则计数器确认确实丢弃了报文,支持这个故障归因,而不是仅从窗口下降猜测网络一定发生了什么。

移除规则后,同一连接恢复。客户端校验第二部分摘要,两个阶段共交付81920字节。最终窗口再次增长,累计重传反映恢复工作。这些是当前 Reno 超时场景的观察,不承诺所有控制器在任何丢失下都会将阈值减半或窗口降为一。应用正确交付与传输曾经经历恢复可以同时成立,不能互相否定。

测量与工作负载真正有关的限制

实验依据 Linux TCP_INFO 布局记录 MSS、拥塞窗口、阈值、RTT、未确认段与重传。这些窗口字段以段计数。一个快照不能单独建立吞吐量结论:应用可能没有数据可发,接收端可能限制进展,或者排队数据仍在等待发送。解释套接字诊断时应保留这些区别,避免把发送许可、实际占用和每秒完成数据量混为一谈。

运行环境变更应记录代表性套接字上的实际控制器,并在相关负载下比较延迟、吞吐、丢失与公平性。单条成功连接,不能证明存在竞争发送端或另一种瓶颈时仍有相同行为。本例隔离输入丢弃是受控故障,也不是互联网正常丢包率估计。选择算法的依据应来自应用需要和对应测量,而不是名字是否新颖或与对端一致。

将传输恢复证据与应用正确性一起观察。重传可以维护字节流,却增加完成时间,所以有效响应不意味着网络从未丢失。反过来,恢复期间窗口较小,也不直接代表连接已经损坏。有价值的结果,是验证交付,并根据真实观测解释发送端如何应对所遇条件。这样才能把正常退让、持续故障和应用自身限制分开处理。

来源