TCP 头部的接收窗口字段只有十六位。窗口缩放让已建立连接依据握手期间确定的移位解释这个字段,从而允许接收端通告超过65535字节的空间。两个方向可以采用不同移位值。它扩展的是接收窗口信息的编码,不会直接扩大每个缓冲区,也不保证发送端始终让那么多数据处于未确认状态。

坏掉的样子

高带宽且 RTT 较长的路径,可能需要远多于未缩放窗口所能描述的在途数据。例如每秒一百兆比特、往返八十毫秒,对应大约一百万字节在途数据;65535字节窗口会形成明显更低的理想速率上限。带宽时延积用于容量估算,不是吞吐量实测,也不保证链路不存在其他约束,不能用一个乘法直接宣布应用应达到某个速度。

发送端还受到拥塞窗口限制,并且必须有应用数据可发。扩大接收窗口编码,无法修复过小的拥塞预算、停止读取的应用、丢包或被阻断的路由。零窗口经过缩放仍然是零。应判断究竟哪个资源限制进展,不能仅因握手里存在缩放选项,就断言连接理应跑满链路,也不能把所有低吞吐问题都归因于缓冲配置。

对协商的误解还会导致抓包解释错误。双方都必须提供选项,缩放才能生效;每个因子用于解释提供它的端点所通告的窗口。SYN 与 SYN-ACK 自身的窗口字段从不缩放。使用反方向的移位值,或把 SYN 窗口也乘上因子,即使数据包字节完全正确,也会算出错误容量。报文值需要结合方向和连接阶段解释。

快速验证

将脚本保存为 check-window-scaling.py,准备 Docker 后运行 python3 check-window-scaling.py。一次性环境使用 Python 3.11.16、iproute2 6.1.0 和 tcpdump 4.99.3,在 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


def capture(stack,root,name,interface,expression,label):
    path=root/(label+'.pcap')
    process=subprocess.Popen(['ip','netns','exec',name,'tcpdump','--immediate-mode','-Z','root','-U','-n','-i',interface,'-w',str(path),expression],
        stdout=subprocess.DEVNULL,stderr=subprocess.PIPE,text=True)
    stack.callback(stop,process)
    deadline=time.monotonic()+3
    while time.monotonic()<deadline:
        assert process.poll() is None,'tcpdump exited before listening'
        if select.select([process.stderr],[],[],.1)[0] and 'listening on' in process.stderr.readline():return process,path
    raise AssertionError('tcpdump did not become ready')
def packets(process,path):
    import struct,socket
    process.send_signal(signal.SIGINT);_,diagnostic=process.communicate(timeout=3)
    assert process.returncode==0,diagnostic
    raw=path.read_bytes();assert raw[:4]==bytes.fromhex('d4c3b2a1') and struct.unpack_from('<I',raw,20)[0]==1
    offset=24;result=[]
    while offset<len(raw):
        seconds,micros,size,original=struct.unpack_from('<IIII',raw,offset);offset+=16
        frame=raw[offset:offset+size];offset+=size
        if frame[12:14]!=b'\x08\x00' or frame[23]!=6:continue
        ihl=(frame[14]&15)*4;length=struct.unpack_from('!H',frame,16)[0];tcp=14+ihl
        sport,dport,seq,ack=struct.unpack_from('!HHII',frame,tcp);header=(frame[tcp+12]>>4)*4
        opts={};cursor=tcp+20
        while cursor<tcp+header:
            kind=frame[cursor]
            if kind==0:break
            if kind==1:cursor+=1;continue
            option_length=frame[cursor+1];data=frame[cursor+2:cursor+option_length];assert option_length>=2
            if kind==2:opts['mss']=struct.unpack('!H',data)[0]
            elif kind==3:opts['wscale']=data[0]
            elif kind==4:opts['sack_ok']=True
            elif kind==5:opts['sack']=[list(pair) for pair in struct.iter_unpack('!II',data)]
            cursor+=option_length
        body=frame[tcp+header:14+length]
        result.append({'t':seconds+micros/1e6,'src':socket.inet_ntoa(frame[26:30]),'dst':socket.inet_ntoa(frame[30:34]),
            'sport':sport,'dport':dport,'seq':seq,'ack':ack,'flags':frame[tcp+13],'window':struct.unpack_from('!H',frame,tcp+14)[0],
            'length':len(body),'options':opts,'preview':body[:24].hex()})
    return result
with lab(['client','target']) as (root,stack,setting):
    wire('client','c0','target','t0',['192.0.2.1/30'],['192.0.2.2/30'])
    server_code=r'''
import socket,json
def read_exact(stream,length):
    data=b''
    while len(data)<length:
        chunk=stream.recv(length-len(data));assert chunk,'premature EOF';data+=chunk
    return data
listener=socket.socket();listener.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1)
listener.setsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF,262144)
listener.bind(('192.0.2.2',18080));listener.listen();print('listening',flush=True)
with listener.accept()[0] as connection:
    connection.settimeout(4);print(json.dumps({'rcvbuf':connection.getsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF)}),flush=True)
    assert read_exact(connection,4)==b'ping';connection.sendall(b'pong')
    assert connection.recv(1)==b''
'''
    client_code=r'''
import socket,json,sys
def read_exact(stream,length):
    data=b''
    while len(data)<length:
        chunk=stream.recv(length-len(data));assert chunk,'premature EOF';data+=chunk
    return data
s=socket.socket();s.settimeout(4);s.setsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF,65536)
s.bind(('192.0.2.1',40400+int(sys.argv[1])));s.connect(('192.0.2.2',18080));s.sendall(b'ping')
assert read_exact(s,4)==b'pong'
print(json.dumps({'rcvbuf':s.getsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF),'reply':'pong'}),flush=True)
assert sys.stdin.readline().strip()=='close';s.close()
'''
    outcomes={}
    for enabled in [1,0]:
        setting('client','net.ipv4.tcp_window_scaling',enabled);setting('target','net.ipv4.tcp_window_scaling',enabled)
        service=spawn(stack,['ip','netns','exec','target','python3','-u','-c',server_code]);assert line(service).strip()=='listening'
        tap,path=capture(stack,root,'client','c0','tcp port 18080','window'+str(enabled))
        client=spawn(stack,['ip','netns','exec','client','python3','-u','-c',client_code,str(enabled)])
        client_result=json.loads(line(client));server_result=json.loads(line(service))
        diagnostics=ns('client','ss','-tin','state','established').stdout
        client.stdin.write('close\n');client.stdin.flush();client.wait(timeout=2);service.wait(timeout=2)
        assert client.returncode==service.returncode==0
        observed=packets(tap,path);syn=next(p for p in observed if p['flags']&0x12==0x02)
        synack=next(p for p in observed if p['flags']&0x12==0x12)
        advertised=next(p for p in observed if p['sport']==18080 and p['length']==4)
        if enabled:
            assert 'wscale' in syn['options'] and 'wscale' in synack['options']
            effective=advertised['window']<<synack['options']['wscale'];assert effective>65535
        else:
            assert 'wscale' not in syn['options'] and 'wscale' not in synack['options'];effective=advertised['window'];assert effective<=65535
        outcomes[enabled]={'client':client_result,'server':server_result,'syn':syn,'synack':synack,
            'server_post_handshake_window_field':advertised['window'],'effective_receive_window':effective,'ss':diagnostics}
    print(json.dumps(outcomes))
"""
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)

连接建立之前,客户端请求65536字节接收缓冲,服务器监听套接字请求262144字节。脚本读取最终套接字选项值,不假定请求值会原样使用。Linux 的缓冲记账和上限会影响结果,本次客户端报告131072,服务器接受后的套接字报告425984。二者均不能直接等同于线上通告窗口。

两端启用缩放时,捕获到客户端 SYN 提供移位一,服务器 SYN-ACK 提供移位二。初始窗口字段仍按未缩放值解释。建立连接后,服务器数据段的窗口字段为53213,应用服务器因子后乘以四,得到212852字节。客户端套接字诊断也独立报告对应发送窗口,使协商、报文与生效状态互相印证。

下一条全新连接在两端关闭缩放,保持缓冲请求和应用交互相同。两个 SYN 都没有缩放选项,服务器通告窗口为65535字节。两次都发送 ping 并收到 pong,既检查协商,也检查真实通信。对照展示编码限制,没有把四字节交换包装成最大吞吐量测试;应用成功与容量上限属于不同结论。

脚本捕获建立过程,因为缩放因子对该连接固定。之后改变系统默认值,不会重新协商已有字节流。如果接收缓冲行为影响握手选择,就应在 connect 或 listen 前适当配置。从握手之后才开始的抓包,需要补充此前协商状态,才能正确解释原始窗口字段。缺少这段背景时,单看一个较小字段容易误判接收能力。

区分缓冲分配、通告容量与实际性能

记录相关套接字设置、双方移位、当前通告窗口、拥塞窗口、RTT 和应用活动。这些描述不同限制。分配量很大时,通告窗口仍可能较小,因为协议栈要考虑缓冲记账与当前占用。本例有意同时给出分配值和报文值,把差异明确展示出来,避免将多个不同指标都笼统叫作“窗口大小”,导致调参对象错误。

传输未达预期时,检查接收应用是否持续消费,以及发送端是否有足够任务使用允许的在途量。修改缓冲预算之前,应审阅丢包和拥塞行为。大量并发套接字采用更大分配,也会增加资源需求,因此调优应依靠负载证据,而不是把所有上限设到最大。只改变编码能力,无法保证应用生产与消费速度随之提升。

协议将移位限制为十四,使可表示窗口小于一吉比字节。这个编码上限并不要求分配同样多内存。变更之后,应针对部署内核和代表性流量验证。本例检查协商、方向、计算及成功交换,持续容量和实际应用行为还需要适合真实路径的测量。明确区分这些结论,才能避免用一个选项的存在代替完整性能证据。

来源

  • RFC 7323 TCP Extensions,双向独立缩放因子、SYN 例外与接收窗口计算,已核验 2026-09-12。
  • Linux tcp(7),接收缓冲、窗口缩放与建立连接前的套接字设置,已核验 2026-09-12。
  • RFC 5681 Congestion Control,拥塞窗口与接收窗口分别施加的限制,已核验 2026-09-12。