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 不会「定期重发」,它只在两种情况下重传:
- 超时(RTO):发出数据后启动定时器,超过 RTO 还没收到确认,重传最早的未确认段。
- 快速重传(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 块 | 精确指出缺口的边界 | 只重传缺口部分 |
重复 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 就是 RTT 的 2 倍」。不是。它是 SRTT + 4 × RTTVAR,抖动越大,RTO 越大。
- 「RTO 最小值是 1 秒」。老实现(RFC 1122)是 1 秒,Linux 现在用 200 ms。这个差别在低延迟链路上影响很大。
- 「超时后立刻重传整个窗口」。不是。超时后只重传最早的未确认段,并把 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):
ssthresh = max(cwnd / 2, 2)- 立刻重传丢失段
cwnd = ssthresh(快恢复,不回到 1)- 每收到一个 dup ACK,
cwnd += 1(膨胀,允许继续发新数据)
超时重传(RTO 到期):
ssthresh = max(cwnd / 2, 2)cwnd = 1(回到慢启动)- 重传最早的未确认段
两条路的差别是恢复时间。快恢复从一半开始,几个 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 会关闭自动调优,这是很多人「调了反而更慢」的原因。
调优的正确顺序是:
- 先确认瓶颈是
rwnd还是cwnd(ss -ti看snd_wnd与cwnd)。 - 如果是
rwnd小,先看应用读得够不够快,再看缓冲区。 - 如果是
cwnd小,先看有没有丢包(retrans),再看拥塞算法。 - 只有确认是 BDP 限制时,才去调大缓冲区。
一个必须记住的数量级:缓冲区要大于 BDP 才能跑满。1 Gbps × 100 ms = 12.5 MB,而默认 tcp_rmem 最大值通常只有 6 MB。这就是跨洲传输跑不满带宽的常见原因。
- 看应用供给:
ss -ti有没有app_limited。有就先查应用,别碰内核参数。 - 看接收窗口:
snd_wnd是否远小于 BDP。小就是接收端缓冲区或读得太慢。 - 看拥塞窗口:
cwnd是否长期停在 10 左右。是则可能刚建连、空闲重置、或持续丢包。 - 看丢包:
retrans比例、nstat的TcpRetransSegs增速。有丢包先查链路。 - 看乱序:
TCPOFOQueue/TCPOFODrop。增长说明多路径或链路聚合哈希不均。 - 最后才调参数:
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 时,一次丢包可能导致整个窗口重传,这是老式实现吞吐低的重要原因。
发送方依次发出 seq=1000、2000、3000、4000 四个 1000 字节的段。网络把 2000 和 3000 调换了顺序。
- 收到 1000:
rcv_nxt从 1000 前移到 2000,交付应用。 - 收到 3000:
seq > rcv_nxt,放入ofo_queue,回 ack=2000(第一个 dup ACK)。 - 收到 2000:
seq == rcv_nxt,交付 2000–2999,然后检查ofo_queue,发现 3000 已在,一并交付,rcv_nxt前移到 4000。 - 收到 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)的主要回调:
| 回调 | 何时调用 | 要做什么 | 典型实现 |
|---|---|---|---|
init | setsockopt(TCP_CONGESTION) 或建连时 | 初始化私有状态 | cubictcp_init、bbr_init |
release | 连接销毁 | 释放私有状态 | bbr_release |
ssthresh | 进入拥塞或恢复 | 返回新的慢启动阈值 | cubictcp_recalc_ssthresh |
cong_avoid | 收到有效 ACK、可以增窗 | 增 cwnd | cubictcp_cong_avoid |
cong_control | 每个 ACK(BBR 用) | 完全接管 cwnd/pacing | bbr_main |
pkts_acked | 一批包被确认 | 采样 RTT/带宽 | bbr_pkts_acked(旧版) |
undo_cwnd | 检测到伪重传 | 恢复 cwnd | cubictcp_undo_cwnd |
cwnd_event | 拥塞事件(CA_EVENT_*) | 重置状态 | cubictcp_cwnd_event |
in_ack_event | ACK 带特定标志 | 微调 | 少见 |
get_info | getsockopt(TCP_CC_INFO) | 导出算法状态 | bbr_get_info |
这张表有两个设计要点:
cong_avoid与cong_control互斥。传统算法(Reno/CUBIC)实现cong_avoid,由 TCP 核心决定何时增窗;BBR 实现cong_control,在每个 ACK 上自己算 pacing rate 和 cwnd,核心只负责执行。这就是「BBR 不像传统拥塞控制」的结构性原因。- 回调里不能睡眠。它们运行在软中断或进程的 ACK 处理路径上,持有 socket 锁;分配内存要用
GFP_ATOMIC,不能等 I/O。
驱动这些回调的输入是 struct rate_sample(include/net/tcp.h),它描述「这一轮 ACK 告诉了我们什么」:
| 字段 | 类型 | 含义 | 谁填 |
|---|---|---|---|
prior_delivered | u32 | 上次采样时已交付字节 | tcp_ack |
delivered | s32 | 本次新交付字节 | tcp_clean_rtx_queue |
interval_us | long | 距上次采样的时间 | tcp_ack |
rtt_us | long | 本次 RTT 样本 | tcp_ack_update_rtt |
losses | int | 本轮判定丢失的段数 | RACK/SACK |
acked_sacked | u32 | 被 SACK 确认的段数 | tcp_sacktag_write_queue |
prior_in_flight | u32 | 采样时在途字节 | tcp_ack |
is_app_limited | bool | 应用供给不足 | tcp_sndbuf_expand 之后 |
is_retrans | bool | 本次 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 上又不会太激进。
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_ack | net/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_main | tcp_cubic.c / tcp_bbr.c | 算目标 cwnd |
| 丢包检测 | tcp_fastretrans_alert | tcp_input.c | 判定是否进入恢复 |
| 重传 | tcp_retransmit_skb | tcp_output.c | 真正重发 |
| 超时 | tcp_retransmit_timer | tcp_timer.c | RTO 到期,进入 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);
}
...
}
三个容易忽略的条件:
- 只有「窗口受限」时才增窗。
tcp_is_cwnd_limited()检查在途数据是否填满了 cwnd;如果应用没供满数据(app_limited),增窗没有意义,内核直接跳过。这解释了ss里cwnd长期不变但吞吐正常的情况。 FLAG_ACKED才更新 RTT。纯重复 ACK 不带新确认,不能用来采样。- 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 等于把连接打回慢启动起点,这是超时与快重传代价差异的根源。
与这些路径对应的计数器:
| 计数器 | 触发点 | 说明 |
|---|---|---|
TcpExtTCPFastRetrans | tcp_fastretrans_alert | 快速重传次数 |
TcpExtTCPSlowStartRetrans | 慢启动阶段重传 | 与快重传区分 |
TcpExtTCPTimeouts | tcp_retransmit_timer | RTO 次数 |
TcpExtTCPLossProbes | tcp_send_loss_probe | TLP 探测次数 |
TcpExtTCPLossProbeRecovery | TLP 后收到 ACK | 探测成功恢复 |
TcpExtTCPSpuriousRTOs | F-RTO 检测 | 伪超时(ACK 其实在路上) |
TcpExtTCPDSACKRecv | 收到 D-SACK | 对端报告重复段 |
TcpExtTCPLostRetransmit | 重传段再次丢失 | 链路质量差的强信号 |
TcpExtTCPRACKDetected | RACK 判定丢包 | 现代内核的主要丢包检测 |
cwnd单位是 MSS,不是字节:cwnd=10在 MSS 1460 时是 14.6 KB,在回环 MSS 65483 时是 640 KB。ss的cwnd是瞬时值:采样间隔大于 RTT 时,看到的可能是锯齿的任意一点,不能凭一次采样判断趋势。ssthresh显示为2147483647表示还没经历拥塞,不是「阈值无穷大所以不控制」。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 本章陷阱清单
- 把
cwnd当字节:它是 MSS 段数;换算要乘mss_cache。 - 以为慢启动真的很慢:它是指数增长,10 个 RTT 就能到 1000 个 MSS 量级。
- 认为丢包一定减半 cwnd:超时把 cwnd 打到 1;快恢复才减半;CUBIC 的削减还按时间函数调整。
- 把
ssthresh=2147483647当异常:这是「从未拥塞」的正常初值。 - 只看
retrans不看total_retrans:前者是当前在途的标记,后者才是累计次数。 - 认为 RTO 最小值是 1 秒:Linux 是 200 ms(
TCP_RTO_MIN),且受tcp_rto_min_us影响。 - 把
TcpExtTCPTimeouts当丢包数:它只统计 RTO,快速重传不计入;总丢包要看TcpRetransSegs与 SACK 统计。 - 在
app_limited状态下调拥塞算法:应用没供满数据,换 BBR 也不会变快。 - 认为 BBR 不丢包:BBR 仍会因队列溢出丢包,只是不把丢包当拥塞信号;v1 在深队列里公平性差,v2/v3 有改进。
- 用
tc netem loss测吞吐就下结论:netem 的丢包是独立随机丢,真实网络是突发丢包,两者对拥塞控制的影响不同。 - 忘记清理 netem:规则会留在网卡上,之后所有测试都带延迟,且难以定位。
- 把
TcpExtTCPLostRetransmit当网卡故障:它只说明「重传的段又丢了」,可能是拥塞、队列或链路误码。 - 认为关掉
tcp_slow_start_after_idle一定更快:空闲后直接大窗口发送可能造成突发丢包,需配合 pacing。
4.6.18 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 说清两种重传的触发条件 | 能解释为什么是 3 个 dup ACK | 4.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 |
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 字节到底买了什么。
| 字段 | 偏移 | 长度 | 含义 | 边界与失败模式 |
|---|---|---|---|---|
| 源端口 | 0 | 2 B | 发送方端口,供对方回包 | 可为 0,表示「不需要回复」 |
| 目的端口 | 2 | 2 B | 接收方分用键 | 无监听时回 ICMP port unreachable |
| 长度 | 4 | 2 B | 首部 + 载荷的总长 | 最小 8;大于 IP 载荷则丢弃,小于则忽略尾部填充 |
| 校验和 | 6 | 2 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 + 载荷,不含伪首部
计算规则是「反码和的反码」:
- 校验和字段先置 0;
- 把伪首部、UDP 首部、载荷按 16 位大端累加,进位回卷到低 16 位;
- 对结果取反,写入校验和字段;
- 如果结果是 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 字节,而是「序号、确认、窗口、标志」这四组状态——可靠传输、流量控制、连接管理全靠它们。
| 字段 | 偏移 | 宽度 | 含义 | 关键边界 |
|---|---|---|---|---|
| 源端口 | 0 | 16 | 发送方端口 | 建连后四元组固定 |
| 目的端口 | 2 | 16 | 接收方端口 | 分用的键 |
| 序号 | 4 | 32 | 本段第一个数据字节的编号 | 初始序号 ISN 随机;回绕后比较靠模运算 |
| 确认号 | 8 | 32 | 期望收到的下一个字节 | 仅 ACK=1 有效;累积确认 |
| 数据偏移 | 12 | 4 | 首部长度 / 4 | 5 = 20 B,15 = 60 B;小于 5 非法 |
| 保留 + NS | 12 | 4 | 保留 3 位 + ECN nonce | 保留位必须为 0 |
| 标志 | 13 | 8 | CWR/ECE/URG/ACK/PSH/RST/SYN/FIN | 见下表 |
| 窗口 | 14 | 16 | 未缩放的接收窗口 | 实际窗口 = 本字段 << wscale |
| 校验和 | 16 | 16 | 伪首部 + TCP 首部 + 载荷 | 与 UDP 同样的反码和 |
| 紧急指针 | 18 | 16 | 紧急数据的偏移 | 仅 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 | 名称 | 长度 | 出现阶段 | 作用 |
|---|---|---|---|---|
| 0 | EOL | 1 | 任意 | 选项结束,后面补零 |
| 1 | NOP | 1 | 任意 | 4 字节对齐填充 |
| 2 | MSS | 4 | SYN | 通告「我能接收的最大 TCP 载荷」,不含 TCP/IP 首部 |
| 3 | Window Scale | 3 | SYN | 窗口左移位数 0–14,把 16 位窗口扩展到最大约 1 GB |
| 4 | SACK permitted | 2 | SYN | 声明支持选择性确认 |
| 5 | SACK | 2 + 8n | ACK | 报告已收到的字节块;40 字节选项区最多放 4 块(34 B) |
| 8 | Timestamps | 10 | 任意 | 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/b | tcpi_rtt / tcpi_rttvar | μs(显示为 ms) | 平滑 RTT / RTT 偏差 | 把 0.043 当成 43 ms |
rto | tcpi_rto | μs(显示 ms) | 当前重传超时 | 把它当固定值,实际随 RTT 变 |
ato | tcpi_ato | μs(显示 ms) | 延迟 ACK 超时 | 以为总是 40 ms |
mss | tcpi_snd_mss | 字节 | 发送 MSS | 与 pmtu 混用 |
pmtu | tcpi_pmtu | 字节 | 路径 MTU | 以为它等于接口 MTU |
cwnd | tcpi_snd_cwnd | MSS | 拥塞窗口 | 当字节数 |
retrans:a/b | tcpi_retrans / tcpi_total_retrans | 段 | 当前在途重传 / 累计重传 | 只看 b 或只看 a |
bytes_acked | tcpi_bytes_acked | 字节 | 被确认的字节数 | 与 bytes_sent 混用 |
unacked | tcpi_unacked | 段 | 在途未确认段数 | 当成字节 |
delivery_rate | tcpi_delivery_rate | B/s(显示为 bps) | 交付速率滑动平均 | 当瞬时速率 |
busy | tcpi_busy_time | ms | 有在途数据的累计时间 | 当 CPU 时间 |
rwnd_limited | tcpi_rwnd_limited | ms | 因接收窗口受限的时间 | 与 sndbuf_limited 混用 |
app_limited | tcpi_delivery_rate_app_limited | 0/1 | 采样受应用供给限制 | 忽略它去调拥塞算法 |
读这张表要抓住三点:
rtt存的是微秒,ss显示毫秒。rtt:0.043/0.02是 43 μs 和 20 μs,不是 43 ms。回环上看到零点几毫秒是正常的。retrans的分子是「当前在途的重传段」,分母才是累计。retrans:0/500表示历史上重传过 500 次,此刻没有在途重传;不要因为分子是 0 就认为没丢过包。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
读数分三步:
- 先看
rtt_ms。加了 20 ms 单向延迟,RTT 应在 40 ms 附近;如果只有 20 ms,说明netem只作用在一个方向(回环上双向都经过 qdisc,通常看到 40 ms)。 - 再看
cwnd的形状。没有丢包时它单调上升;total_retrans跳一次,cwnd往往在同一拍或下一拍下降,然后重新爬。只采到一次下降不算证据,要看到「下降—恢复」的完整段。 - 最后对账。
ss的total_retrans是单条连接的累计,nstat的TcpRetransSegs是全机累计;两者数量级应接近,但不会相等(还有别的连接、SYN 重传等)。
4.6.23 内核路径:TCP 选项写入与 UDP 校验和
4.6.13 跟的是 ACK 到 cwnd 的路径。这一节补两条与首部直接相关的路径:TCP 发送时选项怎么写进去,UDP 发送时校验和在哪算。
| 方向 | 函数 | 文件 | 做什么 |
|---|---|---|---|
| TCP 发送 | tcp_sendmsg_locked → tcp_push | net/ipv4/tcp.c | 拷贝用户数据,挂到发送队列 |
| TCP 发送 | tcp_write_xmit | net/ipv4/tcp_output.c | 按 cwnd 和窗口取段 |
| TCP 发送 | tcp_transmit_skb → tcp_options_write | 同上 | 填首部、选项、校验和,交给 IP |
| TCP 接收 | tcp_v4_rcv → tcp_rcv_established | net/ipv4/tcp_ipv4.c、tcp_input.c | 查控制块,走快路径 |
| TCP 接收 | tcp_parse_options | net/ipv4/tcp_input.c | 逐 kind 解析选项 |
| UDP 发送 | udp_sendmsg → udp_send_skb | net/ipv4/udp.c | 填长度、算校验和 |
| UDP 发送 | ip_send_skb | net/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 校验和 0 当「校验失败」:IPv4 里 0 表示发送方没算校验和,接收方放行;IPv6 里 0 才是非法。抓包工具对两者的标注不同。
- 认为 UDP 长度字段一定等于 IP 载荷:合法包应当相等;不相等时内核按 UDP 长度截断,多出的字节被忽略。构造包时写错长度,会出现「收到了但应用少数据」。
- 把 TCP 的
doff当固定 5:带选项时是 6–15,按doff * 4算首部长度。用固定 20 字节偏移解析,会在 SYN 和带时间戳的 ACK 上读错。 - 认为窗口字段就是最终窗口:16 位字段要左移 wscale。忽略缩放会把 8 MB 窗口读成 64 KB。
- 以为 wscale 可以中途改:它只在 SYN 里协商一次。中间设备剥掉选项后,两端都退回 16 位窗口。
- 把 MSS 当 MTU:MSS 只算 TCP 载荷,不含 TCP/IP 首部;
MSS = MTU - 40只在 IPv4 无选项时成立。 - 把
ss的rtt当毫秒:内核存微秒,ss显示毫秒;0.043是 43 μs。 - 把
retrans:0/500的 0 当没重传:分子是当前在途重传,分母才是累计;要看分母和nstat的增速。 - 在
app_limited时调拥塞算法:应用没供满数据,delivery_rate测的是应用速度,不是链路容量;换 BBR 也不会变快。 - 把抓包里的
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_skb | 4.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,看协议怎么在字节流上定义消息。