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.4 运输层:UDP 与 TCP

本章回答

IP 已经把包送到主机了,为什么还需要一层「运输层」?端口和 socket 是什么关系,五元组为什么能唯一定位一条连接?UDP 只加 8 个字节,凭什么能撑起 DNS 和视频?TCP 首部的 20 个字节里,哪几个真正决定「可靠」?什么时候该用 UDP 而不是 TCP?

读完你会

用五元组解释一条连接的唯一性;逐字段读出 TCP 首部并算出首部长度;用一段 C 程序验证「UDP 有报文边界、TCP 没有」;用 ss -tin 读出 cwnd、rtt、重传;在 TCP 和 UDP 之间做出有理由的选择。

4.3 结尾留了一个问题:网络层把包送到了主机,可主机上跑着几百个进程,包该交给谁?答案分两步:IP 首部的协议号先区分「TCP 还是 UDP」,运输层首部的端口号再区分「哪个进程」。这两级分用,就是运输层存在的第一个理由。

第二个理由是复用。一个进程可能同时连多个对端,一条物理链路承载成千上万条流,运输层负责把它们分开、编号、按序交付。第三个理由是语义:UDP 提供「尽力而为的报文」,TCP 提供「可靠的字节流」。两种语义对应两类完全不同的应用需求,这是本章的主线。

TCP 的细节很多,但这一章只讲到「能读懂一条连接」为止。握手挥手在 4.5,可靠传输与拥塞控制在 4.6。这里先把两个协议的首部、语义和适用场景立起来,后面的机制才有地方挂。

4.4.1 端口、五元组与 socket

端口是 16 位的整数,0–65535。它标识「主机上的哪个进程(或哪组进程)」,但单独一个端口不足以定位一条连接。真正的标识是五元组:

(协议, 源 IP, 源端口, 目的 IP, 目的端口)

协议这一维经常被忽略,但它是必需的:TCP 的 80 端口和 UDP 的 80 端口是两个完全不同的东西,内核里是两张独立的哈希表。四层分用里,IP 首部的协议号选表,端口号选表项。

五元组决定了一条连接的唯一性。这解释了三个常见现象:

  • 服务端只用固定端口(443),却能接受几万条连接——因为源 IP 或源端口不同,五元组不同。
  • 同一个客户端对同一个服务端,最多只能建约 2.8 万条连接——源端口只有那么多(4.1.4 讲过)。
  • SO_REUSEPORT 让多个进程绑同一个端口,靠的是「四元组哈希 + 每进程一个监听 socket」,而不是「多条连接共享一个 socket」。

在内核里,五元组对应 struct sock 的四个字段加协议类型,查找靠哈希表:

协议哈希表查找函数
TCP(已建立)ehash(established hash)__inet_lookup_established
TCP(监听)lhash2(listener hash)__inet_lookup_listener
UDPudp_hashudp4_lib_lookup

监听 socket 和已连接 socket 分开存,是一个重要的性能设计:新连接到达时先查 ehash(精确匹配,O(1)),查不到再查 lhash2(前缀匹配,需要遍历同端口的监听者)。这样绝大多数包只走一次哈希。

定义 4.4.1 · 复用、分用与套接字

复用(multiplexing) 是多个进程共用同一个网络接口发送数据,靠端口区分。分用(demultiplexing) 是接收端根据首部字段把数据交给正确的进程。套接字(socket) 是进程与协议栈之间的编程接口,在 Linux 里由 struct socket(面向文件系统的抽象)和 struct sock(面向协议的实现)两层组成——4.10 会拆开看。

4.4.2 UDP:8 字节首部与三种典型用法

UDP 的首部只有 8 字节,四个字段:

字段长度作用
源端口2 字节回包时用作目的端口;不需要回包时可填 0
目的端口2 字节分用依据
长度2 字节首部 + 载荷,最小 8,最大 65535
校验和2 字节覆盖伪首部 + UDP 首部 + 载荷;IPv4 下可填 0 表示不校验

「伪首部」是 UDP/TCP 校验和的一个特殊设计:计算时临时拼一个包含源 IP、目的 IP、协议号、长度的 12 字节结构放在前面,但不实际传输。它的作用是让校验和覆盖 IP 地址,防止包被错误地投递到另一台主机却校验通过。

UDP 只做四件事:分用、可选的检错、保留报文边界、不保证任何顺序和送达。这种「几乎什么都不做」的设计,恰好适合三类场景:

  1. 一问一答且能重试:DNS 查询就是一个 UDP 包出去、一个回答回来,丢了客户端自己重发。用 TCP 反而要多两次握手。
  2. 实时媒体:视频、语音、游戏。丢一帧的代价小于等一帧的延迟,TCP 的重传和队头阻塞在这里是负担。
  3. 自己实现可靠性的协议:QUIC 在 UDP 上实现可靠传输和拥塞控制,因为它需要「自己控制重传时机」,而内核 TCP 不给这个控制权。

UDP 的坑也很明确:没有拥塞控制。应用如果不自限速,会把网络打到拥塞崩溃。DNS 放大攻击(伪造源 IP 发小请求、骗大响应)就是利用这一点。所以现代 UDP 服务通常要在应用层做速率限制,或者用 QUIC 这种「带拥塞控制的 UDP」。

UDP 的载荷上限是 65507,不是 65535

长度字段 16 位,最大值 65535,减去 20 字节 IP 首部和 8 字节 UDP 首部,载荷最多 65507 字节。但真正能发多少取决于 MTU:超过路径 MTU 的 UDP 包会在 IP 层被分片,任何一片丢失都会导致整个数据报被丢弃(接收端无法重组)。所以工程上 UDP 载荷通常控制在 1200–1400 字节,既避开分片,又给隧道封装留余量。

# 看本机所有 UDP 监听端口
ss -uanp
# 用 nc 做一个 UDP 回声测试(本机)
nc -u -l 127.0.0.1 9999 &
printf 'hello' | nc -u -w1 127.0.0.1 9999
# 抓一个 UDP 包,注意长度字段
sudo tcpdump -i lo -nn -vv -c 2 'udp port 9999'

4.4.3 TCP 首部:20 字节里的可靠性线索

TCP 首部固定 20 字节,带选项最多 60 字节。它的字段比 UDP 多得多,因为「可靠」需要额外的信息:序号、确认号、窗口、标志位。

 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
┌────────────────┬────────────────┬────────────────────────────────┐
│   源端口 16     │   目的端口 16   │                                │
├────────────────┴────────────────┴────────────────────────────────┤
│                      序号 seq 32                                 │
├──────────────────────────────────────────────────────────────────┤
│                    确认号 ack 32                                 │
├───────┬───────┬─┬─┬─┬─┬─┬─┬─┬─┬──────────────────────────────────┤
│首部长  │ 保留  │C│E│U│A│P│R│S│F│        窗口 16                    │
│4 bit  │ 3 bit │W│C│R│C│S│S│Y│I│      (可缩放,见选项)             │
│       │       │R│E│G│K│H│T│N│N│                                  │
├───────┴───────┴─┴─┴─┴─┴─┴─┴─┴─┼──────────────────────────────────┤
│          校验和 16              │        紧急指针 16                │
├────────────────────────────────┴──────────────────────────────────┤
│                    选项(0–40 字节)+ 填充                        │
└──────────────────────────────────────────────────────────────────┘

读这张图要抓住四组字段。第一组定位:源端口、目的端口,和 UDP 一样用于分用。第二组编号:seq 是「本段第一个字节在字节流里的位置」,ack 是「我期望收到的下一个字节」。第三组控制:六个标志位,SYN/FIN/RST 管连接,ACK 管确认,PSH 提示「尽快交给应用」,URG 基本废弃。第四组流控:窗口是「我还能收多少字节」,它是接收缓冲区的剩余空间。

标志位里最容易被误解的是 PSH。它不是「必须立刻发出去」,而是「告诉接收端尽快把数据交给应用」。现代实现里接收端本来就会尽快交付,所以 PSH 的实际作用很小。真正的「立刻发」由 TCP_NODELAY 控制,那是发送端行为。

首部长度 字段(data offset)的单位是 4 字节,最小值 5(20 字节),最大值 15(60 字节)。选项部分的长度 = 首部长度 × 4 − 20。常见的选项有:

选项长度作用
MSS4握手时告知「我一次最多能收多少数据」
窗口缩放(wscale)3把 16 位窗口左移最多 14 位,突破 64 KB 上限
SACK permitted2允许选择性确认,只重传真正丢的段
时间戳(TS)10更精确的 RTT 测量 + 防序号回绕(PAWS)
无操作(NOP)1对齐填充

这些选项只在握手时协商一次,之后一直生效。所以改窗口缩放或 SACK 必须重新建连接,这解释了一个运维常识:调完 TCP 参数要重启服务或等连接自然更替。

例 4.4.1 · 手工解一个 TCP 首部

抓到一个 TCP 段的前 20 字节(十六进制):

c0 6a 01 bb 00 00 00 01 00 00 00 00 50 02 20 00 00 00 00 00

逐字段拆:

字节 字段 值 解读
0–1 源端口 0xc06a = 49258 客户端临时端口
2–3 目的端口 0x01bb = 443 HTTPS
4–7 序号 1 这是第一个字节,常见于 SYN
8–11 确认号 0 SYN 阶段还没有可确认的数据
12 首部长度 0x50,高 4 位 = 5 5 × 4 = 20 字节,无选项
13 标志位 0x02 只有 SYN 置位
14–15 窗口 0x2000 = 8192 未协商缩放前的接收窗口
16–17 校验和 0x0000 抓包点未计算(offload)
18–19 紧急指针 0 未使用

这个段是「SYN,无选项,窗口 8192」。真实握手的 SYN 会带 MSS、SACK、时间戳、窗口缩放四个选项,首部长度通常是 40 字节(0xa0),所以看到 0x50 反而说明对方关掉了所有选项——这在老旧设备或刻意精简的嵌入式协议栈里才会出现。

4.4.4 两种语义:报文边界 vs 字节流

UDP 和 TCP 最本质的差别不在可靠性,而在语义:UDP 保留报文边界,TCP 是连续字节流。这个差别用一段程序就能看出来。

下面这段程序在本机回环上做两件事:UDP 发 3 个 4 字节的报文,收 3 次;TCP 一次写 12 字节,用 5 字节的小缓冲读 3 次。

/* udp_vs_tcp.c:对比「报文边界」与「字节流」 */
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>

/* UDP:发 3 个报文,收 3 次,每次正好一个报文 */
static int udp_demo(void)
{
    int s = socket(AF_INET, SOCK_DGRAM, 0);
    struct sockaddr_in a = { .sin_family = AF_INET };
    socklen_t alen = sizeof a;
    if (s < 0) return -1;
    a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
    if (bind(s, (struct sockaddr *)&a, sizeof a) < 0) return -1;
    if (getsockname(s, (struct sockaddr *)&a, &alen) < 0) return -1;

    for (int i = 1; i <= 3; i++) {
        char buf[8];
        int n = snprintf(buf, sizeof buf, "msg%d", i);
        sendto(s, buf, (size_t)n, 0, (struct sockaddr *)&a, sizeof a);
    }
    for (int i = 0; i < 3; i++) {
        char buf[64];
        ssize_t n = recv(s, buf, sizeof buf, 0);
        printf("udp recv #%d: %zd 字节\n", i + 1, n);
    }
    close(s);
    return 0;
}

/* TCP:一次写 12 字节,用小缓冲分多次读,观察「写边界」不存在 */
static int tcp_demo(void)
{
    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in a = { .sin_family = AF_INET };
    socklen_t alen = sizeof a;
    int one = 1;
    if (lfd < 0) return -1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof one);
    a.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
    if (bind(lfd, (struct sockaddr *)&a, sizeof a) < 0) return -1;
    if (listen(lfd, 1) < 0) return -1;
    if (getsockname(lfd, (struct sockaddr *)&a, &alen) < 0) return -1;

    int cfd = socket(AF_INET, SOCK_STREAM, 0);
    if (connect(cfd, (struct sockaddr *)&a, sizeof a) < 0) return -1;
    int afd = accept(lfd, NULL, NULL);
    if (afd < 0) return -1;

    if (write(cfd, "AAABBBCCCDDD", 12) != 12) return -1;
    ssize_t total = 0;
    for (int i = 0; i < 3; i++) {
        char buf[5];                        /* 故意用小缓冲 */
        ssize_t n = read(afd, buf, sizeof buf);
        if (n <= 0) break;
        total += n;
        printf("tcp recv #%d: %zd 字节(累计 %zd)\n", i + 1, n, total);
    }
    close(afd); close(cfd); close(lfd);
    return 0;
}

int main(void)
{
    if (udp_demo() != 0 || tcp_demo() != 0) {
        perror("demo");
        return 1;
    }
    return 0;
}

编译并运行,输出是确定的:

cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 udp_vs_tcp.c -o udp_vs_tcp
./udp_vs_tcp
udp recv #1: 4 字节
udp recv #2: 4 字节
udp recv #3: 4 字节
tcp recv #1: 5 字节(累计 5)
tcp recv #2: 5 字节(累计 10)
tcp recv #3: 2 字节(累计 12)

UDP 的三次 recv 各拿到一个完整报文,边界清清楚楚。TCP 一次写入 12 字节,却被读成了 5、5、2 三段。这不是 bug,是契约:TCP 只保证字节顺序,不保证「一次读对应一次写」。

这条契约直接决定了两类 bug:

  • 用 TCP 传消息却不做长度前缀/分隔符,接收端按「一次 read 一条消息」处理,会在网络分片、粘包时出错。所谓「TCP 粘包」不是 TCP 的毛病,是应用假设错了。
  • 用 UDP 传大文件,以为「发 100 个包就能收 100 个包」,会在丢包时永远凑不齐。UDP 不重传,也不保证顺序。
为什么 TCP 不保留边界

保留边界需要「每条消息带长度」和「接收端缓冲整条消息」,这会把「应用自己的协议」塞进内核。TCP 选择只做字节流,把消息边界留给应用(HTTP 用 Content-Length,gRPC 用长度前缀)。这是分层原则的体现:内核只提供最小必要语义,复杂语义交给上层。

4.4.5 序号、确认与「可靠」的含义

TCP 的「可靠」有精确含义,不是「一定送达」,而是四条承诺:

  1. 不丢:丢了会重传,直到确认或连接断开。
  2. 不重:重复的段会被序号去重,应用不会读到两份。
  3. 不乱:接收端按序号重组,应用读到的字节顺序与发送顺序一致。
  4. 有界:如果对端长时间不确认,连接会被判定超时并关闭,不会无限等待。

实现这四条的核心是序号和确认号。序号不是「第几个包」,而是「第几个字节」:

发送方: seq=1000, len=500  →  接收方期望下一个是 1500
接收方: ack=1500           →  表示 1000–1499 都收到了

因为是字节编号,所以「确认 1500」同时意味着「1499 及之前全收到」。这种累积确认简洁高效,但有一个缺点:如果 1000–1499 里只有 1200 那一个字节丢了,接收方只能反复 ack 1000(因为 1000 没到,后面都算空洞),发送方无法知道到底丢了哪一段。这就是 SACK(选择性确认)要解决的问题——接收方额外告诉发送方「我还收到了 1500–2000」。

序号的初始值(ISN)不是 0,而是随机选的。原因有两个:防止旧连接的延迟包被误认为新连接的数据;增加攻击者猜测序号的难度(否则可以伪造 RST 或注入数据)。Linux 用基于时间戳和四元组哈希的算法生成 ISN,所以同一对地址短时间内重连,ISN 也不会重复。

序号还有一个 32 位回绕的问题。一个 1 Gbps 的连接,32 位序号大约 34 秒就绕一圈。如果此时有旧的延迟包带着「看起来合法」的序号到达,接收端会把它当新数据。时间戳选项(PAWS,Protection Against Wrapped Sequences)就是为此设计的:每个段带上发送时间,接收端丢弃时间戳倒退的段。这就是为什么高带宽连接上时间戳选项几乎是必需的,而不只是为了更准的 RTT。

定义 4.4.2 · 累积确认与选择性确认

累积确认(cumulative ACK):ack 号表示「这个序号之前的所有字节都收到了」。它简单,但丢一个字节就会让后面的确认全部「卡住」。选择性确认(SACK):在选项里额外报告「我还收到了哪些不连续的块」,让发送方只重传真正缺失的部分。SACK 在 Linux 上默认开启,是高丢包链路性能的关键。

4.4.6 流量控制:别把接收端撑爆

流量控制解决的是「发送方太快、接收方来不及处理」。机制很简单:接收方在每个 ACK 里带一个窗口(window),表示「我还能接收多少字节」。发送方在途未确认的数据不能超过这个值。

窗口来自接收缓冲区的剩余空间:

可用窗口 = 接收缓冲区大小 - 已收到未读数据

SO_RCVBUF 设置缓冲区大小,但实际值会被内核翻倍(用于开销)并对齐。窗口字段只有 16 位,最大 65535;要突破这个限制必须用窗口缩放选项,把窗口值左移 wscale 位,最大 2¹⁴ 倍,也就是约 1 GB。

窗口缩放到 0 时会发生什么?发送方停止发送,启动零窗口探测(zero window probe):隔一段时间发一个 1 字节的探测包,问「现在能收了吗」。如果接收端应用一直不读,窗口一直是 0,连接就卡在那里——这就是经典的「Recv-Q 堆满、Send-Q 也堆满、双方都不动」的僵死状态。

# 看接收队列与发送队列:Recv-Q 是已收到未读,Send-Q 是已发未确认
ss -tn state established '( sport = :8080 )'
# 看窗口与拥塞窗口(-i 显示 tcp_info)
ss -tin state established '( sport = :8080 )' | head -4

ss -ti 的输出里,rwnd 是接收窗口,cwnd 是拥塞窗口,snd_wnd 是对方通告的窗口。实际能发多少 = min(cwnd, snd_wnd):前者受网络拥塞限制,后者受接收端处理能力限制。分不清这两个,就会把「接收端太慢」误判成「网络带宽不够」。

4.4.7 拥塞控制:一句话版本

流量控制防止「撑爆接收端」,拥塞控制防止「压垮网络」。两者的信号不同:流量控制用接收方通告的窗口,拥塞控制用丢包或延迟。

拥塞控制的核心是一个叫拥塞窗口(cwnd) 的变量。发送方维护它,实际发送量取 min(cwnd, rwnd)。算法分四个阶段:

阶段触发行为
慢启动连接开始或超时后cwnd 从 1–10 个 MSS 开始,每个 RTT 翻倍(指数增长)
拥塞避免cwnd 达到 ssthresh每个 RTT 加 1 个 MSS(线性增长)
快重传收到 3 个重复 ACK立刻重传丢失段,不等超时
快恢复快重传之后ssthresh 减半,cwnd 从新 ssthresh 开始,避免回到慢启动

这四步是 TCP Reno 的经典模型。现代 Linux 默认用 CUBIC,在高带宽长距离链路上表现更好;Google 的 BBR 则改用「带宽 × 延迟」建模,不把丢包当拥塞信号。这些细节在 4.6 展开,这里只需要记住:cwnd 是发送方自己维护的,不是协议字段,所以不同内核版本的默认算法不同,表现也不同。

4.4.8 TCP 还是 UDP:一张决策表

选协议不是「TCP 可靠所以更好」,而是「哪种语义匹配应用」。下面这张表按需求组织:

需求选 TCP选 UDP理由
数据不能丢(文件、数据库)✓重传和保序是刚需
延迟敏感(语音、游戏)✓等重传比丢一帧更糟
一问一答、可重试(DNS)✓省掉握手,客户端自己重发
需要广播/组播✓TCP 只支持单播
穿越 NAT、防火墙✓大多数网络放行 TCP 443
自己实现拥塞控制(QUIC)✓需要控制重传时机
极低延迟的本地通信✓Unix domain socket 更快,见 4.11

表里最容易被忽视的是「穿越 NAT」。UDP 没有连接状态,NAT 设备要靠超时来判断「这条映射还在不在」,通常 30 秒到 5 分钟。所以长连接的 UDP 应用必须自己发心跳保活,否则 NAT 映射会被回收,表现为「空闲一会儿就断」。

另一个常见误区是「UDP 比 TCP 快」。在没有丢包的链路上,UDP 的确少了握手和拥塞控制的开销,但差距通常只有百分之几。在有丢包的链路上,没有拥塞控制的 UDP 会把网络打得更糟,最终吞吐反而下降。UDP 快是因为它做得少,不是因为它更高效。

4.4.9 观测一条连接:ss -tin 字段详解

ss 是这一篇最重要的观测工具。-t 看 TCP,-i 看内部信息,-n 不解析名字。一条典型的输出:

ESTAB  0  0  127.0.0.1:51234  127.0.0.1:8080
    cubic wscale:7,7 rto:204 rtt:0.05/0.02 ato:40 mss:65483
    cwnd:10 bytes_acked:1 bytes_received:1 segs_out:5 segs_in:4
    send 10.5Mbps lastsnd:120 lastrcv:120 lastack:120
    pacing_rate 21.0Mbps delivery_rate 10.5Mbps
    app_limited rcv_space:65495 rcv_ssthresh:65495 minrtt:0.03

这些字段分成四组:

字段含义异常时的解读
cubic拥塞控制算法与服务端不一致会导致行为差异
rto:204重传超时,毫秒持续增大说明 RTT 抖动大
rtt:0.05/0.02平滑 RTT / 方差,毫秒RTT 突然变大说明排队
cwnd:10拥塞窗口,单位 MSS长期很小说明有丢包或应用发得慢
retrans重传段数 / 总段数比例 > 1% 要查链路
bytes_acked已确认字节数用来算实际吞吐
send 10.5Mbps当前发送速率估计与带宽对比判断是否跑满
delivery_rate交付速率(BBR 用)判断瓶颈在发送端还是网络
app_limited发送受应用限制说明瓶颈在应用,不在网络

app_limited 是最有价值的一个标志。它表示「发送方想发但应用没给数据」,也就是说网络没满,是应用自己慢。看到这个标志,优化网络参数毫无意义,该去查应用逻辑或上游依赖。

用 ss 判断「慢」在哪一层
  1. ss -s 看全局:连接数、TIME_WAIT 数、orphan 数。数字异常先查连接管理。
  2. ss -lnt 看监听队列:Recv-Q 是 accept 队列长度,接近 backlog 说明应用 accept 太慢。
  3. ss -tn state established 看 Send-Q:堆积说明对端不收或网络不通。
  4. ss -tin 看 cwnd、rtt、retrans、app_limited:区分网络问题与应用问题。
  5. nstat -az | grep -E 'TcpRetrans|TcpExtTCPLostRetransmit' 看累计重传,确认是否长期存在。

4.4.10 TCP 的四个定时器:时间维度

TCP 的可靠性不只靠字段,还靠四个定时器。它们定义了「多久算超时」「等多久算死」。

定时器启动时机默认值超时后做什么
重传定时器发出数据后RTO(动态计算,最小 200 ms)重传最早的未确认段,RTO 指数退避
坚持定时器收到零窗口通告逐渐退避到 60 s 上限发零窗口探测,防止死锁
保活定时器连接空闲tcp_keepalive_time = 7200 s发探测,连续失败后断开
TIME_WAIT 定时器主动关闭方进入 TIME_WAIT2 × MSL = 60 s关闭连接,释放四元组

重传定时器最关键。RTO 不是固定值,而是根据 RTT 动态估算的(Jacobson/Karels 算法):RTT 抖动大,RTO 就放大;连续超时,RTO 翻倍退避(1、2、4、8…),直到 tcp_retries2(默认 15 次,约 15 分钟)后放弃。这意味着一条已经断掉的连接,可能要十几分钟才被内核判定死亡——这是很多「连接假死」问题的根源。

坚持定时器解决零窗口死锁。接收端窗口为 0 后,如果它后来有了空间,会发一个窗口更新 ACK;但这个 ACK 如果丢了,双方就互相等:发送方等窗口更新,接收方等数据。坚持定时器让发送方定期探一下,打破这个僵局。

保活定时器默认 2 小时,太长了,实际业务通常自己发应用层心跳,或者调小:

# 看三个保活参数:空闲多久开始探、探测间隔、失败几次断开
sysctl net.ipv4.tcp_keepalive_time net.ipv4.tcp_keepalive_intvl net.ipv4.tcp_keepalive_probes
# 看重传上限:超过这个次数就放弃连接
sysctl net.ipv4.tcp_retries2

tcp_retries2 = 15 对应的实际时间取决于 RTO,通常是 13–30 分钟。对延迟敏感的服务,这个值太大——连接早就该断了,内核还在重传。把它调到 5–8 是常见的做法,代价是网络抖动时可能误断。

4.4.11 校验和与 offload:为什么 tcpdump 说校验和错误

UDP 和 TCP 的校验和都覆盖一个伪首部(pseudo-header)。它不是真实传输的字段,只在计算时临时拼出来:

伪首部(12 字节,仅参与校验和计算)
┌───────────────────────┬───────────────────────┐
│      源 IP 地址 32     │     目的 IP 地址 32    │
├───────────┬───────────┼───────────────────────┤
│  全 0 8   │  协议 8    │    TCP/UDP 长度 16     │
│           │ 6 或 17   │    首部 + 载荷         │
└───────────┴───────────┴───────────────────────┘
实际计算顺序:伪首部 + TCP/UDP 首部(校验和字段填 0)+ 载荷

伪首部的作用是把 IP 地址纳入校验范围。如果没有它,一个包被错误地投递到另一台主机,只要 TCP 首部和载荷没坏,校验和仍然通过——数据就送错了地方。加上伪首部后,地址不匹配会导致校验失败。

这里有一个非常实用的坑:抓包时看到 incorrect checksum,大多数情况下不是真的错误。原因是校验和卸载(checksum offload):协议栈把校验和的计算推迟到网卡,抓包点在网卡之前,看到的是「还没算好」的字段。接收端网卡会重新校验,所以包其实是好的。

# 看网卡的校验和卸载能力
ethtool -k eth0 | grep -E 'tx-checksum|rx-checksum'
# 临时关掉(仅用于验证抓包结论)
sudo ethtool -K eth0 tx off

判断方法:如果只有发出的包校验和错误、收到的包正常,基本可以确定是 TX offload。如果收到的包也错,才需要怀疑链路或内存问题。这个区别能省下大量误判时间。

内核也记这类错误,只是计数名字不那么直观:nstat -az | grep -i csum 会列出 TcpInCsumErrors、UdpInErrors 等。这些计数增长说明接收到的包真的坏了,与抓包点的 offload 假象不是一回事。把两者分开看,才能判断该换网卡还是该改抓包参数。

为什么 TCP 的校验和不能填 0

IPv4 的 UDP 允许校验和字段为 0,表示「不校验」;TCP 不允许,校验和是强制的。IPv6 更严格:UDP 校验和也变成强制,因为 IPv6 首部没有校验和,链路层又不保证端到端,运输层必须自己兜底。所以同一个应用在 IPv4 上能跑、迁到 IPv6 就报错,原因可能只是某个老代码把 UDP 校验和写成了 0。

4.4.12 从字节到结构体:tcphdr、udphdr 与 tcp_sock

4.4.3 讲的是字段语义,这一节把它们与内核里的 C 声明对齐:抓包看到的每个字节,在 struct tcphdr 里都有确定的类型、位宽和字节序。读错类型不会编译失败,只会得到安静的错误答案。

struct tcphdr 定义在 include/uapi/linux/tcp.h,用户态包含 <linux/tcp.h> 就能看到:

字段类型位宽字节序含义常见读错
source__be1616网络序源端口直接当主机序打印,得到 0x6ac0 这类反值
dest__be1616网络序目的端口同上
seq__be3232网络序本段首字节的序号用 %u 打印,看似随机
ack_seq__be3232网络序期望收到的下一个序号忘记 ACK 位为 0 时该字段无效
doff__u16:44主机序首部长度,单位 4 字节当成字节数,20 被读成 5
res1__u16:44主机序保留位;Linux 未实现 RFC 3540 的 NS把它当 ECN 位
cwr/ece__u16:1各 1主机序ECN 信号与 IP 首部 ECN 字段混为一谈
urg/ack/psh/rst/syn/fin__u16:1各 1主机序控制位用 th->ack 判断「是不是 ACK 包」却不看 doff
window__be1616网络序接收窗口(未缩放)忘记左移 wscale
check__be1616网络序校验和抓包点 offload 导致误判
urg_ptr__be1616网络序紧急指针现代应用几乎不用

两个容易忽略的细节:

  1. 位域布局依赖字节序。内核用 #if defined(__LITTLE_ENDIAN_BITFIELD) 把 res1:4, doff:4 与 fin…cwr 的声明顺序反过来写,保证小端机器上抓到的字节仍按 RFC 解释。你自己解析首部时不要照抄某一端的位域声明,老老实实按字节取位。
  2. doff 是首部长度,不是选项长度。选项长度 = doff × 4 − 20;doff < 5 的段在内核里直接被丢弃(tcp_validate_incoming 之前的长度检查)。

UDP 的首部简单到可以一次读完:

字段类型含义边界
source__be16源端口可为 0(不需要回包时)
dest__be16目的端口分用依据
len__be16首部 + 载荷最小 8;与 IP 总长不一致时丢弃
check__be16校验和IPv4 可 0;IPv6 不可

用户态程序读这两个结构体,只需要一段 12 行的循环:

/* 从抓到的以太网帧偏移 34 字节处读 TCP 首部(无 VLAN、无选项) */
#include <linux/tcp.h>
#include <arpa/inet.h>
#include <stdio.h>

static void dump_tcp(const unsigned char *p)
{
    const struct tcphdr *th = (const struct tcphdr *)p;
    printf("sport=%u dport=%u seq=%u ack=%u doff=%u flags=%c%c%c%c%c%c win=%u\n",
           ntohs(th->source), ntohs(th->dest),
           ntohl(th->seq), ntohl(th->ack_seq), th->doff,
           th->urg ? 'U' : '-', th->ack ? 'A' : '-', th->psh ? 'P' : '-',
           th->rst ? 'R' : '-', th->syn ? 'S' : '-', th->fin ? 'F' : '-',
           ntohs(th->window));
}

关键行是 ntohs/ntohl 与 th->doff:前者修正字节序,后者给出首部长度。把这段和 tcpdump -XX 的十六进制对照,能建立「字节 ↔ 字段」的直觉。

内核里,一条连接的「五元组 + 状态 + 缓冲区」并不在 tcphdr 里,而在三层结构体中:

结构体头文件管什么关键字段
struct sockinclude/net/sock.h与协议无关的套接字状态sk_state、sk_rcvbuf、sk_sndbuf、sk_receive_queue、sk_write_queue、sk_wq、sk_err
struct inet_sockinclude/net/inet_sock.hIPv4 地址与端口inet_saddr、inet_daddr、inet_sport、inet_dport、inet_num
struct inet_connection_sock同上面向连接的公共部分icsk_accept_queue、icsk_retransmits、icsk_pmtu_cookie、icsk_rto
struct tcp_sockinclude/linux/tcp.hTCP 专用状态机与拥塞控制rcv_nxt、snd_una、snd_nxt、snd_cwnd、srtt_us、rx_opt

访问方式是逐层强转:inet_sk(sk)、inet_csk(sk)、tcp_sk(sk)。这些强转没有运行时检查,转错类型读到的是另一块内存——这是内核模块里最常见的崩溃来源之一。

struct tcp_sock 里与观测最相关的字段:

字段类型单位谁改对应 ss 字段
rcv_nxtu32序号收包路径不直接显示
snd_unau32序号收到 ACK用于算在途字节
snd_nxtu32序号发送用于算在途字节
snd_cwndu32MSS 段数拥塞控制cwnd
snd_ssthreshu32MSS 段数拥塞控制ssthresh
snd_wndu32字节对端窗口更新snd_wnd
rcv_wndu32字节本端收包/应用读取rcv_space 的一部分
srtt_usu32微秒 × 8RTT 采样rtt(除以 8)
mdev_usu32微秒RTT 采样rtt 的第二个数
rtou32jiffiesRTO 计算rto(换算成毫秒)
total_retransu32段重传retrans 的分子
bytes_ackedu64字节收到 ACKbytes_acked
deliveredu32字节ACK 交付速率采样delivery_rate 的输入
app_limitedu8布尔发送路径app_limited
mss_cacheu16字节PMTU/MSS 协商mss

这张表解释了一个常见困惑:ss 里的 cwnd:10 不是 10 字节,而是 10 个 MSS;在 MSS=1460 时约 14.6 KB。把它当字节会得出「窗口怎么这么小」的错误结论。

ss -i 的数据来自 getsockopt(TCP_INFO),返回 struct tcp_info。这个结构体只增不改,内核按调用者传入的 optlen 决定拷贝多少:

字段组代表字段内核版本起点用途
基础tcpi_state、tcpi_ca_state、tcpi_retransmits、tcpi_probes、tcpi_backoff2.6状态与重传
选项tcpi_options、tcpi_snd_wscale、tcpi_rcv_wscale2.6协商结果
定时tcpi_rto、tcpi_ato、tcpi_last_data_sent2.6超时量级
窗口tcpi_snd_cwnd、tcpi_snd_wnd、tcpi_rcv_wnd、tcpi_snd_ssthresh2.6 / 4.1流控与拥塞
速率tcpi_delivery_rate、tcpi_busy_time、tcpi_rwnd_limited4.18BBR 与瓶颈判断
计数tcpi_bytes_acked、tcpi_bytes_received、tcpi_segs_out、tcpi_total_retrans4.1 / 4.2吞吐与重传率

所以 ss 在旧内核上会少打几列,不是 bug,是 tcp_info 还没长到那么长。自己写程序时先传 sizeof(struct tcp_info),再用 getsockopt 返回的 optlen 判断内核填到哪;tcpi_options 里的 TCPI_OPT_SACK/TCPI_OPT_TS/TCPI_OPT_WSCALE/TCPI_OPT_ECN 位表示这次握手协商出了哪些选项。

结构体层面的三个坑
  1. sk_state 与 TCP_ESTABLISHED 比较:sk_state 是 unsigned char,TCP_ESTABLISHED 是枚举 1,比较没问题;但 TCP_CLOSE 是 7,别把「状态 7」当成「关闭中」。
  2. tcp_sk(sk) 用在非 TCP socket 上:sk->sk_prot 不是 tcp_prot 时强转读到的是别的协议私有区,字段全是垃圾。
  3. srtt_us 的单位是「微秒 × 8」:直接除以 1000 得到的不是毫秒,而是毫秒 × 8。正确换算是 srtt_us >> 3 得微秒,再除 1000 得毫秒。

4.4.13 选项的真实编码:MSS、窗口缩放、SACK 与时间戳

4.4.3 列了选项的名字,这一节给出线格式。选项区是一串 TLV(type-length-value),type 1 字节,length 1 字节(包含 type 和 length 本身),value 变长。只有 EOL 和 NOP 是单字节。

kind名称长度value 内容Linux 常量
0End of Option List1无TCPOPT_EOL
1No-Operation1无(用于对齐)TCPOPT_NOP
2MSS416 位最大段长TCPOPT_MAXSEG
3Window Scale38 位移位数(0–14)TCPOPT_WINDOW
4SACK Permitted2无TCPOPT_SACK_PERM
5SACK变长n 个 8 字节块(左边界、右边界)TCPOPT_SACK
8Timestamps10TSval 32 位 + TSecr 32 位TCPOPT_TIMESTAMP
28User Timeout416 位超时(秒)TCPOPT_UTO
34TCP Fast Open变长cookieTCPOPT_FASTOPEN
30MPTCP变长多路径控制TCPOPT_MPTCP

一个真实的 SYN 选项区(24 字节)逐字节拆开:

02 04 05 b4  01 03 03 07  01 01 04 02  01 01 08 0a 3f 2a 11 c4 00 00 00 00
字节解读
02 04 05 b4kind=2,len=4,MSS=0x05b4=1460
01 03 03 07NOP + kind=3,len=3,shift=7
01 01 04 02两个 NOP + kind=4,len=2(SACK permitted)
01 01 08 0a …两个 NOP + kind=8,len=10,TSval=0x3f2a11c4,TSecr=0

24 字节选项 + 20 字节固定首部 = 44 字节,所以 doff 是 11(0xb0),抓包里看到 b0 不要以为坏了。选项区还可能补 NOP 让总长按 4 字节对齐,这就是那些「多余」的 01。

MSS 的算术。MSS 是「对方一次能收的最大 TCP 载荷」,不是 MTU:

链路MTUIP 首部TCP 首部通告 MSS
以太网 + IPv4150020201460
以太网 + IPv6150040201440
PPPoE + IPv4149220201452
IPIP 隧道 + IPv4148020201440
回环 + IPv465536202065535 附近(受内核上限裁剪)

MSS 在 SYN 里由双方各自通告;每个方向使用对端通告的值:A→B 用 B 通告的 MSS,B→A 用 A 通告的 MSS。实际发送还受本地 MTU/PMTU 约束,可能比通告值更小;不是「两边取较小者」。这解释了两个现象:容器 overlay 网络里 MSS 常常是 1452 或 1400;iptables -j TCPMSS --clamp-mss-to-pmtu 能修「能 ping 通但大包卡死」的问题。

窗口缩放。16 位窗口字段最大 65535,高带宽长延迟链路不够用。窗口缩放把窗口值左移 shift 位;shift 由双方在 SYN 里各自通告。发送方用对端通告的 shift 解释 snd_wnd,接收方用自己通告的 shift 通告 rcv_wnd;不是两边取 min:

实际窗口 = 首部 window 字段 << snd_wscale
最大窗口 = 65535 << 14 = 1,073,725,440 字节(约 1 GiB)

三个必须记住的边界:

  1. SYN 段本身的 window 不缩放。握手期间还没协商完,任何一方都不能假设对方已经应用了移位。
  2. shift 只协商一次。连接建立后再改 net.ipv4.tcp_window_scaling 不影响已有连接,只影响新连接。
  3. 窗口字段为 0 时不缩放。零窗口通告就是 0,接收方靠窗口更新(window update)恢复。

SACK 块。SACK 选项的 value 是一组 32 位边界对,每对表示「这段我收到了」:

kind=5 len=18 | left=0x00001000 right=0x00002000 | left=0x00003000 right=0x00003800

40 字节选项区最多塞 4 个块(2 + 8×4 = 34,加两个 NOP 对齐到 36,仍有余量)。接收端在 tcp_sacktag_write_queue 里给 skb 打 sacked 标记,发送端据此只重传空洞。SACK 只报告「收到了什么」,不报告「丢了什么」——丢包判断仍靠重复 ACK 或 RACK/TLP。

时间戳。TSval 是发送方单调时钟(Linux 用 tcp_clock_ms(),带每 netns 的随机偏移,避免暴露 uptime),TSecr 回显对端最近一次 TSval。它有三个用途:

  • 更准的 RTT:每个 ACK 都能配对,不必依赖重传歧义(Karn 算法)。
  • PAWS:接收端记录 ts_recent,收到 TSval 更小的段直接丢弃,防止 32 位序号回绕后的旧包被当新数据。
  • RTTM:只在有 TS 时才能测「被重传段的 ACK 到底确认的是哪一次发送」。

代价是每个段多 12 字节(10 字节选项 + 2 字节 NOP),在 64 字节小包场景下开销接近 20%。这也是某些游戏/交易场景关掉 net.ipv4.tcp_timestamps 的原因,代价是失去 PAWS 和 RTTM。

例 4.4.2 · 从抓包反推协商结果

tcpdump -vvv 在 SYN 段打印:

Flags [S], seq 1234567890, win 64240, options [mss 1460,sackOK,TS val 123456 ecr 0,nop,wscale 7], length 0

反推:

  • 本端通告 MSS 1460 → 出口 MTU 1500,走 IPv4。
  • wscale 7 → 本端接收窗口可放大到 65535 << 7 ≈ 8 MB,与 net.ipv4.tcp_rmem 的最大值同一量级。
  • win 64240 是未缩放的窗口,64240 = 65535 − 1295,说明此时接收缓冲已用掉一部分(或受 tcp_rmem 默认值限制)。
  • 没有 ecn 字样 → 本端没开 ECN,或对端不支持。

对端的 SYN-ACK 会带自己的 mss/wscale。两边取 min 后,ss -ti 里的 mss 与 wscale:7,7 就是最终结果。如果只看到 wscale:0,7,说明只有一个方向通告了缩放,另一个方向没开。

内核解析选项的入口是 tcp_parse_options()(net/ipv4/tcp_input.c),它把结果写进 struct tcp_options_received:

字段含义后续使用
mss_clamp对端通告的 MSS 上限tcp_sync_mss 算 mss_cache
saw_mss是否收到 MSS 选项没收到则用默认 536
wscale_ok / snd_wscale / rcv_wscale是否协商、两个方向的移位窗口换算
sack_ok / sack_cntSACK 许可与块数重传决策
tstamp_ok / ts_recent / rcv_tsval时间戳状态PAWS 与 RTT
dsack是否允许 D-SACK检测伪重传

没收到 MSS 选项时,RFC 1122 规定默认 536 字节;Linux 对 SYN 段有 tp->advmss 兜底,但对端不发 MSS 仍按 536 处理。这是「老设备/中间盒吞选项」导致吞吐只有几百 KB/s 的常见原因:MSS 掉到 536,窗口再大也只能小步走。

为什么 `tcpdump` 里 MSS 有时是 1460、有时是 1440

同一条以太网链路,IPv4 的 MSS 是 1460,IPv6 是 1440,差的 20 字节就是 IP 首部从 20 变 40。如果同一台机器双栈访问同一服务,抓包会看到两个不同的 MSS,这不是两边配置不一致,而是地址族不同。判断 MTU 问题时先看地址族,再看隧道封装。

4.4.14 内核收包路径:ip_rcv → tcp_v4_rcv → tcp_rcv_established

这一节回答一个问题:一个 TCP 段从网卡进入内核后,经过哪些函数、在哪个点决定「交给哪条连接」、又在哪里唤醒进程。4.8 会把前半段(网卡、NAPI、GRO)拆开,这里从 IP 层开始。

先给全景表(Linux 6.8):

阶段函数文件做的决定失败计数
IP 入口ip_rcv / ip_rcv_corenet/ipv4/ip_input.c版本、首部长度、校验和InHdrErrors、InCsumErrors
预路由NF_HOOK(NF_INET_PRE_ROUTING)netfilter目的地址是否本机规则计数
路由ip_rcv_finish_core同上查路由、选 dst_inputInNoRoutes
本地交付ip_local_deliver同上是否需要分片重组ReasmFails
协议分用ip_local_deliver_finish → ip_protocol_deliver_rcu同上按 iph->protocol 选 handlerInUnknownProtos
TCP 入口tcp_v4_rcvnet/ipv4/tcp_ipv4.c查 ehash/lhash2、校验、过滤TcpInErrs、ListenDrops
连接处理tcp_v4_do_rcv同上已建立走快路径,监听走握手TCPReqQFullDrop
快路径tcp_rcv_establishednet/ipv4/tcp_input.c纯 ACK / 按序数据 / 乱序TCPHPHits
慢路径tcp_rcv_state_process同上状态迁移(SYN/FIN/RST)TCPAbortOn*
入队tcp_data_queue同上按序入 sk_receive_queue,乱序入 ofo_queueTCPOFOQueue
唤醒tcp_data_ready → sk_data_ready同上 / net/core/sock.c唤醒 sk_wq 上的等待者—

ip_rcv 本身很短,主干是「校验 → 过 netfilter → 进路由」:

/* net/ipv4/ip_input.c,简化 */
int ip_rcv(struct sk_buff *skb, struct net_device *dev, struct packet_type *pt,
           struct net_device *orig_dev)
{
    struct net *net = dev_net(dev);

    skb = ip_rcv_core(skb, net);          /* 版本/ihl/校验和,失败即 kfree_skb */
    if (skb == NULL)
        return NET_RX_DROP;

    return NF_HOOK(NFPROTO_IPV4, NF_INET_PRE_ROUTING, net, NULL, skb, dev, NULL,
                   ip_rcv_finish);
}

ip_rcv_finish 里最关键的一步是 ip_rcv_finish_core() 调用 ip_route_input_noref():如果目的地址是本机,dst_input 指向 ip_local_deliver;如果是转发,指向 ip_forward。同一个 ip_rcv,两种命运在这一行分叉。

ip_local_deliver 检查 ip_is_fragment(iph):有分片就先进 ip_defrag() 重组,重组失败返回 NF_DROP 并记 ReasmFails。重组完成后 ip_local_deliver_finish 把网络首部剥掉,交给 ip_protocol_deliver_rcu。

ip_protocol_deliver_rcu 在 inet_protos[] 数组里按协议号取出 handler:TCP 是 tcp_v4_rcv,UDP 是 udp_rcv,ICMP 是 icmp_rcv。数组是每 netns 一份,所以容器里注册的 raw socket 不会污染宿主机。

tcp_v4_rcv 是 TCP 的总入口,主干可以压成 20 行:

/* net/ipv4/tcp_ipv4.c,简化主干 */
int tcp_v4_rcv(struct sk_buff *skb)
{
    const struct tcphdr *th = (const struct tcphdr *)skb->data;
    struct sock *sk;
    bool refcounted;

    if (skb->pkt_type != PACKET_HOST)              /* 不是发给本机的帧 */
        goto discard_it;
    if (th->doff < sizeof(struct tcphdr) / 4)      /* 首部长度非法 */
        goto bad_packet;
    if (!pskb_may_pull(skb, th->doff * 4))         /* 线性区不够,拉取 */
        goto discard_it;
    if (skb_checksum_init(skb, IPPROTO_TCP, inet_compute_pseudo))
        goto csum_error;

    sk = __inet_lookup_skb(&tcp_hashinfo, skb, __tcp_hdrlen(th),
                           th->source, th->dest, &refcounted);
    if (!sk)
        goto no_tcp_socket;                        /* 回 RST */

process:
    if (sk->sk_state == TCP_TIME_WAIT)             /*  TIME_WAIT 单独处理 */
        goto do_time_wait;
    if (sk->sk_state == TCP_LISTEN) {              /* 监听:走握手 */
        tcp_v4_do_rcv(sk, skb);
        goto put_and_return;
    }
    tcp_v4_do_rcv(sk, skb);                        /* 已建立:快/慢路径 */
put_and_return:
    if (refcounted) sock_put(sk);
    return 0;
}

__inet_lookup_skb 先查 ehash(已建立连接,精确四元组),再查 lhash2(监听 socket,按端口 + 地址通配)。两次查找都失败就回 RST——这就是「连一个没监听的端口,立刻 connection refused」的内核动作。

tcp_v4_do_rcv 只做一次状态判断,然后分流:

/* net/ipv4/tcp_ipv4.c,简化 */
int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)
{
    if (sk->sk_state == TCP_ESTABLISHED) {         /* 绝大多数包走这里 */
        tcp_rcv_established(sk, skb);
        return 0;
    }
    if (sk->sk_state == TCP_LISTEN) {              /* SYN / ACK / RST */
        struct sock *nsk = tcp_v4_hnd_req(sk, skb);
        ...
    }
    return tcp_rcv_state_process(sk, skb);         /* SYN_SENT/FIN_WAIT/... */
}

tcp_rcv_established 是性能关键。它先做「首部预测」(header prediction):如果段没有选项变化、序号正好等于 rcv_nxt、窗口有效、没有 URG/RST/SYN/FIN,就认为是一个「预期内的段」,只做两条最短路径之一:

  • 纯 ACK(len <= tcp_header_len):tcp_ack() 更新 snd_una、释放已确认的 skb、更新 RTT 与 cwnd,然后返回。
  • 按序数据:tcp_queue_rcv() 把 skb 挂到 sk_receive_queue,更新 rcv_nxt,再调 tcp_data_ready() 唤醒进程。

任何条件不满足(乱序、带 FIN、带选项、序号不对)就落到 tcp_rcv_established 的慢路径:tcp_ack() → tcp_data_queue() → 必要时 tcp_ofo_queue()。快路径的意义不是「更快地做同一件事」,而是「避免进入完整的状态机」:少了标志位判断、选项解析和锁竞争。

tcp_data_ready 最终调到 sk->sk_data_ready,对普通 socket 就是 sock_def_readable:它取出 sk->sk_wq,调用 wake_up_interruptible_sync_poll(&wq->wait, EPOLLIN|EPOLLPRI|EPOLLRDNORM|EPOLLRDBAND)。

这一行唤醒就是 4.10 里 epoll 回调的触发点:sk_wq 上挂着阻塞的 read() 等待项和 epoll 的 ep_poll_callback,一次唤醒把它们都叫醒。收包路径和 epoll 的耦合只有这一个函数,理解了它,4.10 的 ep_poll_callback 就不神秘了。

TIME_WAIT 的处理是独立分支(tcp_timewait_state_process):它不查 tcp_sock,只看 inet_timewait_sock,能处理重复 FIN、新 SYN(可能重用连接)和非法段。TIME_WAIT 连接不占 struct tcp_sock 内存,这是它能堆到几万条还不崩的原因。

丢包点分布在整条路径上,每个点都有原因码(enum skb_drop_reason):

原因码触发点典型现象
SKB_DROP_REASON_NO_SOCKETtcp_v4_rcv 查表失败端口没监听,回 RST
SKB_DROP_REASON_TCP_CSUM校验和失败网卡/链路错误,TcpInCsumErrors
SKB_DROP_REASON_TCP_FILTERtcp_filter()(BPF cgroup)被 eBPF 主动丢
SKB_DROP_REASON_TCP_INVALID_SEQUENCE序号不在窗口内收到旧包/攻击
SKB_DROP_REASON_SOCKET_FILTERsk_filter()(经典 BPF)tcpdump 之外的过滤器
SKB_DROP_REASON_TCP_ABORT_ON_MEMORY内存不足TCPAbortOnMemory 增长

排查「包到底在哪被丢」时,drop_monitor 或 bpftrace kprobe:kfree_skb_reason 能直接打印原因码,比逐个计数器猜快得多:

sudo bpftrace -e 'kprobe:kfree_skb_reason { @[args->reason] = count(); } interval:s:5 { print(@); clear(@); }'

这段脚本 5 秒打一次「按丢弃原因分类的计数」。如果 NO_SOCKET 占绝大多数,问题在服务没监听;如果 TCP_CSUM 增长,往网卡/链路查;如果 TCP_FILTER 增长,查 cgroup BPF 程序。

读收包计数的顺序
  1. nstat -az | grep -E '^(Tcp|Ip|IpExt)' 拿全量计数,先看增量而不是绝对值。
  2. TcpInErrs / TcpInCsumErrors 增长 → 包坏了,查链路或 offload。
  3. ListenDrops / TCPReqQFullDrop 增长 → 队列满,查 somaxconn、tcp_max_syn_backlog 和 accept 速度。
  4. TCPHPHits 占比低(低于 80%)→ 快路径命中差,常见于大量小包带选项、或乱序严重。
  5. TCPOFOQueue 增长 → 乱序,查多路径、bonding、GRO 与中间盒。
  6. TCPAbortOnMemory 增长 → 协议栈内存压力,查 tcp_mem 与 cgroup 内存限制。
路径层面的四个误解
  1. 「tcp_v4_rcv 里查两次表」:__inet_lookup_skb 内部先 ehash 后 lhash2,但对已建立连接只查一次 ehash,监听 socket 才会落到 lhash2。不要以为每个包都遍历监听表。
  2. 「快路径等于零拷贝」:快路径只是少走状态机,skb 仍然从环形缓冲区一路传到 sk_receive_queue,数据仍在页里,真正的零拷贝是 MSG_ZEROCOPY/sendfile 那一层(4.9、4.12)。
  3. 「ip_rcv 在进程上下文」:正常收包在软中断(NET_RX_SOFTIRQ)里,只有 RPS/backlog 或 ksoftirqd 才换上下文;在 ip_rcv 里睡眠会直接崩。
  4. 「sk_data_ready 只唤醒一个进程」:它唤醒 sk_wq 上所有等待者(除非用 exclusive wait),多进程阻塞在同一个 socket 上会出现惊群(4.10.14)。

4.4.15 UDP 的内核路径:无连接、错误队列与 ICMP

UDP 的收包路径比 TCP 短,但有两个 TCP 没有的分支:找不到 socket 时回 ICMP 端口不可达,以及错误通过错误队列而不是连接状态传递。

阶段函数文件说明
入口udp_rcvnet/ipv4/udp.c直接调 __udp4_lib_rcv
校验__udp4_lib_rcv同上校验和、长度、分片
查找__udp4_lib_lookup同上按端口哈希 + 四元组打分
入队udp_queue_rcv_skb → __udp_queue_rcv_skb同上检查 sk_rcvbuf
丢包sock_queue_rcv_skb_reasonnet/core/sock.c返回 drop reason
唤醒sk_data_ready同上与 TCP 同一个回调

__udp4_lib_rcv 的第一步是校验和。UDP 校验和是可选的(IPv4 下 check == 0 表示不校验),但一旦非 0 就必须正确:

/* net/ipv4/udp.c,简化 */
int __udp4_lib_rcv(struct sk_buff *skb, struct udp_table *udptable, int proto)
{
    struct sock *sk;
    ...
    if (udp_lib_checksum_complete(skb))
        goto csum_error;                    /* 记 UdpInErrors */
    ...
    sk = __udp4_lib_lookup(net, iph->saddr, uh->source, iph->daddr, uh->dest,
                           inet_iif(skb), sdif, udptable, skb);
    if (!sk) {
        /* 没有 socket:回 ICMP 端口不可达,受 icmp_ratelimit 限制 */
        __UDP_INC_STATS(net, UDP_MIB_NOPORTS, proto == IPPROTO_UDPLITE);
        icmp_send(skb, ICMP_DEST_UNREACH, ICMP_PORT_UNREACH, 0);
        goto drop;
    }
    ...
    udp_queue_rcv_skb(sk, skb);
    return 0;
}

__udp4_lib_lookup 不是一次精确哈希,而是「哈希桶 + 线性打分」。因为 UDP 允许 SO_REUSEPORT 多个 socket 绑同一端口,也允许绑 0.0.0.0 的通配 socket 与绑具体地址的 socket 共存:

候选匹配度选择规则
四元组完全匹配(已 connect)最高直接命中
本地地址 + 端口匹配高优先于通配
通配地址 + 端口匹配低同分时按 SO_REUSEPORT 组哈希
端口都不匹配无回 ICMP 端口不可达

入队时要过 sk_rcvbuf 这一关。UDP 的缓冲记账按 skb 的 truesize,不是按载荷长度:一个 100 字节的 UDP 包,算上 sk_buff、页片段、对齐,实际占用可能是 768 字节甚至更多。所以 SO_RCVBUF=1 MB 并不等于「能排 1 万个 100 字节的包」:

/* net/ipv4/udp.c,简化 */
static int __udp_queue_rcv_skb(struct sock *sk, struct sk_buff *skb)
{
    int rc = sock_queue_rcv_skb_reason(sk, skb, &reason);
    if (unlikely(rc)) {
        if (rc == -ENOMEM)  __UDP_INC_STATS(..., UDP_MIB_RCVBUFERRORS, ...);
        ...
        kfree_skb_reason(skb, reason);
    }
    return 0;
}

UdpRcvbufErrors 增长就是「应用读得太慢,接收缓冲满」。此时内核静默丢包,不会通知对端——UDP 没有重传,也没有窗口。

ICMP 错误怎么传回应用。当对端回「端口不可达」时,icmp_rcv 找到内层 UDP 首部,调 ip_icmp_error → udp_err:

  • socket 未连接(connect 没调过):内核无法确定这个 ICMP 属于哪条「连接」,直接丢掉。
  • socket 已连接且没开 IP_RECVERR:错误写进 sk->sk_err,下一次 recv/send 返回 ECONNREFUSED。这就是「UDP 是假连接,但 connect 之后能收到错误」的原因。
  • socket 已连接且开了 IP_RECVERR:错误进 sk_error_queue,用 recvmsg(MSG_ERRQUEUE) 读,能拿到 sock_extended_err(含 ee_errno、ee_origin、ee_type、ee_code、ee_info)。
/* 开启错误队列并读一条 ICMP 错误(Linux) */
int on = 1;
setsockopt(fd, IPPROTO_IP, IP_RECVERR, &on, sizeof on);
/* ... sendto 之后 ... */
struct msghdr msg = { .msg_control = cbuf, .msg_controllen = sizeof cbuf };
ssize_t n = recvmsg(fd, &msg, MSG_ERRQUEUE);
for (struct cmsghdr *c = CMSG_FIRSTHDR(&msg); c; c = CMSG_NXTHDR(&msg, c)) {
    if (c->cmsg_level == IPPROTO_IP && c->cmsg_type == IP_RECVERR) {
        struct sock_extended_err *ee = (struct sock_extended_err *)CMSG_DATA(c);
        fprintf(stderr, "errno=%u origin=%u type=%u code=%u\n",
                ee->ee_errno, ee->ee_origin, ee->ee_type, ee->ee_code);
    }
}

ee_origin == SO_EE_ORIGIN_ICMP 表示来自网络,SO_EE_ORIGIN_LOCAL 表示本机生成(例如 EMSGSIZE)。大批量 UDP 服务必须处理错误队列,否则 ICMP 错误会不断把 sk_err 置位,表现为「偶发 ECONNREFUSED」或「send 成功但数据没到」。

UDP 相关的计数器集中在 /proc/net/snmp 的 Udp: 行:

计数器含义增长说明
InDatagrams成功交付的报文正常
NoPorts无 socket 的报文有人打错端口,或服务没起
InErrors校验/长度错误链路问题或伪造包
RcvbufErrors接收缓冲满应用读得慢
SndbufErrors发送缓冲满应用发得太快
IgnoredMulti非组播 socket 收到组播通常是配置问题
UDP 也能「面向连接」

connect() 一个 UDP socket 并不发任何包,它只做三件事:绑定四元组、检查路由是否可达、允许内核把 ICMP 错误映射到这个 socket。此后可以用 send()/recv() 而不是 sendto()/recvfrom(),内核还允许 write()/read()。代价是一个 socket 只能对一个对端,收益是查找更快、错误可见。QUIC 实现普遍对每个连接用一个 connect 过的 UDP socket,就是为了拿到错误和 SO_REUSEPORT 的分流。

4.4.16 程序一:读 TCP_INFO,把一条连接的状态打出来

这段程序连接指定主机端口,然后连续两次调用 getsockopt(TCP_INFO):第一次在刚连上时,第二次在发了一个字节之后。它验证三件事:tcp_info 的结构随内核增长、cwnd 的单位是 MSS、rtt 是微秒而不是毫秒。

/* tcpinfo.c:读取并解释 struct tcp_info(Linux);非 Linux 平台只做连接测试 */
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netdb.h>
#include <stdio.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 int connect_tcp(const char *host, const char *port, char *peer, size_t peerlen)
{
    struct addrinfo hints = { .ai_family = AF_UNSPEC, .ai_socktype = SOCK_STREAM };
    struct addrinfo *res = NULL;
    int fd = -1, rc = getaddrinfo(host, port, &hints, &res);

    if (rc != 0) {
        fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(rc));
        return -1;
    }
    for (struct addrinfo *ai = res; ai != NULL; ai = ai->ai_next) {
        fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
        if (fd < 0)
            continue;
        if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) {
            if (getnameinfo(ai->ai_addr, ai->ai_addrlen, peer, (socklen_t)peerlen,
                            NULL, 0, NI_NUMERICHOST | NI_NUMERICSERV) != 0)
                snprintf(peer, peerlen, "?");
            fcntl(fd, F_SETFD, FD_CLOEXEC);
            break;
        }
        close(fd);
        fd = -1;
    }
    freeaddrinfo(res);
    return fd;
}

static void show_info(int fd, const char *tag)
{
#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) {
        perror("getsockopt(TCP_INFO)");
        return;
    }
    printf("[%s] state=%u ca_state=%u optlen=%u/%zu\n", tag,
           ti.tcpi_state, ti.tcpi_ca_state, (unsigned)len, sizeof ti);
    printf("  mss=%u rto=%uus ato=%uus rtt=%uus rttvar=%uus\n",
           ti.tcpi_snd_mss, ti.tcpi_rto, ti.tcpi_ato, ti.tcpi_rtt, ti.tcpi_rttvar);
    printf("  cwnd=%u ssthresh=%u snd_wnd=%u rcv_wnd=%u\n",
           ti.tcpi_snd_cwnd, ti.tcpi_snd_ssthresh, ti.tcpi_snd_wnd, ti.tcpi_rcv_wnd);
    printf("  unacked=%u retrans=%u total_retrans=%u\n",
           ti.tcpi_unacked, ti.tcpi_retrans, ti.tcpi_total_retrans);
    printf("  bytes_acked=%llu segs_out=%u delivery_rate=%llu B/s app_limited=%u\n",
           (unsigned long long)ti.tcpi_bytes_acked, ti.tcpi_segs_out,
           (unsigned long long)ti.tcpi_delivery_rate,
           ti.tcpi_delivery_rate_app_limited);
#else
    (void)fd;
    printf("[%s] TCP_INFO 仅 Linux 提供;本机不是 Linux,跳过。\n", tag);
#endif
}

int main(int argc, char **argv)
{
    char peer[NI_MAXHOST];
    struct timespec ts = { .tv_sec = 0, .tv_nsec = 50 * 1000 * 1000 };

    if (argc != 3) {
        fprintf(stderr, "用法: %s <host> <port>\n", argv[0]);
        return 2;
    }
    int fd = connect_tcp(argv[1], argv[2], peer, sizeof peer);
    if (fd < 0) {
        perror("connect");
        return 1;
    }
    printf("connected to %s\n", peer);
    show_info(fd, "after-connect");

    if (write(fd, "x", 1) != 1)
        perror("write");
    nanosleep(&ts, NULL);
    show_info(fd, "after-write");

    close(fd);
    return 0;
}

编译与运行:

cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 tcpinfo.c -o tcpinfo
# 本机起一个服务端,再连它
python3 -m http.server 18080 --bind 127.0.0.1 &
./tcpinfo 127.0.0.1 18080

预期输出(Linux 6.8,数值随内核与缓冲区变化):

connected to 127.0.0.1:18080
[after-connect] state=1 ca_state=0 optlen=232/232
  mss=65483 rto=204000us ato=40000us rtt=52us rttvar=26us
  cwnd=10 ssthresh=2147483647 snd_wnd=65483 rcv_wnd=65339
  bytes_acked=1 segs_out=2 delivery_rate=0 B/s app_limited=0

四个关键读法:

  1. state=1 是 TCP_ESTABLISHED(include/net/tcp_states.h),不是「1 号状态机」。
  2. cwnd=10 是 10 个 MSS,约 640 KB(回环 MSS 大);在以太网上是 14.6 KB。
  3. ssthresh=2147483647 是 TCP_INFINITE_SSTHRESH,表示还没经历过拥塞,慢启动没有上限。
  4. optlen=232 说明内核填满了整个结构;如果换成 4.9 内核,同一个程序会得到更小的值——getsockopt 不会失败,只是后面的字段保持调用者缓冲区里的旧值。这是读 tcp_info 必须清零并检查 optlen 的原因。

非 Linux 平台(macOS/FreeBSD)走 #else 分支,只验证连接成功。同一个程序能编译、能跑,但拿不到 TCP 内部状态——这也是 ss 只存在于 Linux 的原因。

4.4.17 程序二:UDP 的假连接与 ICMP 错误队列

这段程序向一个没人监听的 UDP 端口发数据,然后分别用「不连接」「连接」「连接 + IP_RECVERR」三种方式观察错误。它验证一个反直觉的事实:UDP 的 connect() 不发包,但 connect() 之后内核对 ICMP 错误的处理完全不同。

/* udp_errq.c:观察 UDP 的 ICMP 端口不可达如何返回给应用 */
#define _GNU_SOURCE
#include <arpa/inet.h>
#include <errno.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <time.h>
#include <unistd.h>

#ifdef __linux__
#include <linux/errqueue.h>
#endif

static void msleep(long ms)
{
    struct timespec ts = { .tv_sec = ms / 1000, .tv_nsec = (ms % 1000) * 1000000L };
    nanosleep(&ts, NULL);
}

static int make_udp(const char *ip, const char *port, int do_connect)
{
    struct sockaddr_in a = { .sin_family = AF_INET, .sin_port = htons((unsigned short)atoi(port)) };
    int fd = socket(AF_INET, SOCK_DGRAM, 0);

    if (fd < 0) {
        perror("socket");
        return -1;
    }
    if (inet_pton(AF_INET, ip, &a.sin_addr) != 1) {
        fprintf(stderr, "bad address: %s\n", ip);
        close(fd);
        return -1;
    }
    if (do_connect && connect(fd, (struct sockaddr *)&a, sizeof a) < 0) {
        perror("connect");
        close(fd);
        return -1;
    }
    return fd;
}

int main(void)
{
    const char *ip = "127.0.0.1", *port = "9";   /* discard 端口,通常没人监听 */

    /* 1. 不 connect:sendto 成功,ICMP 错误无人认领 */
    int fd = make_udp(ip, port, 0);
    if (fd < 0)
        return 1;
    struct sockaddr_in dst = { .sin_family = AF_INET, .sin_port = htons(9) };
    inet_pton(AF_INET, ip, &dst.sin_addr);
    ssize_t n = sendto(fd, "hello", 5, 0, (struct sockaddr *)&dst, sizeof dst);
    printf("unconnected sendto -> %zd (%s)\n", n, n < 0 ? strerror(errno) : "ok");
    msleep(20);
    char buf[64];
    n = recv(fd, buf, sizeof buf, MSG_DONTWAIT);
    printf("unconnected recv -> %zd errno=%s\n", n, n < 0 ? strerror(errno) : "-");
    close(fd);

    /* 2. connect:第二次 send 很可能返回 ECONNREFUSED */
    fd = make_udp(ip, port, 1);
    if (fd < 0)
        return 1;
    n = send(fd, "hello", 5, 0);
    printf("connected send #1 -> %zd errno=%s\n", n, n < 0 ? strerror(errno) : "-");
    msleep(20);
    n = send(fd, "hello", 5, 0);
    printf("connected send #2 -> %zd errno=%s\n", n, n < 0 ? strerror(errno) : "-");

#ifdef __linux__
    /* 3. IP_RECVERR:错误进错误队列,而不是覆盖 sk_err */
    int on = 1;
    if (setsockopt(fd, IPPROTO_IP, IP_RECVERR, &on, sizeof on) < 0)
        perror("setsockopt(IP_RECVERR)");
    (void)send(fd, "hello", 5, 0);
    msleep(20);
    for (int i = 0; i < 10; i++) {
        char cbuf[256], data[64];
        struct iovec iov = { .iov_base = data, .iov_len = sizeof data };
        struct msghdr msg = {
            .msg_iov = &iov, .msg_iovlen = 1,
            .msg_control = cbuf, .msg_controllen = sizeof cbuf,
        };
        ssize_t r = recvmsg(fd, &msg, MSG_ERRQUEUE);
        if (r < 0) {
            if (errno == EAGAIN) { msleep(10); continue; }
            printf("recvmsg(MSG_ERRQUEUE) -> %s\n", strerror(errno));
            break;
        }
        for (struct cmsghdr *c = CMSG_FIRSTHDR(&msg); c != NULL; c = CMSG_NXTHDR(&msg, c)) {
            if (c->cmsg_level == IPPROTO_IP && c->cmsg_type == IP_RECVERR) {
                struct sock_extended_err *ee = (struct sock_extended_err *)CMSG_DATA(c);
                printf("errqueue: errno=%u origin=%u type=%u code=%u info=%u\n",
                       ee->ee_errno, ee->ee_origin, ee->ee_type, ee->ee_code, ee->ee_info);
            }
        }
        break;
    }
#else
    printf("IP_RECVERR/MSG_ERRQUEUE 仅 Linux 提供;本机跳过第 3 步。\n");
#endif
    close(fd);
    return 0;
}

编译运行:

cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 udp_errq.c -o udp_errq
./udp_errq

Linux 上的典型输出:

unconnected sendto -> 5 (ok)
unconnected recv -> -1 errno=Resource temporarily unavailable
connected send #1 -> 5 errno=-
connected send #2 -> -1 errno=Connection refused
errqueue: errno=111 origin=2 type=3 code=3 info=0

三个结论:

  • 未连接 socket 的 sendto 永远「成功」,ICMP 错误被内核丢弃,应用毫无感知。
  • connect 后,ICMP 端口不可达把 sk_err 置为 ECONNREFUSED,下一次 send/recv 才返回错误(第一次 send 仍然成功)。这是 UDP 服务「偶发 ECONNREFUSED」的根源:对端进程重启,端口暂时没人听。
  • 开了 IP_RECVERR 后,错误进队列,ee_origin=2(SO_EE_ORIGIN_ICMP)、type=3(ICMP_DEST_UNREACH)、code=3(ICMP_PORT_UNREACH)。有了 ee_info 还能拿到触发错误的包的信息(如 MTU)。

4.4.18 实验:用 tcpdump、ss 与 bpftrace 读一条连接

实验一验证「首部字段与协商结果」,实验二验证「内核走的是快路径还是慢路径」。两个实验都只需要本机回环,不需要外网。

实验一:抓三次握手,逐项对上选项。

# 终端 1:起服务
python3 -m http.server 18080 --bind 127.0.0.1
# 终端 2:抓 6 个包,-S 显示绝对序号,-vvv 展开选项
sudo tcpdump -i lo -nn -S -vvv -c 6 'tcp port 18080'
# 终端 3:发起连接
curl -s -o /dev/null http://127.0.0.1:18080/
IP 127.0.0.1.51342 > 127.0.0.1.18080: Flags [S], seq 3912345678, win 65495,
   options [mss 65495,sackOK,TS val 1234567 ecr 0,nop,wscale 7], length 0
IP 127.0.0.1.18080 > 127.0.0.1.51342: Flags [S.], seq 1122334455, ack 3912345679, win 65483,
   options [mss 65495,sackOK,TS val 7654321 ecr 1234567,nop,wscale 7], length 0
IP 127.0.0.1.51342 > 127.0.0.1.18080: Flags [.], ack 1, win 128, length 0

读法:

  • -S 让 seq/ack 显示绝对值,否则 tcpdump 显示相对值(第一个数据字节为 1),无法和 ss 对照。
  • SYN 的 win 是未缩放的 65495;第三个包 win 128 已经缩放(128 × 2⁷ = 16384),因为握手完成后缩放生效。同一个连接窗口从 65495「变成」128,不是窗口变小,是显示口径变了。
  • mss 65495 说明回环 MTU 65536;换到以太网会是 1460。
  • TS val 是毫秒时钟,ecr 回显对端;nop,wscale 7 里的 NOP 是对齐填充。

实验二:看快路径命中与收包耗时。

# 连接建立后,读出窗口、RTT、重传与快路径统计
ss -tinm 'sport = :18080'
nstat -az | grep -E 'TcpExtTCPHPHits|TcpExtTCPPureAcks|TcpRetransSegs|TcpInErrs'
# 用 bpftrace 统计快/慢路径调用次数与 tcp_v4_rcv 的耗时分布
sudo bpftrace -e '
kprobe:tcp_rcv_established { @fast[comm] = count(); }
kprobe:tcp_rcv_state_process { @slow[comm] = count(); }
kprobe:tcp_v4_rcv { @start[tid] = nsecs; }
kretprobe:tcp_v4_rcv /@start[tid]/ { @ns = hist(nsecs - @start[tid]); delete(@start[tid]); }
interval:s:5 { print(@fast); print(@slow); print(@ns); clear(@fast); clear(@slow); clear(@ns); exit(); }'

ss -tinm 输出里要盯住这几个字段:

字段本次实验的预期说明
wscale:7,7两个方向都缩放 7与抓包里的 wscale 7 对应
rtt:0.05/0.02回环亚毫秒单位是毫秒,rtt 用 srtt_us >> 3 换算
cwnd:10初始 10 个 MSS慢启动起点,不是 10 字节
app_limited小文件下载时出现说明瓶颈在应用/文件,不在网络
rcv_space约 6 MB 上限受 net.ipv4.tcp_rmem 第三个数约束

bpftrace 的输出应该看到 @fast 的计数远大于 @slow:一个正常传输的连接,除了握手和挥手,几乎全部段都走 tcp_rcv_established。如果 @slow 比例很高,常见原因是大量短连接(每次握手都走 tcp_rcv_state_process)、频繁 RST、或大量带 FIN 的段。

@ns 的直方图能看出单次 tcp_v4_rcv 的耗时量级:回环通常在几百纳秒到几微秒;如果出现几十微秒的长尾,查 netfilter 规则数量、cgroup BPF 程序和 sk_filter。

4.4.20 本章出口检查

检查项达标线没达标去哪
用五元组解释连接唯一性能说明为什么服务端能接几万连接4.4.1
列出 UDP 首部四个字段知道长度字段包含首部4.4.2
逐字段读 TCP 首部能算首部长度与选项长度4.4.3
解释「TCP 没有报文边界」能说清粘包是应用假设错误4.4.4
区分流量控制与拥塞控制知道 rwnd 与 cwnd 的来源4.4.6、4.4.7
用 ss -tin 读出四个关键指标会看 cwnd/rtt/retrans/app_limited4.4.9
说清四个定时器的量级知道 RTO 最小值与 2MSL 是多少4.4.10
解释抓包里的校验和错误能区分 offload 与真实损坏4.4.11
解析 SYN 选项并算出 MSS能从十六进制拆出 MSS/wscale/TS4.4.13
说出收包路径的三个分叉点路由、协议号、ehash/lhash24.4.14
解释 UDP 的 ICMP 错误如何到应用区分未连接/连接/IP_RECVERR4.4.15
读 tcp_info 并换算单位知道 cwnd 是 MSS、rtt 是微秒4.4.16
即时自测 · 4.4

Q1 一台服务器监听 0.0.0.0:443,它能同时接受多少条 TCP 连接?限制因素有哪些?

Q2 UDP 首部的「长度」字段最小值是多少?如果把它设为 0 会发生什么?

Q3 一个 TCP 首部的 data offset 字段是 8,这个首部多少字节?选项占多少字节?

Q4 为什么 TCP 的初始序号要随机化?如果用固定的 0,会有什么风险?

Q5 ss -ti 显示 cwnd:10、snd_wnd:65495、app_limited。瓶颈在哪?该优化什么?

Q6 DNS 用 UDP,但响应超过 512 字节时怎么办?为什么现在很多 DNS 走 TCP?

答案

A1 理论上受五元组空间限制(约 2³² × 2¹⁶),实际受四个因素限制:文件描述符上限(ulimit -n / fs.file-max)、内核内存(每条连接约 2–3 KB 的 struct sock 加缓冲区)、accept 队列(somaxconn)、以及端口/内存的分配速度。4.11 会给出百万连接的具体算法。

A2 最小值是 8(首部本身)。长度字段为 0 在 IPv4 里是非法值(IPv6 允许 0 表示 jumbogram),大多数实现会直接丢弃并可能记入 InErrors。校验和字段为 0 才是合法的「不校验」。

A3 8 × 4 = 32 字节,选项占 32 − 20 = 12 字节。12 字节正好能放一个时间戳选项(10 字节)加 2 字节 NOP 填充。

A4 防止旧连接的延迟包被新连接误收(同一个四元组重用后,旧包可能还在网络里);同时增加攻击者伪造 RST 或注入数据的难度——如果 ISN 可预测,攻击者不需要看到握手就能猜出序号。RFC 6528 专门规定了 ISN 的生成算法。

A5 瓶颈在应用:app_limited 表示「有窗口但没数据可发」,cwnd 小是因为没发满,不是网络限制。该查应用的处理逻辑、上游依赖、线程模型,而不是调 TCP 参数。

A6 超过 512 字节时,传统 DNS 用截断标志(TC=1)让客户端改用 TCP 重查;EDNS0 把 UDP 上限提高到 4096 字节左右。现在很多 DNS 走 TCP 是因为 DoT/DoH 需要 TCP,且大响应(DNSSEC)在 UDP 下容易分片丢失。

母本对应

母本章节本书落点程度
W(谢希仁)第 5 章 运输层(UDP、TCP 首部、可靠传输、流量控制、拥塞控制)4.4.1–4.4.8完整:首部、语义、窗口、拥塞控制四阶段
K(Kerrisk)第 58 章 58.6(传输层:端口、UDP、TCP)4.4.1、4.4.2完整:端口分配与两种协议的编程视角
K(Kerrisk)第 61 章 61.1–61.3、61.12(部分读写、recv/send、TCP vs UDP)4.4.4、4.4.8精选:部分读写的成因与协议选择
C(CSAPP)第 11 章 11.4(套接字接口)4.4.1、4.4.4背景:socket API 与连接的建立
N(深入理解 Linux 网络)第 7 章 7.3(TCP 连接相关内核对象)4.4.1前置:struct sock 的字段来源
W(谢希仁)第 5 章 5.5–5.9(TCP 首部、可靠传输、流量与拥塞控制,PDF p222–270)4.4.12、4.4.13补充:选项编码与缩放算术
K(Kerrisk)第 61 章 61.6.1–61.6.2(TCP 报文格式与序号,PDF p1084–1086)4.4.12、4.4.13完整:字段定义与伪首部
K(Kerrisk)第 61 章 61.7–61.8(netstat 与 tcpdump 观测,PDF p1092–1098)4.4.18精选:观测方法
N(深入理解 Linux 网络)第 2 章 2.2(网卡到协议栈,PDF p27–49)、第 7 章 7.3(TCP 内核对象,PDF p214–222)4.4.14、4.4.15补充:路径与结构体

下一站:TCP 首部里的 SYN、ACK、FIN 三个标志位,组合出了一套连接生命周期。进入 4.5 TCP 连接建立与关闭,看三次握手、两个队列、四次挥手和 TIME_WAIT。