网络栈 入门
客户端跑 curl https://api.example.com/user/42。这个进程和对端之间隔着 5 层。关于它们的 3 个主张,每一个都由紧挨着的那张图证明:每一层的代价都是可以数出来的字节、路径上每一个天花板都是一个数、以及这些失败在设计上就是安静的。
每一层到底加了什么
这 5 层里的每一层,下行时加一个头,上行时剥掉。最终上线的字节,永远不是应用写下的那些字节。
学校教 OSI 的 7 层。生产里按 5 层推理,因为会话层和表示层从来没有落地过:L7 的 HTTP、L4 的 TCP、L3 的 IPv4、L2 的 Ethernet、 L1 的线编码。
我们这个请求是 500 字节的 HTTP。一层一层拖动滑块,看 载荷始终保持原样,而 每一个头被依次加在它前面:
注意没有任何一层改写载荷。TCP 加 20 字节,IPv4 再加 20,Ethernet 前面加 14、后面加 4 字节的帧校验序列 —— 58 字节的封装裹住 500 字节的请求,线上 558 字节。这就是整个栈赖以成立的不变式:每层只拥有自己的头,把上面的一切当成不透明的字节。
58 字节对 500 字节的请求很便宜,对小包就是灾难。沿曲线拖动标记 —— 整张图都是拖拽面 —— 读出封装占比和它包住的 载荷之间的关系:
500 字节时封装占线上的 10.4%。到 时是 98.3% —— 搬 59 个字节只送到 1 个。这就是为什么啰嗦的协议远在链路跑满之前就输给批量协议,也是 Nagle 算法存在的全部理由:它把小写压住,等未确认的 ACK 回来,好让下一次一起走。
上行时同一帧被逐层剥开,每个头真正的活是点名它上面那一层。按播放,看 每一个分用键把剩下的部分交给唯一一个楼上邻居:
盯键,不要盯大小。EtherType 0x0800 说是 IPv4;IP 的协议字节 6 说是 TCP;目的端口 443 说是哪个 struct sock。层从不搜索 —— 它读一个字段然后派发。收一个包是 3 次表查找,不是 3 次扫描,这也是为什么接收路径对打开的连接数是 O(1) 的。
线上不会承载任意大的帧。以太网的载荷上限是 1500 字节,减去 20 的 IP 和 20 的 TCP,一个段最多带 1460。把写入量拖过这条线,看 TCP 怎么切:
一旦写入越过 ,第二个段只带 1 个字节,却要再花 58 字节封装把它送到。因为 TCP 是字节流,应用根本看不到这次切分 —— 所以一个记录只比 MSS 多出 1 字节的协议,包数悄悄翻倍,暴露在丢包下的面也翻倍。
地址变了,天花板也跟着变。IPv6 的头是 40 字节而不是 20,所以同样的 1500 字节帧只能带 1440 字节载荷,不是 1460。保持写入量不变,切换版本:
1,460 字节的写入在 IPv4 下是 1 个段, 在 IPv6 下是 2 个 —— 这是「双栈上线之后什么都没改却变慢了」最常见的原因。隧道也一样,而且会叠加: VLAN 标签 4 字节、GRE 24 字节、IPsec 五十来字节,统统从同一个 1500 里扣。
然后介质本身还要收一笔没有任何头记账的税:7 字节前导码、1 字节帧起始定界符,以及每两帧之间 12 字节的帧间隙。横向拖动帧长, 读出链路真正送出去的量:
在 的最小帧上,10 Gbps 的链路跑 14.88 Mpps,有效吞吐只有 714 Mbps —— 93% 的链路花在封装和间隙上;1,518 字节的最大帧上是 813 Kpps、9.49 Gbps。两个数字都会在 section 04 回来。
一条流挂在哪个 socket 上
一个文件描述符指向一个内核对象,它拥有两个缓冲、TCP 状态机,以及标识这条流的那 4 个数。
socket() 分配一个 struct socket 包住一个 struct sock —— TCP 下是 struct tcp_sock, 把它作为第一个成员内嵌。描述符是普通的 VFS 句柄,这也是 read 能作用在它上面的原因。
这 4 个数是源地址、源端口、目的地址、目的端口。逐个走过到达的段,看这个 tuple 恰好选中一个 socket:
注意整个分用器依赖的不变式:任意两条已建立的连接,至少在 4 个字段中的一个上不同。上面有两个段共享源 IP,有两个段共享源端口,两对都不冲突。没有已建立 socket 认领的 tuple 会落到监听 socket; 谁都不认领的 tuple 换来一个 RST。
这条规则对服务端慷慨,对客户端苛刻。监听 (*, 443) 的服务端能挂上百万条连接,因为客户端一侧在变;而一个客户端连同一个目的地,只有自己的端口在变。把连接数拉上去,看临时端口范围被填满:
Linux 出厂的 net.ipv4.ip_local_port_range 是 32768 60999 —— ,也就是从一个源 IP 到一个 (目的 IP, 目的端口) 最多 28,232 条并发连接。再往上,connect() 返回 EADDRNOTAVAIL。这个是响亮地失败,属于好情况;安静的那种,是同一段范围被关闭后停在 TIME_WAIT 里 60 秒的 socket 吃掉。
端口是一个天花板,窗口是另一个。一条流未确认的字节数不能超过接收方通告的窗口,所以吞吐是窗口 ÷ 往返,不会更多。把往返时延拉大,看默认窗口落在哪:
100 ms 下,1 Gbps 的链路要在途 12.5 MB才填得满,而 200 KB 的窗口只跑出 16.4 Mbps —— 链路的 1.6%,而且链路既不拥塞也不丢包。这就是 Linux 要自动调tcp_rmem 的原因,也是手工设 SO_RCVBUF几乎总是错的原因:它把窗口钉死,并关掉自动调节。
accept 路径上还有两个队列、两个天花板。listen(fd, backlog) 决定第二个,第一个是 tcp_max_syn_backlog。在没人调用 accept() 的情况下把到达数拉上去:
看第 24 个到达之后发生了什么。内核不拒绝连接,应用什么都不记 —— SYN-ACK 只是被重传,最后丢掉, 于是客户端看到超时,服务端看到一个健康的进程。告诉你真相的计数器是 nstat -az TcpExtListenOverflows,深度在 ss -lnt 的 Recv-Q 里。
连接建好之后,同样的压力出现在连接内部。接收缓冲存着已到达但没被读走的字节,内核通告的窗口就是剩下的那部分。把读的一方拖慢:
大约低于到达速度的 40% 时,缓冲填满,通告窗口到达零—— 发送方停下,唯一能让它重新开跑的是读者把字节取走。这就是端到端的背压,也是为什么一个慢消费者表现为生产者侧的延迟,而不是内核里的内存膨胀。
一个握着几万个这种对象的服务端,不会挨个去问。epoll 只把变化了的描述符还给你。把描述符数拉上去,在两个接口之间切换:
select() 每次调用都要拷贝并扫描整个集合,代价是「盯着的描述符数」;epoll_wait() 直接返回就绪列表,代价是「就绪的描述符数」。而且 select() 在 处彻底停住 —— 那是编译期常量,不是可调项。
UDP 是同一个 struct 拿掉状态机:一次 sendto 是一个包,一次 recvfrom 也是一个包,没有窗口可关。它的接收缓冲照样会满,而且它计费的东西和你发的东西不是一回事:
缓冲按 skb->truesize 记账 —— 整块分配,小报文约 768 字节 —— 而不是线上的 100 字节。212,992 字节的 rmem_default 只装277 个报文,不是 2,130 个,其余的被丢掉,而且没有任何信号:没有 RST、没有 ICMP、发送方没有错误。
包是怎么找到路的
离开 socket 之后,一个包还差 3 样东西:出接口、下一跳地址、目的 MAC。路由回答前两个,ARP 回答第三个,路径 MTU 决定结果能有多大。
路由表不是按顺序走一遍的列表。它是最长前缀匹配:每一条前缀覆盖了目的地址的路由都是候选,固定位最多的那条胜出。
走过 8 个目的地址,看每一个被哪些前缀覆盖。地址在你手里;胜出的路由是仍然覆盖它的那根最长的条:
注意默认路由 0.0.0.0/0 覆盖这 8 个里的每一个 —— 它固定 0 个位,所以永远匹配,也永远输给别人。metric 只在等长前缀之间打破平局;一条 /24 哪怕 metric 很糟,照样赢过 metric 完美的 /0, 所以「加一条 metric 很高的路由,这样就不会被用到」通常就是被用到了。
路由给出下一跳,而下一跳不是目的地。逐跳走一遍,比较头里的两个地址 —— IP 那个和MAC 那个:
这就是分层落到实处:L3 是端到端的,L2 是逐跳的。目的 IP 由发送方写一次,之后再不改;目的 MAC 被每一台路由器重写,因为它只指本网段上的下一台设备。 TTL 每跳减一,正是 traceroute 得以存在的原因。
填上那个 MAC 需要一次查询,而答案缓存在邻居表里,有自己的生命周期。对一条持续有流量的表项,把时间往前拖:
到 30 秒 —— base_reachable_time_ms,内核会把实际值在 0.5 到 1.5 倍之间随机化 —— 表项转成 STALE,而因为流量还在,它立刻进入 DELAY。真正值得知道的是,这段时间里包一直照着那个陈旧地址走。重新验证发生在流量旁边,不是挡在流量前面:3 次单播探测、每次间隔 1 秒,之后才是 FAILED。
这套交换里没有任何认证。同网段上任何主机都能替任何地址作答,而 Linux 会相信一条更新它已有表项的、未经请求的应答。走一遍这给同网段上的攻击者带来了什么:
受害者的路由表没被动过,它的 ARP 缓存里是一条语法完美的表项,而现在每一个发往互联网的包都要过一个第三方的手再转出去。 L2 上没有解法;答案在交换机的动态 ARP 检查、802.1X, 或者干脆别把不可信主机放在同一网段上 —— 云上的 VPC 就是靠给每个租户一个独立的虚拟 L2 替你做到这一点。
有了接口、下一跳和 MAC,最后一个问题是帧能有多大。答案是路径上最小的那个 MTU,而路径里包含没人告诉过你的链路。把中间那条隧道往下拖:
一条 吃 24 字节, PPPoE 吃 8,IPsec 五十来字节 —— 而从没听说过它们的发送方,照旧发着置了 DF 的 1500 字节包。转不动的那台路由器把它丢掉, 回一个 ICMP type 3 code 4 带上自己能走的 MTU,发送方据此收紧 MSS。这就是路径 MTU 发现,它离能工作只差一条 ICMP 消息。
那就「为了安全」把 ICMP 拦掉,看同一个响应会怎样。小响应毫发无伤;分别在放行和拦截两种情况下把响应拉大:
这就是每个工程师迟早要查一次的形状:完全正常,挂住。发送方什么都没被告知,于是把同一个过大的段一直重传到连接死掉 —— 握手成功了,头也到了,只有正文掉进了黑洞。放行 ICMP type 3,或者打开 net.ipv4.tcp_mtu_probing, 它改从丢包里推断上限(PLPMTUD,RFC 4821)。
更老的答案是分片而不是收紧,而它有一个算术问题。一个数据报只有在每一个分片都活下来时才算活,所以它的存活率是 (1 − p) 的分片数次方。两个都拉上去:
1% 的链路丢包下,6 个分片的数据报有 5.9% 的时候整个丢掉 ——是那条链路丢包率的 5.9× —— 而 IP 没有部分重传,一个分片丢了就赔上整个数据报。这就是 TCP 宁可置 DF 并收紧的原因。
栈的最底下
IP 之下,是决定一台机器封顶在每秒十万个包还是一千万个的那部分。
网卡和内核在主存里的一对环形缓冲上会合。内核投放指向空的 2 KB 缓冲的描述符;网卡把到达的帧 DMA 进下一个,推进尾指针。没有人拷贝,也没有人等。
只要内核收回描述符的速度快过网卡填入的速度,这就一直成立。把到达速率调到排空速率之上,看这个环:
注意这个环真正的用途。512 个描述符的环能吸收 300 Kpps 的超载 1.71 ms, 之后把所有超出排空速率的包丢掉 —— ethtool -G eth0 rx 4096 把 1.71 ms 变成 13.7 ms。环的大小买的是抗突发,永远不是吞吐;而 ethtool -S 里往上走的 rx_missed_errors说明问题在 CPU,不在卡。
排空是 CPU 花在的地方。2003 年之前每一帧都触发一次硬件中断; section 01 已经算过,满帧下 10 Gbps 的链路是 。在这个速率上切换两种通知方式:
算上进入、退出和它留下的冷缓存,一次中断大约要 1.5 µs,所以 813 Kpps 就是1.22 个核纯做中断 —— 一个字节都还没到 socket,机器已经跑满了。NAPI 的答案是:第一个包到来时关掉这条队列的中断,然后每次调用轮询最多64 个帧的预算,于是负载下中断代价按这个倍数下降,而只要包还在持续到达,这个循环根本不会把中断重新打开。
一个核在轮询,那也还是一个核。现代网卡有多条接收队列, 对每个包的 4-tuple 做哈希,然后中断拥有那条队列的 CPU。把流数拉上去,再切换流量形态:
因为哈希算的是 4-tuple,一条流的每个包都落在同一条队列、同一个核、同一份热缓存上 —— 不乱序,也不用跨核加锁。这同时意味着一条大象流切不开:单条 8 Gbps 的传输会把一个核钉在 100% %si 上,另外七个闲着,而 /proc/interrupts 表现为一列怎么也平不下来。
从线缆到应用之间的每一段都有自己的队列、自己的上限、自己的计数器,回程上还多一段 —— 而且没有一个是光靠 netstat -s 能看全的。顺着这条链往下走:
要记的是这个梯子,不是具体命令。丢在环上说明 CPU 跟不上;丢在每 CPU backlog 上说明某一个核跟不上 (net.core.netdev_max_backlog);丢在 socket 缓冲上说明应用跟不上;丢在 accept 队列上说明应用根本没在调用 accept。每一级的修法都不一样,而丢在错误一级上的包, 对你正在看的那个指标来说是隐形的。
对于本来就打算丢掉的流量,最便宜的丢法是在这一切存在之前就丢。 XDP 在驱动内部、在 sk_buff 被分配之前,对原始 DMA 缓冲跑一段 eBPF。在 下比较两个丢弃点:
在 iptables 里丢,每个包要几微秒,因为它已经被分了 socket buffer、走过一条链;XDP_DROP 只要 50 ns 左右,因为那些都还没发生。10 Mpps 下这就是 25 个核对 0.5 个核,也正是 Cloudflare 的 L3 清洗和 Meta 的 Katran 负载均衡器住在 XDP 而不是 netfilter 里的全部理由。
再往前,出口就整个离开内核了。设一个目标速率,读出每条数据路径撑住它需要几个核 —— 内核栈对绕过路径:
1 Mpps 下内核栈要 1.0 个核,DPDK 要 0.07 个;到 14.88 Mpps —— section 01 里 64 字节的线速 —— DPDK 要 1.0 个绑定的核,内核栈要 14.9 个。代价是 TCP、重传、拥塞控制和 ARP 现在都归你写了。
速查
两个值得脱稿答出来的问题,以及五个值得在评审里抓住的红旗。
一次 HTTPS 请求的时间到底花在哪?
几乎没有花在传输上。一次冷请求要付:一个往返做解析、一个往返握手、一个往返做 TLS 1.3,再一个往返把问题问出去、把答案拿回来。拖动往返时延,读出结果里服务端自己那一份:
在 下,答案在请求之后 225 ms 才到,其中只有 25 ms 是服务端。复用连接砍掉 4 个往返里的 3 个,落在 75 ms —— 这就是为什么在这个 RTT 上, keep-alive、TLS 会话恢复和 HTTP/2 多路复用的收益,远远高过任何服务端优化。
为什么繁忙的客户端总是先把端口用光,而不是别的什么?
因为一条关闭的连接会把端口按住整个 TIME_WAIT —— Linux 上是写死的 60 秒 —— 所以占用的端口数是请求速率乘以这个时间,不是并发数。把速率拉到这个范围能给的 28,232 上去:
不复用的话,这段范围在 时就见底了。用 keep-alive,同样的速率只需要十二条连接。
- 手工设
SO_RCVBUF。 把窗口钉死,关掉自动调节。 - 对单个后端开几万条连接。 每个源 IP 只有 28,232 个端口。
- 边界上把 ICMP 全拦掉。 PMTUD 静默死亡,大响应永远挂住。
- UDP 里「读到凑够 N 字节」。 一次
recvfrom= 一个数据报。 - 热路径上不带过滤的
tcpdump。 带上 BPF 表达式,让内核先过滤。