TCP/IP 协议栈

本页目录

send 的边界不会原样变成 recv 的边界,ACK 也不意味着对端已提交业务。TCP 提供有序可靠字节流,理解 Linux 实现时,先把序列、窗口、恢复和关闭方向分开。

如果还不熟悉报文头、选项和协议层面的窗口计算,可以先读网络系列的 TCP。本篇沿 Linux 的连接队列、收发路径与 socket 行为展开;两篇共用的序列与窗口模型用于衔接协议和实现。

查阅目标阅读位置
路由与握手队列第 1~2 节
字节序列、BDP 与丢包第 3~5 节
控制算法与快路径第 6~7 节
半关闭实验第 8 节

1. IP 路由决定本机交付还是转发

IPv4 接收验证基本头部后,经过 PREROUTING 等处理再作路由决策。本机目标进入 LOCAL_IN/INPUT 后交给 TCP、UDP 等处理;转发路径经过 FORWARD 和出口处理。本机发送需要初始路由信息,OUTPUT 中的地址/mark 变化还可能触发重新路由,之后进入 POSTROUTING。

IPv6 有自己的输入与路由实现:基本头部没有 IPv4 头校验和,扩展头处理和分片规则也不同。IPv6 路由器不在途中代替源端分片,Path MTU Discovery 等机制因此必须正确处理,不能照搬 IPv4 函数链当成通用协议栈。

2. 三次握手建立的是连接状态

监听 socket 保持 LISTEN,握手建立连接对象 客户端 服务端监听与连接状态 SYN:seq = x SYN + ACK:seq = y,ack = x + 1 ACK:ack = y + 1 accept 从已建立连接队列取连接;它不负责触发客户端 SYN。

普通主动打开经历 SYN_SENT,再由合法回应推进到 ESTABLISHED。服务端监听 socket 继续监听,半连接常用较轻量的 request socket 跟踪,握手完成后才进入可 accept 的连接队列;SYN cookie、TCP Fast Open 等又会改变具体资源和时序。

SYN 队列与 accept 队列是不同阶段,listen(backlog) 也不是为所有半连接直接设定同一个总数。accept 返回过慢可能堵住已完成握手的队列,不能只靠扩大 SYN 相关参数解决。

握手交换初始序列号及选项,SYN 自身消耗一个序列号。成功建立 TCP 只说明传输连接可用,不说明应用已认证或业务服务已经准备好。协议规范见 RFC 9293。

3. 序列、ACK 与接收队列

收到的范围接收状态累计 ACK
[1000,2000) 已按序收到下一字节应为 20002000
先到 [3000,4000)中间仍有缺口仍为 2000
缺口随后补齐才能推进连续接收位置随连续范围推进

TCP 序列号计数字节。SACK 可以另行报告离散已收范围,让发送者更准确选择重传;累计 ACK 不会跳过尚未填补的缺口。

对端确认字节进入接收协议状态,不等于对端应用已经读取,更不等于业务写入数据库已经提交。send 返回也不等于收到 ACK。应用若需要消息边界或完成确认,必须定义长度、分隔、请求 ID 和响应协议。

Linux 可以把顺序数据放入接收队列,把乱序数据保存在相应结构中;应用读取时再按语义交付有序字节流。PSH 不构成可靠的消息分界,recv(4096) 可能返回少于 4096 字节,也可能一次合并多个 send 的数据。

直接点击任意字节段制造乱序与重复到达。累计 ACK 只沿连续前缀前进;应用读取另记一笔,不能把接收端确认当成应用已经消费。

正在呈现知识画面
后面的数据到了,ACK 为什么还停在缺口?

累计 ACK 指向下一个尚未连续收到的字节;SACK 描述后方离散范围。协议确认不等于应用消费,重复收到已知范围不再次推进 ACK。

4. 流量控制与拥塞控制是两把尺子

接收窗口 rwnd 表示接收端还能接收多少数据;拥塞窗口 cwnd 表示发送端对网络可承载在途量的限制。两者单位在实现和显示中可能不同,例如 cwnd 常以 MSS 段计数,需要换算再比较。

粗略地说,可继续发送的新数据量受 min(cwnd_bytes, rwnd_bytes) - bytes_in_flight 限制,同时还要考虑 pacing、应用是否有数据、重传及实现细节。窗口不是发送速率的直接单位。

窗口是新发送的约束,不会抹去已经在途的数据。下面把 cwnd、rwnd 与在途量放到同一字节尺度,改变任一上限后再尝试发送。

三个量都按字节计。此处比较当前发送额度,未模拟拥塞算法、SACK 管道估计或真实窗口更新协议。

正在呈现知识画面
两把窗口尺子,减的是同一份在途量

新数据额度为 max(0, min(cwnd, rwnd) − flight)。窗口缩小可以使 flight 超过新上限,此时停止新增发送,等待确认释放额度。

算一笔在途量:带宽足够,窗口仍可能限制吞吐

条件或计算结果
链路 1 Gbit/s,RTT 20 msBDP 约 2.5 MB,即 2.38 MiB
有效在途量仅 64 KiB远小于这个路径的 BDP
64 KiB / 0.02 s约 3.28 MB/s 的简化吞吐上限

此处忽略其他限制。计算说明窗口不足会限制吞吐,并不说明扩大 buffer 能解决全部性能问题。

扩大 buffer 可以消除某些限制,但不能修复丢包、错误 MTU、拥塞或慢应用。接收自动调优还有上限、内存压力和用户设置等约束;设置 SO_RCVBUF 等参数也会影响自动调优行为,不能承诺“高 BDP 永远不需要检查缓冲区”。见 TCP sysctl。

5. 丢包检测不只有超时

重复 ACK、SACK 和按时间推断的 RACK 等信息可以在 RTO 到期前发现丢失。RTO 仍是恢复的重要兜底机制,基于平滑 RTT 与波动估计,并在超时后退避。

RFC 6298给出 RTO 计算框架;具体 Linux 最小值、路径和可配置项应对照目标版本,不能把 RFC 的推荐数值当成所有 Linux 定时器的固定值。重传后的 RTT 样本存在歧义,估算不能把任何 ACK 都直接归给最近一次发送。

定时器按目的分类:窗口、空闲与丢失

零窗口探测处理接收端窗口关闭,keepalive 检查长期空闲连接可达性,两者都不是普通丢包重传定时器的改名。TCP 重试超时也不能保证对端应用没处理过先前的数据,业务重试需要自己的去重语义。

6. CUBIC 与 BBR:比较控制目标,不背宣传结论

CUBIC 的窗口增长围绕上次拥塞前的窗口值设计,立方函数改变接近旧峰值与远离旧峰值时的增长行为;还包含 TCP-friendly 行为等细节,不能用一条立方公式替代全部算法。RFC 9438是 CUBIC 的规范,不是 BBR 的规范。

BBR 通过交付速率和传播 RTT 等估计构建路径模型,并用 pacing 和在途限制控制发送。不同 BBR 版本的丢包、ECN、公平性及探测行为不同;不能统一说“完全不考虑丢包”或“任何拥塞链路都优于 CUBIC”。实际内核模块名也不自动证明采用了某个论文/补丁版本。

cat /proc/sys/net/ipv4/tcp_congestion_control
cat /proc/sys/net/ipv4/tcp_available_congestion_control
ss -tin

系统默认算法影响新连接,已有 socket 还可有自己的设置。比较时固定路径、RTT、流数量、接收窗口和瓶颈队列,观察吞吐与延迟及共存流表现。

7. 快速路径不是绕过协议正确性

tcp_rcv_established() 包含对常见已建立连接报文的优化处理。header prediction 等条件成立时,可减少一般状态处理开销;乱序、异常标志、窗口变化、选项等会使处理走其他分支。

不能据函数名字推导“90% 报文固定只执行几十条指令”。校验和卸载、GRO、锁竞争、socket backlog、内存分配和唤醒都会影响成本。socket 正被用户上下文占用时,报文还可能排入 backlog,随后由合适路径处理。

MSG_DONTWAIT 只控制本次读写是否等待,不能把它当成内核收包直接写入用户缓冲区的条件。源码应结合 net/ipv4/tcp_input.c、tcp.c 和 tcp_output.c 阅读。

8. 关闭一个方向,不等于关闭整个连接

情况典型状态/行为解释
主动发送 FINFIN_WAIT1,FIN 被确认后 FIN_WAIT2本端发送方向关闭,仍可接收
收到对方 FINCLOSE_WAIT对方不再发数据,本地应用仍可发送
被动方随后关闭LAST_ACK等待本端 FIN 被确认
主动方完成双方关闭TIME_WAIT处理迟到报文及最终 ACK 重传等协议需要
双方交叉发送 FIN可能经过 CLOSING不是每次都严格串行走同一路径

TIME_WAIT 与 FIN_WAIT2 是不同状态,不能认为 tcp_fin_timeout 就是通用 TIME_WAIT 时长开关。CLOSE_WAIT 长期积累常需检查应用关闭行为;大量 TIME_WAIT 则不能单凭数量判断泄漏。

可运行的半关闭例子

下面只使用本机回环和临时端口。客户端写完后关闭发送方向,服务端看到 EOF 后仍能返回已收字节数。

import socket
import threading

errors = []
with socket.socket() as listener:
    listener.bind(('127.0.0.1', 0))
    listener.listen(1)
    listener.settimeout(3)

    def serve():
        try:
            conn, _ = listener.accept()
            with conn:
                conn.settimeout(3)
                size = 0
                while data := conn.recv(1024):
                    size += len(data)
                conn.sendall(str(size).encode('ascii'))
        except Exception as exc:
            errors.append(exc)

    worker = threading.Thread(target=serve)
    worker.start()
    try:
        with socket.create_connection(listener.getsockname(), timeout=3) as client:
            client.sendall(b'hello')
            client.shutdown(socket.SHUT_WR)
            parts = []
            while data := client.recv(1024):
                parts.append(data)
            assert b''.join(parts) == b'5'
    finally:
        worker.join(timeout=4)
    assert not worker.is_alive()
    if errors:
        raise errors[0]
print('half-close: reply received after SHUT_WR')

这个协议特意用 EOF 作为请求边界;长期复用连接一般需另设长度或帧格式。它说明 TCP 半关闭语义,不模拟断网、重传或应用幂等。