Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

4.6 TCP 可靠传输与拥塞控制

本章回答

丢了一个包,TCP 怎么知道?为什么有时等 200 毫秒就重传,有时要等好几秒?「收到 3 个重复 ACK 就重传」这条规则的依据是什么?cwnd 从 10 涨到 100 要多久,为什么长距离链路特别慢?CUBIC 和 BBR 到底在赌什么?Nagle 和延迟 ACK 为什么会凑出 40 毫秒的停顿?

读完你会

用 RFC 6298 手算 RTO;用 ss -ti 的判断 cwnd 是在涨还是在跌;用 tc netem 人为制造丢包和延迟,观察重传与窗口变化;用 bpftrace 抓 tcp_retransmit_skb 定位重传来源;解释 Nagle、延迟 ACK、零窗口三者的相互作用。

4.5 讲的是连接怎么开始、怎么结束。这一章讲中间那段:数据怎么可靠地、不把网络压垮地送过去。这是 TCP 最复杂的部分,也是「调优」这个词最常被误用的地方——很多人一上来就改 tcp_wmem,却没看过 ss -ti 里的 cwnd 到底卡在哪。

这一章分两条线。第一条是可靠性:序号、确认、重传、RTT 估计。第二条是效率:滑动窗口、拥塞控制、Nagle。两条线共用同一组字段(snd_una、snd_nxt、snd_wnd、cwnd),所以先把字段讲清,再讲算法怎么改这些字段。

读法建议:这一章的数字(200 ms、3 个重复 ACK、40 ms)都是有出处的默认值,不是随便写的。看到数字先问「这个值在哪个 sysctl 里、改了会怎样」。所有实验都用 tc netem 在本机回环上做,不需要外网。

4.6.1 重传:两种触发方式

TCP 不会「定期重发」,它只在两种情况下重传:

  1. 超时(RTO):发出数据后启动定时器,超过 RTO 还没收到确认,重传最早的未确认段。
  2. 快速重传(fast retransmit):收到 3 个重复 ACK(dup ACK),立刻重传,不等定时器。

第二种更快。设想发送方发了 1、2、3、4、5 五个段,第 2 个丢了。接收方收到 1 后 ack=2;收到 3 时发现缺 2,仍然 ack=2(这就是重复 ACK);收到 4、5 也各回 ack=2。发送方连续收到 3 个 ack=2,就知道「2 大概率丢了」,立刻重传,而不必等 RTO 超时。

为什么是 3 个而不是 1 个?因为乱序也会产生重复 ACK。如果 2 只是走得慢,3、4、5 先到了,同样会产生重复 ACK。等 3 个是一种折中:乱序通常不会连续影响 3 个段,而真丢包几乎必然产生 3 个以上重复 ACK。

# 看乱序阈值(超过这个值才认为是丢包而不是乱序)
sysctl net.ipv4.tcp_reordering net.ipv4.tcp_max_reordering
# 看快速重传的统计
nstat -az | grep -E 'TcpExtTCPFastRetrans|TcpExtTCPSlowStartRetrans|TcpRetransSegs'

TcpExtTCPFastRetrans 是快速重传次数,TcpExtTCPSlowStartRetrans 是慢启动阶段的重传,TcpRetransSegs 是总重传数。如果总重传远大于快速重传,说明大量重传靠超时触发——这意味着 RTO 太大或者重复 ACK 收不到(比如窗口太小,同时在途的段不足 3 个)。

后一种情况很常见:窗口只有 2 个 MSS 时,丢一个包最多产生 1 个重复 ACK,永远凑不够 3 个,只能等 RTO。小窗口连接对丢包特别敏感,这是长距离链路上小窗口性能差的深层原因。

三种信号对应三种反应,记住这张表就不会混淆:

信号含义发送方动作
3 个重复 ACK后续段能到达,中间缺了一段快重传,cwnd 减半
RTO 超时连 ACK 都回不来重传,cwnd 降到 1
SACK 块精确指出缺口的边界只重传缺口部分
定义 4.6.1 · 重复 ACK 与 SACK

重复 ACK(duplicate ACK) 指确认号与上一个 ACK 相同的 ACK,表示「我还在等那个没到的字节」。它既可能由丢包引起,也可能由乱序引起。SACK(选择性确认) 在选项里报告「我还收到了哪些块」,让发送方一次就知道丢了哪几段,不必依赖重复 ACK 的个数。现代 Linux 默认开启 SACK,所以「3 个重复 ACK」这条规则在实际中常被 SACK 信息增强。

4.6.2 RTT 与 RTO:怎么估一个「合理的等待时间」

RTO 不能设成固定值。设小了,正常抖动会触发误重传,越重传越拥塞;设大了,真丢包时要等很久。TCP 的做法是持续测量 RTT,动态估算。

RFC 6298 定义了四个变量和两个平滑系数:

第一个样本:  SRTT = R
             RTTVAR = R / 2
             RTO = SRTT + max(G, 4 × RTTVAR)

后续样本:    RTTVAR = (1 - β) × RTTVAR + β × |SRTT - R|
             SRTT   = (1 - α) × SRTT + α × R
             RTO    = SRTT + max(G, 4 × RTTVAR)

其中 α = 1/8,β = 1/4,G 是时钟粒度。系数这么小,是为了让 SRTT 对单个异常样本不敏感——一个突然变大的 RTT 只会让 SRTT 移动一点点,而 RTTVAR 会明显变大,从而把 RTO 推高。RTO 的调整主要靠 RTTVAR,不是靠 SRTT,这是理解这套公式的关键。

下面这段程序按 RFC 6298 实现,输入几个 RTT 样本,看 RTO 怎么变:

/* rto.c:按 RFC 6298 计算 SRTT / RTTVAR / RTO(整数微秒) */
#include <stdint.h>
#include <stdio.h>

struct rtt_state {
    int64_t srtt;      /* 平滑 RTT,微秒 */
    int64_t rttvar;    /* RTT 偏差,微秒 */
    int64_t rto;       /* 重传超时,微秒 */
    int     have;      /* 是否已有第一个样本 */
};

static int64_t clamp_rto(int64_t rto)
{
    const int64_t min_rto = 200000;    /* Linux 下限 200 ms */
    const int64_t max_rto = 120000000; /* Linux 上限 120 s */
    if (rto < min_rto) return min_rto;
    if (rto > max_rto) return max_rto;
    return rto;
}

static void rtt_sample(struct rtt_state *s, int64_t r)
{
    if (!s->have) {
        s->srtt   = r;
        s->rttvar = r / 2;
        s->rto    = clamp_rto(s->srtt + 4 * s->rttvar);
        s->have   = 1;
        return;
    }
    int64_t err = s->srtt - r;
    if (err < 0) err = -err;
    s->rttvar = (3 * s->rttvar + err) / 4;     /* beta = 1/4 */
    s->srtt   = (7 * s->srtt + r) / 8;         /* alpha = 1/8 */
    s->rto    = clamp_rto(s->srtt + 4 * s->rttvar);
}

int main(void)
{
    struct rtt_state s = {0};
    int64_t samples[] = { 100000, 120000, 90000, 300000, 110000 };

    for (size_t i = 0; i < sizeof samples / sizeof samples[0]; i++) {
        rtt_sample(&s, samples[i]);
        printf("rtt=%6.1f ms  srtt=%6.1f  rttvar=%6.1f  rto=%6.1f ms\n",
               samples[i] / 1000.0, s.srtt / 1000.0,
               s.rttvar / 1000.0, s.rto / 1000.0);
    }
    printf("超时后退避: ");
    for (int i = 0; i < 5; i++) {
        s.rto = s.rto * 2 > 120000000 ? 120000000 : s.rto * 2;
        printf("%.0f ", s.rto / 1000.0);
    }
    printf("ms\n");
    return 0;
}

运行结果:

rtt= 100.0 ms  srtt= 100.0  rttvar=  50.0  rto= 300.0 ms
rtt= 120.0 ms  srtt= 102.5  rttvar=  42.5  rto= 272.5 ms
rtt=  90.0 ms  srtt= 100.9  rttvar=  35.0  rto= 240.9 ms
rtt= 300.0 ms  srtt= 125.8  rttvar=  76.0  rto= 429.9 ms
rtt= 110.0 ms  srtt= 123.8  rttvar=  61.0  rto= 367.7 ms
超时后退避: 735 1471 2942 5883 11767 ms

第三个样本 RTT 降到 90 ms,RTO 却从 300 降到 240——只降了一点点,因为 RTTVAR 还很大。第四个样本跳到 300 ms,RTO 立刻涨到 430。这种「涨得快、降得慢」是刻意设计的安全偏置:宁可多等,也不要误重传。

超时后 RTO 指数退避(735、1471、2942……),直到上限 120 秒。这是为了防止「网络已经拥塞,还在拼命重传」。Linux 里由 tcp_retries2(默认 15)限制总次数,大约 13–30 分钟后放弃连接。

RTO 相关的三个误解
  1. 「RTO 就是 RTT 的 2 倍」。不是。它是 SRTT + 4 × RTTVAR,抖动越大,RTO 越大。
  2. 「RTO 最小值是 1 秒」。老实现(RFC 1122)是 1 秒,Linux 现在用 200 ms。这个差别在低延迟链路上影响很大。
  3. 「超时后立刻重传整个窗口」。不是。超时后只重传最早的未确认段,并把 cwnd 降到 1 个 MSS(进入慢启动)。这就是「一次丢包导致吞吐骤降」的机制。

4.6.3 滑动窗口:发送方到底能发多少

TCP 的发送方维护三个指针:

snd_una ──────── snd_nxt ──────────── snd_una + snd_wnd
   │                 │                        │
   │ 已确认           │ 已发送未确认            │ 窗口右边界
   └─────────────────┴────────────────────────┘
        在途数据 = snd_nxt - snd_una
  • snd_una:最早未被确认的字节序号。
  • snd_nxt:下一个要发送的字节序号。
  • snd_wnd:接收方通告的窗口,右边界 = snd_una + snd_wnd。

实际能发多少,取三个限制的最小值:

可发 = min(snd_wnd - (snd_nxt - snd_una), cwnd - (snd_nxt - snd_una), 应用还有多少数据)

第一个是流量控制(接收端能力),第二个是拥塞控制(网络能力),第三个是应用供给。排障时看到的「发不出去」,一定要先分清是哪一个:ss -ti 的 snd_wnd、cwnd、app_limited 正好对应这三个。

窗口滑动的过程:收到 ack=N,snd_una 前进到 N,窗口右边界跟着前移,新的数据可以进入发送队列。这就是「滑动」的含义——窗口大小可以不变,但位置在前进。

# 看一条连接的三个窗口
ss -tin state established '( sport = :8080 )'
# 关键字段:snd_wnd(对方通告)、cwnd(拥塞窗口)、snd_cwnd(同 cwnd)

4.6.4 慢启动与拥塞避免:cwnd 的两段曲线

拥塞控制的核心变量是 cwnd(拥塞窗口),单位是 MSS。它的变化分两段:

cwnd
 40 │                                      ╭────── 拥塞避免:线性增长
    │                                 ╭────╯        (每 RTT +1 MSS)
 30 │                            ╭────╯
    │                       ╭────╯
 20 │                  ╭────╯
    │             ╭────╯  ← ssthresh(慢启动阈值)
 10 │        ╭────╯
    │   ╭────╯  慢启动:每 RTT 翻倍(指数)
  1 │╭──╯
    └──────────────────────────────────────────────▶ 时间(RTT)
         │
         └─ 发生丢包:ssthresh = cwnd/2,cwnd 回到 1(超时)
                     或 ssthresh = cwnd/2,cwnd = ssthresh(快恢复)

读这张图要抓住三点。第一,慢启动不慢,它是指数增长,每个 RTT 翻一倍,从 1 到 100 只需要约 7 个 RTT。第二,ssthresh 是转折点,超过它就改成线性增长,避免把网络一下打爆。第三,丢包后的处理决定恢复速度:超时把 cwnd 打回 1,快恢复只降到一半。

慢启动的初始窗口在 Linux 上是 10 个 MSS(TCP_INIT_CWND),不是 1。这是 RFC 6928 的改动,目的是让短连接更快结束——一个 RTT 就能发 14 KB,足够大多数 HTTP 响应。

tcp_slow_start_after_idle 默认开启:连接空闲超过 RTO 后,cwnd 会重置回初始值。这对「间歇性发送」的应用很不利——每次空闲后都要重新爬坡。长连接但低频的 RPC 场景通常要关掉它:

sysctl net.ipv4.tcp_slow_start_after_idle
# 关闭(注意:可能加剧拥塞,只在可控网络里做)
sysctl -w net.ipv4.tcp_slow_start_after_idle=0

4.6.5 快重传与快恢复:丢包后的两条路

丢包后 TCP 有两条恢复路径,走哪条取决于「有没有收到足够多的重复 ACK」。

快重传 + 快恢复(收到 3 个 dup ACK):

  1. ssthresh = max(cwnd / 2, 2)
  2. 立刻重传丢失段
  3. cwnd = ssthresh(快恢复,不回到 1)
  4. 每收到一个 dup ACK,cwnd += 1(膨胀,允许继续发新数据)

超时重传(RTO 到期):

  1. ssthresh = max(cwnd / 2, 2)
  2. cwnd = 1(回到慢启动)
  3. 重传最早的未确认段

两条路的差别是恢复时间。快恢复从一半开始,几个 RTT 就回到原水平;超时要从 1 重新爬,长距离链路上可能要几秒。所以同样的丢包率,超时多的连接吞吐远低于快重传多的连接。

现代 Linux 还引入了两个改进:

  • RACK(Recent ACK):不再单纯依赖 dup ACK 计数,而是用「最近收到的 ACK 的时间」判断哪个段丢了。它对乱序更鲁棒,在 4.11 之后默认启用。
  • TLP(Tail Loss Probe):窗口末尾丢包时没有后续段能触发 dup ACK,TLP 在 RTO 之前主动发一个探测包,把「等超时」变成「快速重传」。
# 看恢复相关统计
nstat -az | grep -E 'TcpExtTCPLostRetransmit|TcpExtTCPTimeouts|TcpExtTCPSynRetrans'
# 看 RACK/TLP 相关开关
sysctl net.ipv4.tcp_recovery net.ipv4.tcp_early_retrans

TcpExtTCPTimeouts 是超时次数,TcpExtTCPLostRetransmit 是「重传的包又丢了」的次数。后者增长说明链路质量很差,重传都过不去。

4.6.6 CUBIC:用三次函数代替线性增长

慢启动 + 拥塞避免的经典组合(Reno)在高带宽长距离链路上表现很差:每个 RTT 只加 1 个 MSS,要把 10 Gbps、100 ms RTT 的链路填满需要几十分钟。CUBIC 换了一个增长函数:

W(t) = C × (t - K)³ + W_max

其中 W_max 是上次丢包时的窗口,K 是「回到 W_max 所需的时间」,C 是常数 0.4。这条曲线的特点是:离 W_max 远时涨得快,接近时涨得慢,超过后再次加速。它不依赖 RTT,所以不同 RTT 的流能更公平地竞争。

CUBIC 还有两个细节:

  • 快速收敛(fast convergence):如果新流的 W_max 比旧流小,说明网络可能刚经历过拥塞,主动把 W_max 调低一点,让出带宽。
  • TCP 友好区:在小窗口时退化成类似 Reno 的行为,避免欺负短 RTT 的流。
# 看当前算法与可用算法
sysctl net.ipv4.tcp_congestion_control
sysctl net.ipv4.tcp_available_congestion_control
# 临时切换(需要模块已加载)
sysctl -w net.ipv4.tcp_congestion_control=bbr

Linux 默认是 CUBIC。它在大多数场景下表现均衡,但在有随机丢包(非拥塞丢包) 的链路上会误判——把无线误码当成拥塞,白白降速。

4.6.7 BBR:把「丢包」换成「带宽和延迟」

BBR(Bottleneck Bandwidth and Round-trip propagation time)的出发点是:丢包不等于拥塞。它不再用丢包当信号,而是持续测量两个量:

  • BtlBw:瓶颈带宽,用「交付速率」的最大值估计。
  • RTprop:最小 RTT,用「窗口内最小 RTT」估计。

然后令 cwnd = BtlBw × RTprop × gain,pacing_rate = BtlBw × gain。gain 在不同阶段取不同值(启动阶段 2/ln2 ≈ 2.885,稳态 1.0 附近摆动)。

BBR 有四个状态:Startup(快速探带宽)、Drain(排空队列)、ProbeBW(周期性试探)、ProbeRTT(周期性测最小 RTT)。它的优点是在高丢包链路上仍能跑满带宽,缺点是对深队列不友好——它倾向于填满瓶颈缓冲区,会增加排队延迟,与其他 CUBIC 流共存时可能不公平。

为什么没有「最好的拥塞控制算法」

CUBIC 优化吞吐的稳定性,BBR 优化延迟和丢包容忍度,二者在「深队列 + 多流共存」场景下会互相挤压。选算法本质是选优化目标:数据中心内部(低 RTT、可控丢包)BBR 往往更好;公网混合流量 CUBIC 更保守。没有免费午餐,只有权衡。

4.6.8 Nagle、延迟 ACK 与 40 毫秒的停顿

这是 TCP 最经典的「坑」,值得单独讲。

Nagle 算法:如果还有未确认的小数据,就把新的小数据缓存起来,等 ACK 回来或者攒够一个 MSS 再发。目的是减少「小包泛滥」(tinygram),提高带宽利用率。

延迟 ACK:接收方收到数据后不立刻回 ACK,而是等一小会儿(Linux 常用 40 ms 量级),希望「捎带」在反向数据里,或者等第二个段一起确认。40 ms 不是内核上限:TCP_DELACK_MAX = HZ/5(HZ=1000 时 200 ms),40 ms 是 TCP_DELACK_MIN/ATO 量级;具体值随 HZ 与内核版本变。目的是减少 ACK 数量。

两者单独看都合理,凑在一起就出事:

客户端: 发 1 字节 → 等 ACK(Nagle 挡住后续小包)
服务端: 收到 1 字节 → 等 40 ms 再 ACK(延迟 ACK)
客户端: 40 ms 后收到 ACK → 才发下一个 1 字节
结果: 每次交互多 40 ms

这就是著名的「Nagle + 延迟 ACK 导致 40 ms 延迟」。它出现在「写-写-读」模式的协议里(比如某些 RPC、Redis 的 pipeline 未开启时)。

三种解法,各有代价:

解法做法代价
TCP_NODELAY关闭 Nagle,小包立刻发小包变多,带宽利用率下降
TCP_QUICKACK接收方立刻回 ACK需要每次 recv 后重设,容易忘
应用层合并写把多个小写合并成一次 writev需要改代码,但最根本
# 看 Nagle 与延迟 ACK 的统计
nstat -az | grep -E 'TcpExtDelayedACKs|TcpExtDelayedACKLocked'
# 应用侧设置(C)
# int one = 1; setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof one);

现代框架(Netty、gRPC、Go 的 net 包)默认开 TCP_NODELAY,就是因为这个坑太常见。代价是更多小包,但在局域网里可以接受。

4.6.9 零窗口、接收缓冲与调优边界

接收方的窗口来自接收缓冲区剩余空间。如果应用不读,缓冲区满,窗口变成 0,发送方停止发送并启动零窗口探测。

# 看接收/发送缓冲区的三个值:最小、默认、最大
sysctl net.ipv4.tcp_rmem net.ipv4.tcp_wmem
# 看 socket 实际的缓冲区(单位字节)
ss -tmn state established '( sport = :8080 )'

tcp_rmem 的三个值是「最小 / 默认 / 最大」,内核会在 SO_RCVBUF 和应用读取速度之间自动调整(自动调优,tcp_moderate_rcvbuf=1)。手动设 SO_RCVBUF 会关闭自动调优,这是很多人「调了反而更慢」的原因。

调优的正确顺序是:

  1. 先确认瓶颈是 rwnd 还是 cwnd(ss -ti 看 snd_wnd 与 cwnd)。
  2. 如果是 rwnd 小,先看应用读得够不够快,再看缓冲区。
  3. 如果是 cwnd 小,先看有没有丢包(retrans),再看拥塞算法。
  4. 只有确认是 BDP 限制时,才去调大缓冲区。

一个必须记住的数量级:缓冲区要大于 BDP 才能跑满。1 Gbps × 100 ms = 12.5 MB,而默认 tcp_rmem 最大值通常只有 6 MB。这就是跨洲传输跑不满带宽的常见原因。

吞吐上不去的排查顺序
  1. 看应用供给:ss -ti 有没有 app_limited。有就先查应用,别碰内核参数。
  2. 看接收窗口:snd_wnd 是否远小于 BDP。小就是接收端缓冲区或读得太慢。
  3. 看拥塞窗口:cwnd 是否长期停在 10 左右。是则可能刚建连、空闲重置、或持续丢包。
  4. 看丢包:retrans 比例、nstat 的 TcpRetransSegs 增速。有丢包先查链路。
  5. 看乱序:TCPOFOQueue / TCPOFODrop。增长说明多路径或链路聚合哈希不均。
  6. 最后才调参数:tcp_rmem/tcp_wmem、拥塞算法、tcp_notsent_lowat。

4.6.10 实验:用 netem 制造丢包,观察 cwnd

本机回环默认没有丢包、延迟极低,看不到拥塞控制的行为。用 tc netem 可以人为加上延迟和丢包:

# 给回环加 50 ms 延迟、1% 随机丢包
sudo tc qdisc add dev lo root netem delay 50ms loss 1%
# 起一个服务端,用 curl 下载一个大文件,同时观察
python3 -m http.server 8080 --bind 127.0.0.1 &
dd if=/dev/zero of=/tmp/big.bin bs=1M count=50 status=none
curl -sS -o /dev/null http://127.0.0.1:8080/big.bin &
sleep 1
ss -tin dst 127.0.0.1:8080 | head -3
# 清理
sudo tc qdisc del dev lo root

观察重点:

  • rtt 会跳到 100 ms 左右(往返各 50 ms)。
  • cwnd 会在丢包时明显下跳,然后重新爬升。
  • retrans 的分母变大,说明重传在发生。
  • delivery_rate 会稳定在一个远低于 1 Gbps 的值。

想看更细的重传来源,用 bpftrace 挂内核函数:

# 统计哪些进程在触发重传,以及重传次数
sudo bpftrace -e 'kprobe:tcp_retransmit_skb { @[comm] = count(); }'
# 看每次重传的序号与原因(需要内核符号)
sudo bpftrace -e 'kprobe:tcp_retransmit_skb { printf("%s seq=%u\n", comm, ((struct tcp_skb_cb *)arg1)->seq); }'

第二条命令依赖内核结构体布局,不同版本可能不同,跑不通就退回第一条。先用计数定位「谁在重传」,再决定要不要深挖,这是 bpftrace 的正确用法。

4.6.11 接收端:乱序队列与 SACK 打分

前面讲的都是发送方视角。接收方的工作同样复杂:它要把乱序到达的段缓存起来,等缺失的段到了再按序交付给应用。

内核里这条路径是:

tcp_v4_rcv
  → tcp_rcv_established
    → tcp_data_queue                 # 按序号判断:顺序 / 乱序 / 重复
      ├─ seq == rcv_nxt:直接加入 sk_receive_queue,唤醒应用,rcv_nxt 前移
      ├─ seq >  rcv_nxt:放入 ofo_queue(out-of-order 队列),发 dup ACK
      └─ seq <  rcv_nxt:重复段,丢弃
    → tcp_sacktag_write_queue        # 用 SACK 块标记发送队列,找出真正丢失的段

ofo_queue 是一个按序号排序的红黑树(早期是链表),每个节点是一个 sk_buff。它不能无限增长:当它占用的内存超过接收缓冲区的限制时,tcp_prune_ofo_queue 会丢弃最靠后的乱序段——因为那些段离「缺口」最远,最不可能马上用到。

这个设计有一个重要后果:乱序严重时,接收端会主动丢数据。发送方看到的是「重传了还是丢」,于是降低窗口。所以「乱序」在网络里不是无害的,它会被 TCP 当成准拥塞信号。

# 看乱序相关的计数
nstat -az | grep -E 'TcpExtTCPOFOQueue|TcpExtTCPOFODrop|TcpExtTCPOFOMerge'
  • TCPOFOQueue:进入乱序队列的段数。
  • TCPOFODrop:因内存压力被丢弃的乱序段数。
  • TCPOFOMerge:与已有乱序段合并的次数。

如果 TCPOFODrop 增长,说明乱序严重且接收缓冲区不够,应用会看到「莫名其妙的卡顿」。这时该查的是链路是否有多路径(ECMP 哈希不均会让同一条流走不同路径,产生乱序),而不是调 TCP 参数。

SACK 打分(scoreboard)是接收端的另一半工作。它记录「哪些字节块收到了」,在 ACK 的 SACK 选项里报告给发送方。发送方据此只重传缺口部分,而不是重传整个窗口。没有 SACK 时,一次丢包可能导致整个窗口重传,这是老式实现吞吐低的重要原因。

例 4.6.1 · 一次乱序的完整过程

发送方依次发出 seq=1000、2000、3000、4000 四个 1000 字节的段。网络把 2000 和 3000 调换了顺序。

  1. 收到 1000:rcv_nxt 从 1000 前移到 2000,交付应用。
  2. 收到 3000:seq > rcv_nxt,放入 ofo_queue,回 ack=2000(第一个 dup ACK)。
  3. 收到 2000:seq == rcv_nxt,交付 2000–2999,然后检查 ofo_queue,发现 3000 已在,一并交付,rcv_nxt 前移到 4000。
  4. 收到 4000:正常交付。

整个过程只产生了 1 个重复 ACK,发送方不会误判为丢包(不足 3 个)。这就是「3 个 dup ACK」阈值的作用:容忍少量乱序,只在连续异常时才重传。

4.6.12 拥塞控制的内核接口:tcp_congestion_ops 与 rate_sample

4.6.6 与 4.6.7 讲了 CUBIC 和 BBR 的公式,这一节回答「算法怎么挂进内核」。Linux 把拥塞控制抽象成一张函数指针表,换算法就是换一张表;表里的回调由 TCP 在特定事件点调用。

struct tcp_congestion_ops(include/net/tcp.h)的主要回调:

回调何时调用要做什么典型实现
initsetsockopt(TCP_CONGESTION) 或建连时初始化私有状态cubictcp_init、bbr_init
release连接销毁释放私有状态bbr_release
ssthresh进入拥塞或恢复返回新的慢启动阈值cubictcp_recalc_ssthresh
cong_avoid收到有效 ACK、可以增窗增 cwndcubictcp_cong_avoid
cong_control每个 ACK(BBR 用)完全接管 cwnd/pacingbbr_main
pkts_acked一批包被确认采样 RTT/带宽bbr_pkts_acked(旧版)
undo_cwnd检测到伪重传恢复 cwndcubictcp_undo_cwnd
cwnd_event拥塞事件(CA_EVENT_*)重置状态cubictcp_cwnd_event
in_ack_eventACK 带特定标志微调少见
get_infogetsockopt(TCP_CC_INFO)导出算法状态bbr_get_info

这张表有两个设计要点:

  1. cong_avoid 与 cong_control 互斥。传统算法(Reno/CUBIC)实现 cong_avoid,由 TCP 核心决定何时增窗;BBR 实现 cong_control,在每个 ACK 上自己算 pacing rate 和 cwnd,核心只负责执行。这就是「BBR 不像传统拥塞控制」的结构性原因。
  2. 回调里不能睡眠。它们运行在软中断或进程的 ACK 处理路径上,持有 socket 锁;分配内存要用 GFP_ATOMIC,不能等 I/O。

驱动这些回调的输入是 struct rate_sample(include/net/tcp.h),它描述「这一轮 ACK 告诉了我们什么」:

字段类型含义谁填
prior_deliveredu32上次采样时已交付字节tcp_ack
delivereds32本次新交付字节tcp_clean_rtx_queue
interval_uslong距上次采样的时间tcp_ack
rtt_uslong本次 RTT 样本tcp_ack_update_rtt
lossesint本轮判定丢失的段数RACK/SACK
acked_sackedu32被 SACK 确认的段数tcp_sacktag_write_queue
prior_in_flightu32采样时在途字节tcp_ack
is_app_limitedbool应用供给不足tcp_sndbuf_expand 之后
is_retransbool本次 ACK 确认了重传tcp_clean_rtx_queue

is_app_limited 是 BBR 最在意的字段:如果发送受应用限制,测到的带宽不是链路容量,不能用来更新 bw。没有这个标志,BBR 会把「应用发得慢」误判成「链路只有这么宽」,然后长期低速。

拥塞控制的状态挂在 struct tcp_sock 的尾部,按算法分配的私有区里。CUBIC 的私有结构:

/* net/ipv4/tcp_cubic.c,简化 */
struct bictcp {
    u32 cnt;            /* 增窗计数器 */
    u32 last_max_cwnd;  /* 上次拥塞前的最大 cwnd */
    u32 last_cwnd;
    u32 last_time;      /* 上次更新的 jiffies */
    u32 bic_origin_point;
    u32 bic_K;          /* 三次函数的 K */
    u32 delay_min;      /* 最小 RTT,用于 Reno 友好区 */
    u32 epoch_start;
    u8  found;          /* 是否已找到新的 W_max */
};

bic_K 就是 CUBIC 公式里的 K:从当前 cwnd 长到 W_max 需要的时间。CUBIC 在每个 ACK 上算「现在应该在曲线的哪个点」,再把 cwnd 拉过去;不是按 RTT 定时增加。这让它在高 BDP 链路上增长快,在低 BDP 上又不会太激进。

定义 4.6.2 · pacing 与 cwnd 的分工

cwnd 限制「一个 RTT 内能发多少」,是粗粒度的窗口。pacing 限制「每隔多久发一个包」,是细粒度的速率。只有 cwnd 时,发送方会在 ACK 到达的瞬间突发一整窗;加了 pacing 后,包被均匀铺开,交换机队列不容易溢出。Linux 的 tcp_pacing 用 sk->sk_pacing_rate 和 tcp_pacing_check() 实现,BBR 会主动设置它,CUBIC 只在检测到丢包时用。

4.6.13 内核路径:一个 ACK 如何改变 cwnd

ss -ti 里的 cwnd 不是应用写的,也不是定时器直接改的,而是每个 ACK 到达时由 tcp_ack() 驱动。这一节跟一遍这个调用链。

阶段函数文件做什么
ACK 入口tcp_acknet/ipv4/tcp_input.c校验 ack 合法性、是否重复
清发送队列tcp_clean_rtx_queue同上释放已被确认的 skb,统计 delivered
RTT 采样tcp_ack_update_rtt同上更新 srtt_us、mdev_us、rto
拥塞事件tcp_cong_avoid同上调 icsk_ca_ops->cong_avoid
增窗tcp_slow_start / tcp_cong_avoid_ai同上慢启动或拥塞避免的加法
算法cubictcp_cong_avoid / bbr_maintcp_cubic.c / tcp_bbr.c算目标 cwnd
丢包检测tcp_fastretrans_alerttcp_input.c判定是否进入恢复
重传tcp_retransmit_skbtcp_output.c真正重发
超时tcp_retransmit_timertcp_timer.cRTO 到期,进入 loss

tcp_ack() 的主干(简化,只保留与 cwnd 有关的分支):

/* net/ipv4/tcp_input.c,简化 */
static int tcp_ack(struct sock *sk, const struct sk_buff *skb, int flag)
{
    struct tcp_sock *tp = tcp_sk(sk);
    u32 prior_snd_una = tp->snd_una;
    u32 ack_seq = TCP_SKB_CB(skb)->ack_seq;
    u32 prior_in_flight;
    ...
    if (unlikely(!tcp_ack_is_valid(...)))       /* ack 超出范围 */
        goto invalid_ack;

    prior_in_flight = tcp_packets_in_flight(tp);
    if (tcp_clean_rtx_queue(sk, &flag, &prior_fack, ...)) {
        ...
    }
    if (flag & FLAG_ACKED) {                    /* 有新数据被确认 */
        tcp_ack_update_rtt(sk, flag, seq_rtt_us, sack_rtt_us, ...);
        ...
        if (tcp_is_cwnd_limited(sk))            /* 只有窗口是瓶颈时才增 */
            tcp_cong_avoid(sk, ack_seq, prior_in_flight);
    }
    ...
}

三个容易忽略的条件:

  1. 只有「窗口受限」时才增窗。tcp_is_cwnd_limited() 检查在途数据是否填满了 cwnd;如果应用没供满数据(app_limited),增窗没有意义,内核直接跳过。这解释了 ss 里 cwnd 长期不变但吞吐正常的情况。
  2. FLAG_ACKED 才更新 RTT。纯重复 ACK 不带新确认,不能用来采样。
  3. ACK 可能触发丢包判定。tcp_fastretrans_alert() 在同一个 tcp_ack() 里被调用,SACK/RACK 发现空洞就进入恢复,cwnd 可能在同一拍里先增后减。

增窗的两个函数很短,但语义不同:

/* net/ipv4/tcp_cong.c,简化 */
void tcp_slow_start(struct tcp_sock *tp, u32 acked)
{
    u32 cwnd = min(tp->snd_cwnd + acked, tp->snd_ssthresh);

    acked -= cwnd - tp->snd_cwnd;
    tp->snd_cwnd = min(cwnd, tp->snd_cwnd_clamp);
    if (!acked)
        return;
    tcp_cong_avoid_ai(tp, tp->snd_cwnd, acked);   /* 超过 ssthresh 后线性 */
}

void tcp_cong_avoid_ai(struct tcp_sock *tp, u32 w, u32 acked)
{
    if (tp->snd_cwnd_cnt >= w) {
        tp->snd_cwnd_cnt = 0;
        tp->snd_cwnd++;
    }
    tp->snd_cwnd_cnt += acked;
    if (tp->snd_cwnd_cnt >= w) {
        u32 delta = tp->snd_cwnd_cnt / w;
        tp->snd_cwnd_cnt -= delta * w;
        tp->snd_cwnd += delta;
    }
    tp->snd_cwnd = min(tp->snd_cwnd, tp->snd_cwnd_clamp);
}

snd_cwnd_cnt 是「积攒了多少个 ACK」,攒够一个窗口 w 就加 1 个 MSS——这就是「每 RTT 加 1」的实现方式。它按 ACK 计数,不按时间,所以 RTT 越大,增窗越慢,这就是 4.6.6 说 Reno 填不满长肥管道的原因。

丢包路径是另一条链:

/* net/ipv4/tcp_input.c,简化 */
static void tcp_fastretrans_alert(struct sock *sk, const u32 prior_snd_una,
                                  int num_dupack, int *ack_flag, ...)
{
    ...
    if (tcp_is_reno(tp) && num_dupack == 3) { ... }
    if (tcp_check_sack_reneging(sk, flag))
        return;
    if (tcp_is_sack(tp) && ...)
        tcp_rack_detect_loss(sk, &now);          /* RACK:按时间判丢 */
    ...
    if (flag & FLAG_LOST_RETRANS) ...            /* 重传又丢 */
    if (do_lost)
        tcp_update_scoreboard(sk, fast_rexmit);
    tcp_cwnd_reduction(sk, newly_acked_sacked, flag);
    ...
}

tcp_cwnd_reduction() 按算法回调 ssthresh() 和 undo_cwnd() 调整 cwnd,然后由 tcp_xmit_retransmit_queue() 重传。注意恢复期间的 cwnd 不是立刻减半:Linux 用「按已确认字节比例缩减」的方式,让恢复更平滑,这是相对 RFC 2581 的实现优化。

RTO 路径更粗暴:tcp_retransmit_timer() → tcp_enter_loss() 把 snd_cwnd 置 1、snd_ssthresh 减半,然后 tcp_retransmit_skb() 重发最早未确认段,并启动指数退避。一次 RTO 等于把连接打回慢启动起点,这是超时与快重传代价差异的根源。

与这些路径对应的计数器:

计数器触发点说明
TcpExtTCPFastRetranstcp_fastretrans_alert快速重传次数
TcpExtTCPSlowStartRetrans慢启动阶段重传与快重传区分
TcpExtTCPTimeoutstcp_retransmit_timerRTO 次数
TcpExtTCPLossProbestcp_send_loss_probeTLP 探测次数
TcpExtTCPLossProbeRecoveryTLP 后收到 ACK探测成功恢复
TcpExtTCPSpuriousRTOsF-RTO 检测伪超时(ACK 其实在路上)
TcpExtTCPDSACKRecv收到 D-SACK对端报告重复段
TcpExtTCPLostRetransmit重传段再次丢失链路质量差的强信号
TcpExtTCPRACKDetectedRACK 判定丢包现代内核的主要丢包检测
cwnd 观测的四个坑
  1. cwnd 单位是 MSS,不是字节:cwnd=10 在 MSS 1460 时是 14.6 KB,在回环 MSS 65483 时是 640 KB。
  2. ss 的 cwnd 是瞬时值:采样间隔大于 RTT 时,看到的可能是锯齿的任意一点,不能凭一次采样判断趋势。
  3. ssthresh 显示为 2147483647 表示还没经历拥塞,不是「阈值无穷大所以不控制」。
  4. retrans 是「当前在途段中被重传的数量」,不是累计重传;累计值看 total_retrans(ss -ti 的 retrans 分母/分子含义随版本变化,读之前先用 ss -ti 对照 nstat 的 TcpRetransSegs)。

4.6.14 程序一:每 20 毫秒采一次 cwnd,画出锯齿

ss 只能给快照。要看 cwnd 怎么变,得在进程里周期采样 TCP_INFO。这段程序连接本机服务端,边发数据边采样,输出 CSV,可直接喂给 gnuplot 或 Python 画图。

/* cwnd_trace.c:周期采样 TCP_INFO 的 cwnd/ssthresh/rtt/retrans */
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <netdb.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <time.h>
#include <unistd.h>

#ifdef __linux__
#include <netinet/tcp.h>
#include <linux/tcp.h>
#endif

static long now_ms(void)
{
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return ts.tv_sec * 1000L + ts.tv_nsec / 1000000L;
}

static int dial(const char *host, const char *port)
{
    struct addrinfo hints = { .ai_family = AF_UNSPEC, .ai_socktype = SOCK_STREAM };
    struct addrinfo *res = NULL;

    if (getaddrinfo(host, port, &hints, &res) != 0)
        return -1;
    int fd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
    if (fd >= 0 && connect(fd, res->ai_addr, res->ai_addrlen) < 0) {
        close(fd);
        fd = -1;
    }
    freeaddrinfo(res);
    return fd;
}

int main(int argc, char **argv)
{
    char chunk[16384];
    long t0;

    if (argc != 3) {
        fprintf(stderr, "用法: %s <host> <port>\n", argv[0]);
        return 2;
    }
    int fd = dial(argv[1], argv[2]);
    if (fd < 0) {
        perror("connect");
        return 1;
    }
    signal(SIGPIPE, SIG_IGN);
    memset(chunk, 'a', sizeof chunk);
    t0 = now_ms();
    printf("t_ms,cwnd,ssthresh,rtt_us,retrans,total_retrans,bytes_acked\n");

    for (int i = 0; i < 400; i++) {
        ssize_t n = send(fd, chunk, sizeof chunk, 0);
        if (n < 0 && errno != EAGAIN)
            break;
#ifdef __linux__
        struct tcp_info ti;
        socklen_t len = sizeof ti;
        memset(&ti, 0, sizeof ti);
        if (getsockopt(fd, IPPROTO_TCP, TCP_INFO, &ti, &len) == 0)
            printf("%ld,%u,%u,%u,%u,%u,%llu\n", now_ms() - t0,
                   ti.tcpi_snd_cwnd, ti.tcpi_snd_ssthresh, ti.tcpi_rtt,
                   ti.tcpi_retrans, ti.tcpi_total_retrans,
                   (unsigned long long)ti.tcpi_bytes_acked);
#else
        printf("%ld,0,0,0,0,0,0\n", now_ms() - t0);
#endif
        struct timespec ts = { .tv_sec = 0, .tv_nsec = 20 * 1000 * 1000 };
        nanosleep(&ts, NULL);
    }
    close(fd);
    return 0;
}

在 Linux 上用 tc netem 制造丢包,再采:

cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 cwnd_trace.c -o cwnd_trace
python3 -m http.server 18082 --bind 127.0.0.1 &     # 任何会读数据的服务端均可
sudo tc qdisc add dev lo root netem delay 20ms loss 1%
./cwnd_trace 127.0.0.1 18082 > /tmp/cwnd.csv
sudo tc qdisc del dev lo root

/tmp/cwnd.csv 里能看到两类形状:

  • 没有丢包:cwnd 单调上升,ssthresh 保持 2147483647,直到受 snd_wnd 或应用限制。
  • 有丢包:cwnd 突然下降,ssthresh 变成下降前的一半左右,然后重新爬。若 retrans 增长但 total_retrans 不增长,说明只是「在途段被标记重传」,不是真的重发。

这段程序刻意用 MSG_NOSIGNAL,避免对端关闭时收到 SIGPIPE 直接杀死进程——这是网络程序最常见的「无缘无故退出」原因之一。

4.6.15 程序二:netem 丢包实验脚本

手工敲 tc 命令容易忘清理,留下 netem 规则会让后续所有测试都带着延迟。下面这个脚本用 trap 保证退出时清理,并且把「制造丢包 → 传输 → 采样 → 恢复」串成一条流水线。

#!/usr/bin/env bash
# netem_lab.sh:在指定网卡上制造延迟+丢包,跑一次传输并采样
set -euo pipefail

DEV="${1:-lo}"
PORT="${2:-18082}"
DELAY="${3:-20ms}"
LOSS="${4:-1%}"
DIR="$(mktemp -d)"
SRV_PID=""

cleanup() {
    tc qdisc del dev "$DEV" root 2>/dev/null || true
    [[ -n "$SRV_PID" ]] && kill "$SRV_PID" 2>/dev/null || true
    rm -rf "$DIR"
}
trap cleanup EXIT INT TERM

# 1) 准备一个 64 MiB 的文件,供 HTTP 服务端发送
dd if=/dev/zero of="$DIR/big.bin" bs=1M count=64 status=none

# 2) 起服务端(只监听回环)
python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$DIR" &
SRV_PID=$!
sleep 0.5

# 3) 加 netem:延迟 + 随机丢包;先删旧规则,保证可重复
tc qdisc del dev "$DEV" root 2>/dev/null || true
tc qdisc add dev "$DEV" root netem delay "$DELAY" loss "$LOSS"

# 4) 后台下载,同时采样
curl -s -o /dev/null "http://127.0.0.1:$PORT/big.bin" &
CURL_PID=$!
for _ in $(seq 1 20); do
    ss -tinm state established "( sport = :$PORT )" 2>/dev/null | sed -n '2p'
    sleep 0.2
done
wait "$CURL_PID" || true

# 5) 打印累计重传统计(trap 负责删规则)
nstat -az 2>/dev/null | grep -E 'TcpRetransSegs|TcpExtTCPTimeouts|TcpExtTCPFastRetrans|TcpExtTCPLossProbes' || true

用法与预期:

chmod +x netem_lab.sh
sudo ./netem_lab.sh lo 18082 20ms 1%
  • 第 4 步每 0.2 秒打印一行 ss -tinm,能看到 cwnd 在丢包后下降、rtt 因 20 ms 延迟而变大、retrans 偶尔跳一下。
  • 最后一行给出累计计数:TcpExtTCPTimeouts 增长说明有 RTO;只有 TCPFastRetrans 增长说明丢包都被快速重传接住了。
  • trap cleanup EXIT 保证即使 Ctrl-C,netem 规则也会被删掉。忘记清理 netem 是实验室里最常见的「污染环境」事故。

如果 tc 报 Operation not supported,说明网卡不支持该 qdisc(部分虚拟网卡默认 noqueue)。改用一对 veth:

sudo ip netns add lab
sudo ip link add veth0 type veth peer name veth1
sudo ip link set veth1 netns lab
sudo ip addr add 10.9.0.1/24 dev veth0
sudo ip -n lab addr add 10.9.0.2/24 dev veth1
sudo ip link set veth0 up
sudo ip -n lab link set veth1 up
# 之后把 DEV 换成 veth0,服务端要监听 10.9.0.1

4.6.16 实验:抓一次重传,读出三种信号

实验一用 tcpdump 看「同一个序号出现两次」,实验二用 bpftrace 看「内核走的是快重传、TLP 还是 RTO」。

实验一:抓包识别重传。

# 终端 1:加 1% 丢包,起下载
sudo tc qdisc add dev lo root netem delay 20ms loss 1%
./netem_lab.sh lo 18082 20ms 1%
# 终端 2:只抓 200 个包,-S 显示绝对序号
sudo tcpdump -i lo -nn -S -c 200 'tcp port 18082' > /tmp/retrans.txt

在输出里找两类行:

IP 127.0.0.1.18082 > 127.0.0.1.51234: Flags [.], seq 1048576:1064960, ack 1, win 256, length 16384
IP 127.0.0.1.51234 > 127.0.0.1.18082: Flags [.], ack 1048576, win 512, length 0
IP 127.0.0.1.51234 > 127.0.0.1.18082: Flags [.], ack 1048576, win 512, length 0   ← 重复 ACK
IP 127.0.0.1.18082 > 127.0.0.1.51234: Flags [.], seq 1048576:1064960, ack 1, win 256, length 16384  ← 同 seq 重传

判据:

  • 相同 seq 区间出现两次,第二次就是重传;tcpdump 新版本会标 [TCP Retransmission],老版本只给序号。
  • 重复 ACK 的 ack 号相同,且 win 不变;连续 3 个之后紧跟重传,是快重传。
  • 重传前有很长间隔(约 RTO),没有 3 个重复 ACK,是超时重传。

实验二:bpftrace 看重传来源。

sudo bpftrace -e '
kprobe:tcp_retransmit_skb { @retrans[comm] = count(); }
kprobe:tcp_send_loss_probe { @tlp[comm] = count(); }
kprobe:tcp_enter_recovery { @recovery[comm] = count(); }
kprobe:tcp_enter_loss { @loss[comm] = count(); }
interval:s:10 { print(@retrans); print(@tlp); print(@recovery); print(@loss); clear(@retrans); clear(@tlp); clear(@recovery); clear(@loss); exit(); }'
现象解读
@recovery 与 @retrans 接近丢包主要靠快重传/快恢复处理,健康
@loss 增长进入 RTO 超时,吞吐会骤降,查链路或队列
@tlp 增长但 @loss 不涨TLP 在 RTO 前接住了尾包丢失,符合预期
@retrans 远大于 @recovery + @loss大量重传不在恢复流程里,查 TCPLostRetransmit 与网卡

配合 nstat -az | grep -E 'TCPTimeouts|TCPFastRetrans|TCPLossProbes|TCPLostRetransmit' 的前后差值,就能判断「重传是偶发还是持续」以及「哪条恢复路径在工作」。

4.6.17 本章陷阱清单

13 条高频错误
  1. 把 cwnd 当字节:它是 MSS 段数;换算要乘 mss_cache。
  2. 以为慢启动真的很慢:它是指数增长,10 个 RTT 就能到 1000 个 MSS 量级。
  3. 认为丢包一定减半 cwnd:超时把 cwnd 打到 1;快恢复才减半;CUBIC 的削减还按时间函数调整。
  4. 把 ssthresh=2147483647 当异常:这是「从未拥塞」的正常初值。
  5. 只看 retrans 不看 total_retrans:前者是当前在途的标记,后者才是累计次数。
  6. 认为 RTO 最小值是 1 秒:Linux 是 200 ms(TCP_RTO_MIN),且受 tcp_rto_min_us 影响。
  7. 把 TcpExtTCPTimeouts 当丢包数:它只统计 RTO,快速重传不计入;总丢包要看 TcpRetransSegs 与 SACK 统计。
  8. 在 app_limited 状态下调拥塞算法:应用没供满数据,换 BBR 也不会变快。
  9. 认为 BBR 不丢包:BBR 仍会因队列溢出丢包,只是不把丢包当拥塞信号;v1 在深队列里公平性差,v2/v3 有改进。
  10. 用 tc netem loss 测吞吐就下结论:netem 的丢包是独立随机丢,真实网络是突发丢包,两者对拥塞控制的影响不同。
  11. 忘记清理 netem:规则会留在网卡上,之后所有测试都带延迟,且难以定位。
  12. 把 TcpExtTCPLostRetransmit 当网卡故障:它只说明「重传的段又丢了」,可能是拥塞、队列或链路误码。
  13. 认为关掉 tcp_slow_start_after_idle 一定更快:空闲后直接大窗口发送可能造成突发丢包,需配合 pacing。

4.6.18 本章出口检查

检查项达标线没达标去哪
说清两种重传的触发条件能解释为什么是 3 个 dup ACK4.6.1
手算一次 RTO会用 α=1/8、β=1/4 的公式4.6.2
区分 snd_wnd 与 cwnd知道哪个是接收端、哪个是网络4.6.3
画出 cwnd 的两段曲线能标出 ssthresh 与丢包后的变化4.6.4
解释快恢复为什么比超时快知道 cwnd 从一半还是从 1 开始4.6.5
说清 CUBIC 与 BBR 的差别知道一个用丢包、一个用带宽延迟4.6.6、4.6.7
解释 40 ms 停顿的成因能说出 Nagle 与延迟 ACK 的相互作用4.6.8
说出拥塞控制回调的分工知道 cong_avoid 与 cong_control 互斥4.6.12
跟一遍 ACK 到 cwnd 的路径能指出 tcp_ack 里的三个条件4.6.13
采样并解释 cwnd 曲线能区分丢包下降与 idle 重置4.6.14
用 netem 做可重复实验会清理规则、会用 veth 兜底4.6.15
即时自测 · 4.6

Q1 发送方发了 1–5 共 5 个段,第 3 个丢失。接收方会回几个重复 ACK?发送方什么时候重传?

Q2 第一个 RTT 样本是 40 ms,第二个是 200 ms。按 RFC 6298 算,第二个样本之后的 SRTT、RTTVAR、RTO 各是多少?

Q3 ss -ti 显示 cwnd:10、snd_wnd:3000000、retrans:0/500。瓶颈在哪?为什么 cwnd 这么小?

Q4 为什么超时重传后 cwnd 要降到 1,而快恢复只降到一半?

Q5 一条 10 Gbps、RTT 80 ms 的链路,BDP 是多少?默认 tcp_rmem 最大 6 MB 时,单流吞吐上限是多少?

Q6 应用每次 write 1 字节,对端每次读完才回 ACK。延迟为什么是 40 ms 而不是 RTT?

答案

A1 接收方收到 1 回 ack=2;收到 2 回 ack=3;收到 4、5 时因为 3 没到,各回 ack=3。也就是 2 个重复 ACK(4 和 5 各一个)。不足 3 个,所以不会触发快速重传,要等 RTO 超时。这说明窗口太小时快速重传会失效。

A2 第一个样本:SRTT=40,RTTVAR=20,RTO=40+4×20=120 ms。第二个样本 R=200:|SRTT−R|=160,RTTVAR=(3×20+160)/4=55,SRTT=(7×40+200)/8=60,RTO=60+4×55=280 ms。

A3 瓶颈不在接收端(snd_wnd 很大),也不在丢包(retrans:0/500)。cwnd:10 且没有重传,最可能是连接刚建立还在慢启动,或者 app_limited(应用供不上数据)。看 ss -ti 里有没有 app_limited 就能确认。

A4 超时被当作「严重拥塞」的信号——连 ACK 都回不来了,说明路径可能已经严重堵死,必须大幅退让。快恢复收到 3 个 dup ACK,说明后续的包还能到达,网络只是轻度拥塞,所以只减半。这是「信号强度不同,反应不同」。

A5 BDP = 10e9 × 0.08 / 8 = 100 MB。窗口 6 MB 时,吞吐上限 = 6 MB / 80 ms = 75 MB/s = 600 Mbps,只有带宽的 6%。要跑满必须把缓冲区调到 100 MB 以上,或者用多流并行。

A6 因为 Nagle 挡住了后续的小包(等 ACK),而接收方的延迟 ACK 又要等最多 40 ms。两者叠加,实际等待是「延迟 ACK 的 40 ms」而不是 RTT(本机 RTT 只有几十微秒)。关掉 TCP_NODELAY 或让接收方 TCP_QUICKACK 都能消除。

4.6.19 UDP 首部:8 字节与一个可选校验和

这一章讲 TCP,但「什么时候不该用 TCP」由 UDP 回答。UDP 首部只有 8 字节,没有序号、没有确认、没有窗口;它把可靠性完全交给应用,只保留两样东西:端口分用和可选的校验和。把 UDP 与下一节的 TCP 首部并排看,就能明白 TCP 多出来的 12 字节到底买了什么。

字段偏移长度含义边界与失败模式
源端口02 B发送方端口,供对方回包可为 0,表示「不需要回复」
目的端口22 B接收方分用键无监听时回 ICMP port unreachable
长度42 B首部 + 载荷的总长最小 8;大于 IP 载荷则丢弃,小于则忽略尾部填充
校验和62 B伪首部 + 首部 + 载荷的反码和IPv4 可填 0 表示不校验;IPv6 填 0 直接丢

校验和覆盖的范围比首部大:它前面要拼一个伪首部(pseudo-header),把 IP 层的源地址、目的地址、协议号和 UDP 长度拉进来,防止「包被送错地址」这类错误。

IPv4 伪首部(12 字节,不随包发送)
┌──────────────┬──────────────┬────────┬────────┬────────────┐
│ 源 IP  4 B    │ 目的 IP 4 B   │ 全 0 1B│ 17 1B  │ UDP 长度 2B │
└──────────────┴──────────────┴────────┴────────┴────────────┘
 17 是 IP 协议号;UDP 长度 = UDP 首部 8 + 载荷,不含伪首部

计算规则是「反码和的反码」:

  1. 校验和字段先置 0;
  2. 把伪首部、UDP 首部、载荷按 16 位大端累加,进位回卷到低 16 位;
  3. 对结果取反,写入校验和字段;
  4. 如果结果是 0,写入 0xffff——因为全 0 在 IPv4 里表示「未计算」。

接收方把包括校验和字段在内的所有 16 位字再累加一次,结果应为 0xffff。下面这段程序构造一个 IPv4/UDP 包,算一次、验一次:

/* udp_csum.c:按 IPv4 伪首部计算并验证 UDP 校验和 */
#include <stdint.h>
#include <stdio.h>
#include <string.h>
static uint32_t sum16(const void *data, size_t len, uint32_t sum)
{
    const uint8_t *p = data;
    while (len > 1) {
        sum += ((uint32_t)p[0] << 8) | p[1];
        p += 2;
        len -= 2;
    }
    if (len)
        sum += (uint32_t)p[0] << 8;
    return sum;
}
static uint16_t fold(uint32_t sum)
{
    while (sum >> 16)
        sum = (sum & 0xffff) + (sum >> 16);
    return (uint16_t)sum;
}
int main(void)
{
    const uint8_t src[4] = { 192, 0, 2, 1 };
    const uint8_t dst[4] = { 198, 51, 100, 2 };
    const char payload[] = "linux-udp";
    uint8_t pseudo[12];
    uint8_t datagram[8 + sizeof payload - 1];
    uint16_t ulen = (uint16_t)sizeof datagram;
    uint32_t sum = 0;
    uint16_t csum;
    memcpy(pseudo, src, 4);
    memcpy(pseudo + 4, dst, 4);
    pseudo[8] = 0;
    pseudo[9] = 17;                     /* 协议号 UDP */
    pseudo[10] = (uint8_t)(ulen >> 8);
    pseudo[11] = (uint8_t)(ulen & 0xff);
    datagram[0] = 0x30; datagram[1] = 0x39;   /* 源端口 12345 */
    datagram[2] = 0x00; datagram[3] = 0x35;   /* 目的端口 53 */
    datagram[4] = (uint8_t)(ulen >> 8);
    datagram[5] = (uint8_t)(ulen & 0xff);
    datagram[6] = 0; datagram[7] = 0;         /* 校验和先置 0 */
    memcpy(datagram + 8, payload, sizeof payload - 1);
    sum = sum16(pseudo, sizeof pseudo, 0);
    sum = sum16(datagram, sizeof datagram, sum);
    csum = (uint16_t)~fold(sum);
    if (csum == 0)
        csum = 0xffff;                        /* 全 0 在线路上表示「未计算」 */
    datagram[6] = (uint8_t)(csum >> 8);
    datagram[7] = (uint8_t)(csum & 0xff);
    sum = sum16(pseudo, sizeof pseudo, 0);
    sum = sum16(datagram, sizeof datagram, sum);
    printf("UDP 长度 %u,校验和 0x%04x\n", ulen, csum);
    printf("含校验和字段的折叠和 = 0x%04x(应为 0xffff)\n", fold(sum));
    return fold(sum) == 0xffff ? 0 : 1;
}

编译运行:

cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 udp_csum.c -o udp_csum
./udp_csum
UDP 长度 17,校验和 0xaab5
含校验和字段的折叠和 = 0xffff(应为 0xffff)

三个常被忽略的边界:

  • IPv4 下 UDP 的校验和是可选的。发送方把 sk_no_check_tx 置 1(setsockopt(SO_NO_CHECK))就填 0,接收方看到 0 直接放行。IPv6 不允许:校验和为 0 的包会被丢弃。所以「UDP 校验和关掉能省 CPU」只在 IPv4 成立,而且会把静默损坏暴露给应用。
  • 校验和是在整段 UDP 上算的,包括分片后的所有载荷。IP 分片后每片首部的校验和独立,但 UDP 校验和只在最终重组后验证一次;任何一片丢失,整个 UDP 数据报都作废。
  • 抓包看到 bad udp cksum 不一定是真错。发送端网卡支持校验和卸载时,skb->ip_summed = CHECKSUM_PARTIAL,抓包点看到的是「还没算完」的包,到网卡才填。判断方法:在接收端抓,或看 ethtool -k 的 tx-checksum-*。

4.6.20 TCP 首部与选项:20 字节到 60 字节

TCP 首部的固定部分是 20 字节,加上选项最多 60 字节。它比 UDP 多的不只是 12 字节,而是「序号、确认、窗口、标志」这四组状态——可靠传输、流量控制、连接管理全靠它们。

字段偏移宽度含义关键边界
源端口016发送方端口建连后四元组固定
目的端口216接收方端口分用的键
序号432本段第一个数据字节的编号初始序号 ISN 随机;回绕后比较靠模运算
确认号832期望收到的下一个字节仅 ACK=1 有效;累积确认
数据偏移124首部长度 / 45 = 20 B,15 = 60 B;小于 5 非法
保留 + NS124保留 3 位 + ECN nonce保留位必须为 0
标志138CWR/ECE/URG/ACK/PSH/RST/SYN/FIN见下表
窗口1416未缩放的接收窗口实际窗口 = 本字段 << wscale
校验和1616伪首部 + TCP 首部 + 载荷与 UDP 同样的反码和
紧急指针1816紧急数据的偏移仅 URG=1 时有意义,基本废弃

八个标志位各自解决一个问题:

标志置位含义典型场景
SYN请求建连,序号同步三次握手的前两次
ACK确认号有效建连之后几乎所有包
FIN发送方没有更多数据四次挥手的第一次
RST立即重置连接端口未监听、半开连接、异常中止
PSH提示接收端尽快交付应用 write 后 push,内核在最后一个段上置位
URG紧急指针有效带外数据,极少使用
CWR / ECE拥塞窗口已减 / 显式拥塞通知路由器置 IP 头 CE;接收方回 ECE;发送方减窗后置 CWR

选项区是这张表里最容易读错的部分。每个选项用「kind + length」自描述,长度包含 kind 和 length 两个字节;kind=0 是 EOL,kind=1 是单字节 NOP,用于把总长度补到 4 字节边界。

Kind名称长度出现阶段作用
0EOL1任意选项结束,后面补零
1NOP1任意4 字节对齐填充
2MSS4SYN通告「我能接收的最大 TCP 载荷」,不含 TCP/IP 首部
3Window Scale3SYN窗口左移位数 0–14,把 16 位窗口扩展到最大约 1 GB
4SACK permitted2SYN声明支持选择性确认
5SACK2 + 8nACK报告已收到的字节块;40 字节选项区最多放 4 块(34 B)
8Timestamps10任意TSval/TSecr,用于 RTT 测量与 PAWS 防回绕
Flags [S], seq 123456789, win 64240, options [mss 1460,sackOK,TS val 123456 ecr 0,nop,wscale 7], length 0

mss 1460 说明对端 MTU 1500 且没有额外封装;wscale 7 表示双方把窗口字段左移 7 位,实际窗口是 64240 << 7 ≈ 8 MB;TS val/ecr 是时间戳。选项总长 20 字节(4 + 2 + 10 + 1 + 3),正好是 4 的倍数。

三条边界必须记住:

  • 窗口缩放只在 SYN 里协商。一旦握手完成,双方不能再改 wscale。如果中间设备剥掉了 WS 选项,两端都按 16 位窗口通信,长肥管道(高 BDP)上的吞吐会直接掉一个数量级。
  • MSS 只约束 TCP 载荷。MSS = MTU - 40(IPv4/TCP 无选项);它不包含以太网头,也不包含 TCP 选项——带 12 字节选项时,实际段长仍是 MSS,只是首部变长、有效载荷变少。
  • 时间戳会占掉 10 字节选项空间。这也是为什么开启 tcp_timestamps 后,SYN 的选项区常常刚好 20 字节,剩不下多少空间给 SACK 块。

4.6.21 tcp_info 字段表:ss -ti 的每个数字从哪来

ss -ti 打印的是 getsockopt(TCP_INFO) 的结构体,再由 iproute2 做单位换算和取舍。知道字段来源,才能判断「这个数字能不能当证据」。

ss 显示tcp_info 字段单位含义常见误读
rtt:a/btcpi_rtt / tcpi_rttvarμs(显示为 ms)平滑 RTT / RTT 偏差把 0.043 当成 43 ms
rtotcpi_rtoμs(显示 ms)当前重传超时把它当固定值,实际随 RTT 变
atotcpi_atoμs(显示 ms)延迟 ACK 超时以为总是 40 ms
msstcpi_snd_mss字节发送 MSS与 pmtu 混用
pmtutcpi_pmtu字节路径 MTU以为它等于接口 MTU
cwndtcpi_snd_cwndMSS拥塞窗口当字节数
retrans:a/btcpi_retrans / tcpi_total_retrans段当前在途重传 / 累计重传只看 b 或只看 a
bytes_ackedtcpi_bytes_acked字节被确认的字节数与 bytes_sent 混用
unackedtcpi_unacked段在途未确认段数当成字节
delivery_ratetcpi_delivery_rateB/s(显示为 bps)交付速率滑动平均当瞬时速率
busytcpi_busy_timems有在途数据的累计时间当 CPU 时间
rwnd_limitedtcpi_rwnd_limitedms因接收窗口受限的时间与 sndbuf_limited 混用
app_limitedtcpi_delivery_rate_app_limited0/1采样受应用供给限制忽略它去调拥塞算法

读这张表要抓住三点:

  1. rtt 存的是微秒,ss 显示毫秒。rtt:0.043/0.02 是 43 μs 和 20 μs,不是 43 ms。回环上看到零点几毫秒是正常的。
  2. retrans 的分子是「当前在途的重传段」,分母才是累计。retrans:0/500 表示历史上重传过 500 次,此刻没有在途重传;不要因为分子是 0 就认为没丢过包。
  3. app_limited 置位时,带宽类指标不可信。BBR 用它判断「测到的低速率是应用没发满,不是链路只有这么宽」;人也要这样判断,否则会把应用瓶颈当成网络瓶颈。

4.6.22 实验:用 ss -ti 与 nstat 对齐一条连接的生命周期

这个实验在 netem 延迟与丢包下下载一个 128 MiB 文件,每 200 ms 采一次 ss -ti,把 cwnd、rtt、重传统计写成 CSV,并在结束时打印 nstat 的累计差值。它验证两件事:丢包时 cwnd 会下跳,以及ss 的瞬时值与 nstat 的累计值能对上。

#!/usr/bin/env bash
# tcp_observe.sh:netem 下采样 ss -ti,输出 CSV 与 nstat 差值
set -euo pipefail
DEV="${1:-lo}"
PORT="${2:-18086}"
DIR="$(mktemp -d)"
SRV=""

cleanup() {
    tc qdisc del dev "$DEV" root 2>/dev/null || true
    [[ -n "$SRV" ]] && kill "$SRV" 2>/dev/null || true
    rm -rf "$DIR"
}
trap cleanup EXIT INT TERM
[[ ${EUID:-$(id -u)} -eq 0 ]] || { echo "需要 root" >&2; exit 1; }

dd if=/dev/zero of="$DIR/big.bin" bs=1M count=128 status=none
python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$DIR" >/dev/null 2>&1 &
SRV=$!
sleep 0.5

tc qdisc del dev "$DEV" root 2>/dev/null || true
tc qdisc add dev "$DEV" root netem delay 20ms loss 1%
nstat -r >/dev/null 2>&1 || true          # 清零累计计数,便于算差值

curl -s -o /dev/null "http://127.0.0.1:$PORT/big.bin" &
CURL=$!

field() { sed -n "s/.*[[:space:]]$1:\([^[:space:]]*\).*/\1/p" <<<"$2" | head -1; }
printf 't_ms,cwnd,rtt_ms,retrans,total_retrans,unacked,app_limited\n'
t0=$(date +%s%3N)
while kill -0 "$CURL" 2>/dev/null; do
    line=$(ss -tinm state established "( sport = :$PORT )" 2>/dev/null \
           | grep -m1 'cwnd:' || true)
    if [[ -n "$line" ]]; then
        cwnd=$(field cwnd "$line")
        rtt=$(field rtt "$line"); rtt=${rtt%%/*}
        rtx=$(field retrans "$line")
        unacked=$(field unacked "$line")
        limited=0
        if grep -q 'app_limited' <<<"$line"; then limited=1; fi
        printf '%s,%s,%s,%s,%s,%s,%s\n' \
            "$(( $(date +%s%3N) - t0 ))" "$cwnd" "$rtt" \
            "${rtx%%/*}" "${rtx##*/}" "$unacked" "$limited"
    fi
    sleep 0.2
done
wait "$CURL" || true

echo "== nstat 差值 =="
nstat -az 2>/dev/null | grep -E \
    'TcpRetransSegs|TcpExtTCPTimeouts|TcpExtTCPFastRetrans|TcpExtTCPLossProbes' || true

以 root 运行 chmod +x tcp_observe.sh && sudo ./tcp_observe.sh lo 18086。预期输出:

t_ms,cwnd,rtt_ms,retrans,total_retrans,unacked,app_limited
0,10,40.2,0,0,10,0
400,12,40.4,0,0,12,0
1200,18,41.0,0,1,18,0
2000,9,40.7,0,2,9,0
== nstat 差值 ==
TcpRetransSegs                 12                 0.0
TcpExtTCPFastRetrans            8                 0.0
TcpExtTCPTimeouts               2                 0.0

读数分三步:

  1. 先看 rtt_ms。加了 20 ms 单向延迟,RTT 应在 40 ms 附近;如果只有 20 ms,说明 netem 只作用在一个方向(回环上双向都经过 qdisc,通常看到 40 ms)。
  2. 再看 cwnd 的形状。没有丢包时它单调上升;total_retrans 跳一次,cwnd 往往在同一拍或下一拍下降,然后重新爬。只采到一次下降不算证据,要看到「下降—恢复」的完整段。
  3. 最后对账。ss 的 total_retrans 是单条连接的累计,nstat 的 TcpRetransSegs 是全机累计;两者数量级应接近,但不会相等(还有别的连接、SYN 重传等)。

4.6.23 内核路径:TCP 选项写入与 UDP 校验和

4.6.13 跟的是 ACK 到 cwnd 的路径。这一节补两条与首部直接相关的路径:TCP 发送时选项怎么写进去,UDP 发送时校验和在哪算。

方向函数文件做什么
TCP 发送tcp_sendmsg_locked → tcp_pushnet/ipv4/tcp.c拷贝用户数据,挂到发送队列
TCP 发送tcp_write_xmitnet/ipv4/tcp_output.c按 cwnd 和窗口取段
TCP 发送tcp_transmit_skb → tcp_options_write同上填首部、选项、校验和,交给 IP
TCP 接收tcp_v4_rcv → tcp_rcv_establishednet/ipv4/tcp_ipv4.c、tcp_input.c查控制块,走快路径
TCP 接收tcp_parse_optionsnet/ipv4/tcp_input.c逐 kind 解析选项
UDP 发送udp_sendmsg → udp_send_skbnet/ipv4/udp.c填长度、算校验和
UDP 发送ip_send_skbnet/ipv4/ip_output.c进入 IP 输出

TCP 接收端的选项解析是一个「自描述 TLV」循环,长度字段决定下一步跳多远:

/* net/ipv4/tcp_input.c,简化 */
static void tcp_parse_options(const struct sk_buff *skb,
                              struct tcp_options_received *opt_rx, int estab)
{
    const struct tcphdr *th = tcp_hdr(skb);
    const unsigned char *ptr = (const unsigned char *)(th + 1);
    int length = (th->doff * 4) - sizeof(struct tcphdr);

    while (length > 0) {
        int opcode = *ptr++;
        int opsize;

        switch (opcode) {
        case TCPOPT_EOL:
            return;
        case TCPOPT_NOP:
            length--;
            continue;
        default:
            opsize = *ptr++;
            if (opsize < 2 || opsize > length)
                return;                       /* 畸形选项,停止解析 */
            switch (opcode) {
            case TCPOPT_MSS:      opt_rx->mss_clamp = ...; break;
            case TCPOPT_WINDOW:   opt_rx->wscale_ok = 1; ...; break;
            case TCPOPT_SACK_PERM: opt_rx->sack_ok = 1; break;
            case TCPOPT_TIMESTAMP: ...; break;
            }
            ptr += opsize - 2;
            length -= opsize;
        }
    }
}

三个细节:

  • doff * 4 - 20 是选项区长度。doff 小于 5 或大于 15 的包直接丢弃;选项区里出现 opsize 越界也停止解析,不会读越界。
  • 解析是「按需」的。只有 SYN 阶段关心 MSS/WS/SACK-permitted,只有 ACK 阶段关心 SACK 块和时间戳;estab 参数决定能不能接受某些选项。
  • 发送端不重排选项。tcp_options_write() 按固定顺序写:MSS、窗口缩放、SACK permitted、时间戳、SACK 块;顺序变了不影响语义,但会让抓包对比变困难。

UDP 的校验和路径更短,核心只有几行:

/* net/ipv4/udp.c,简化 */
uh = udp_hdr(skb);
uh->len = htons(len);
uh->check = 0;
...
if (sk->sk_no_check_tx) {
    uh->check = 0;                            /* IPv4 允许不校验 */
} else {
    uh->check = udp_v4_check(len, inet->inet_saddr, fl4->daddr, 0);
    if (uh->check == 0)
        uh->check = CSUM_MANGLED_0;           /* 计算结果 0 写成 0xffff */
}
err = ip_send_skb(sock_net(sk), skb);

udp_v4_check() 内部做三件事:拼 IPv4 伪首部、对首部与载荷求反码和、取反。它不在 udp_sendmsg() 里做,而是在 udp_send_skb() 里做,因为只有到这一步才知道最终的源地址(路由可能改源地址)和 UDP 长度。

4.6.24 本章新增陷阱:UDP/TCP 首部与观测的十个坑

UDP、TCP 首部与 `ss` 观测的十个新坑
  1. 把 UDP 校验和 0 当「校验失败」:IPv4 里 0 表示发送方没算校验和,接收方放行;IPv6 里 0 才是非法。抓包工具对两者的标注不同。
  2. 认为 UDP 长度字段一定等于 IP 载荷:合法包应当相等;不相等时内核按 UDP 长度截断,多出的字节被忽略。构造包时写错长度,会出现「收到了但应用少数据」。
  3. 把 TCP 的 doff 当固定 5:带选项时是 6–15,按 doff * 4 算首部长度。用固定 20 字节偏移解析,会在 SYN 和带时间戳的 ACK 上读错。
  4. 认为窗口字段就是最终窗口:16 位字段要左移 wscale。忽略缩放会把 8 MB 窗口读成 64 KB。
  5. 以为 wscale 可以中途改:它只在 SYN 里协商一次。中间设备剥掉选项后,两端都退回 16 位窗口。
  6. 把 MSS 当 MTU:MSS 只算 TCP 载荷,不含 TCP/IP 首部;MSS = MTU - 40 只在 IPv4 无选项时成立。
  7. 把 ss 的 rtt 当毫秒:内核存微秒,ss 显示毫秒;0.043 是 43 μs。
  8. 把 retrans:0/500 的 0 当没重传:分子是当前在途重传,分母才是累计;要看分母和 nstat 的增速。
  9. 在 app_limited 时调拥塞算法:应用没供满数据,delivery_rate 测的是应用速度,不是链路容量;换 BBR 也不会变快。
  10. 把抓包里的 bad tcp cksum 当链路错误:发送端校验和卸载会让抓包点看到未算完的校验和;在接收端抓,或关闭 tx-checksum-* 再验证。

4.6.25 本章出口检查(增补)

检查项达标线没达标去哪
说出 UDP 四个字段与伪首部能解释校验和 0 与 0xffff 的区别4.6.19
逐字段读出 TCP 首部能按 doff 算首部长度、按 wscale 算窗口4.6.20
说出六个常用 TCP 选项的作用知道 MSS/WS/SACK/TS 各出现在哪个阶段4.6.20
把 ss -ti 的数字对上 tcp_info能区分 μs/ms、MSS/字节、当前/累计4.6.21
采一条连接的 cwnd 与重传能与 nstat 的累计值对账4.6.22
跟一遍 TCP 选项与 UDP 校验和路径能指出 tcp_options_write 与 udp_send_skb4.6.23

母本对应

母本章节本书落点程度
W(谢希仁)第 5 章 5.6–5.9(可靠传输、流量控制、拥塞控制、慢启动/快重传)4.6.1–4.6.8完整:序号确认、滑动窗口、四种算法
N(深入理解 Linux 网络)第 7 章 7.2–7.4(内核内存管理、TCP 对象、实测开销)4.6.3、4.6.9精选:发送/接收队列的内存视角
N(深入理解 Linux 网络)第 9 章 9.1–9.3(请求优化、接收优化、发送优化)4.6.9、4.6.10前置:调优的观测手段
N(深入理解 Linux 网络)第 7 章 7.3(TCP 内核对象与内存,PDF p214–222)4.6.12、4.6.13补充:tcp_sock 与算法私有区
K(Kerrisk)第 61 章 61.6.2、61.9(序列号、套接字选项,PDF p1086–1102)4.6.14、4.6.16精选:观测与选项
W(谢希仁)第 5 章 5.6–5.8(可靠传输、流量控制、拥塞控制,PDF p222–270)4.6.12–4.6.15补充:算法到实现的映射
K(Kerrisk)第 61 章 61.6.2(TCP 序列号和确认机制)4.6.1、4.6.3完整:序号语义与累计确认
C(CSAPP)第 11 章 11.4(套接字接口的阻塞语义)4.6.8背景:写阻塞与 TCP 缓冲的关系
W(谢希仁)第 5 章 5.5–5.6(UDP 与 TCP 首部、选项,PDF p222–270)4.6.19–4.6.20补充:字段表、校验和与选项协商
N(深入理解 Linux 网络)第 6 章 6.2–6.4(握手与 MSS/窗口缩放等选项,PDF p155–195)4.6.20补充:选项在握手时的作用
N(深入理解 Linux 网络)第 7 章 7.3(TCP 内核对象,PDF p214–222)4.6.21补充:tcp_sock 与 tcp_info 的字段来源
N(深入理解 Linux 网络)第 9 章(网络性能优化与观测,PDF p269–287)4.6.22补充:ss 与计数器对账

下一站:TCP 把字节可靠地送到了,可这些字节到底是什么意思?进入 4.7 应用层:HTTP、DNS 与 TLS,看协议怎么在字节流上定义消息。