TCP 深入 入门

一条到 api.example.com:443 的连接,就是两台机器对数字达成一致。三个论断,每一个都能上手推:握手要三个包,不是两个;发送方同时听两个窗口的,一个通告出来、一个猜出来;而生产上每一个昂贵的 TCP 故障都是无声的。

01

三个包,以及为什么不是两个

curl 刚按下回车。请求的第一个字节出门之前,内核得先和一台素未谋面的机器就两个数字达成一致。

每条连接都以 SYN、SYN-ACK、ACK 开场。是三个不是两个,这不是礼节:两端各自挑一个随机初始序列号,而一个对方从未确认过的数字,两端都没法信。

一开始两端都不知道对方怎么编号,每个包恰好把一个事实搬过去。按播放,或者抓住滑杆,读一读两端各知道了什么:

还没发包

注意两个包之后画面已经不对称了。客户端两个数字都知道;服务端发出了 y,却完全不知道有没有人收到。第三个包确认的不是连接本身 —— 它确认 y, 而这是唯一能让服务端自己的编号变得可信的东西。

坚持要这个包的代价,是整整一个往返里什么有用的东西都没过去。把握手砍回两个包,再把一个陈旧的重复 SYN推进去:

三包握手,已发 0 / 3 个

看右边服务端的状态。两个包的版本上,它在一条谁都没开过的连接上进了 ESTABLISHED,而且会一直占着一个 socket 和一块缓冲,直到什么东西超时。三个包的版本上,客户端回的是 RST: 从没发过 x 的客户端,没法确认 y。第三个包正是让陈旧副本被拒绝而不是被当真的那一个。

一个往返是价钱,而价钱由链路说了算。拖动往返时间,再切换加密层压在握手上的东西:

同区域:建连花掉 12 ms

加密层要自己的往返,所以代价是乘上去而不是加上去的。在 上,纯 TCP 是 140 ms、TLS 1.3 是 280 ms、TLS 1.2 是 420 ms —— 全在请求写出去之前。这就是连接池的全部理由,服务端怎么调都碰不到。

第三个包一落地,内核得把这条连接放到某个地方,而队列有两个不是一个。把到达速率抬到超过应用接得走的量:

2 个 socket 在等 accept()

两个队列的失败方式不一样,而且只有一个失败得响。SYN 队列满了有防御 ——tcp_syncookies 干脆不分配状态,把服务端的 ISN 编成 4 元组的哈希,于是 SYN flood 一点内存都不花。accept 队列满了什么都没有:内核丢掉第三个 ACK、不记任何日志,而客户端还以为自己连上了。

于是失败到客户端手里变成了等待而不是报错,而这种等待的形状你只需要见一次。把重传一步步走出来:

已发出第一个 SYN

看刻度的间距在翻倍。tcp_syn_retries = 6 把它们摆在、3 s、7 s、15 s、31 s、63 s,connect() 在 127 s 放弃。连接延迟直方图在 1 秒和 3 秒上有尖峰,那不是服务器慢;那是一个被丢掉的 SYN,或者 accept 队列满了。

02

给每个字节编号

IP 会丢包、会重复、会乱序,而且什么都不说。 TCP 加上去的一切,都长自一个 32 位计数器。

每个字节都有序列号 —— 这个字段数的是字节不是包。接收方确认的是一个位置:它回的数字是它期待的下一个字节,不是收到的最后一个。

这一个选择决定了后面的一切。沿着带子拖动确认点,看已经确认的部分和还悬着的部分怎么分开:

ack = 1. 沿着带子拖动确认点;方向键一次移动一个段,Home 回到起点
8 个段里确认了 0 个 —— ack = 1

注意 ACK 号永远只能说一件事:某个前缀到了。基础头部里没有任何东西能说「1461–2920 和 5841–7300 我有,中间那段没有」, 所以累积 ACK 会卡在第一个洞上不动 —— 也就是说,丢一个段会让之后每一个 ACK 都长得一模一样。

一模一样的 ACK 本身就是信息,TCP 会把它花掉。把丢包和恢复一步步走出来,看发送方那侧的重复怎么攒起来:

第 1 / 8 步

触发条件是第三个重复,于是发送方根本不等时钟 —— 它凭接收方自己给的证据就重传。3 不是随手挑的:一两个重复正是普通乱序的产物,阈值再低一点,每次两个包送反顺序你就重发一遍健康数据。

不过发送方能重发什么,取决于接收方被允许说什么。沿着带子拖动那个洞,再把选择性确认选项关掉:

第 6 个段丢了。沿着带子拖动那个洞;方向键一次移动一个段,Home 放回中间
为补第 6 个段重传了 1.4 KB

没有 SACK,发送方只知道洞在哪,别的什么都不知道,于是它把洞和洞后面的一切都重发:丢在第 6 个段上时,那是重新压回线上的 10 KB, 其中 8.6 KB 早就到了;对上 1.4 KB —— 只有那个洞本身。 SACK(RFC 2018)只在 SYN 里协商一次,就把这次修补变成恰好一个段。

接收方压根没机会报告的洞是另一个问题,那正是计时器存在的理由。设定平滑往返时间和它的方差:

RTO = 200 ms —— 被地板托住

看那道地板。RFC 6298 算的是RTO = SRTT + max(G, 4·RTTVAR),但 Linux 把结果夹在 200 ms 以上,所以在往返 0.2 ms 的同机架路径上,超时是 RTT 的一千倍。这是故意的:提前开火的重传只会给已经出问题的网络加压。

而超时真的开始连发时,它们不是等间隔的。把次数往上走,看已经耗掉的跨度:

1 次超时后已过 0.2 s

注意头几次超时在总量里占的比例有多小。第十五次重传 ——tcp_retries2 的默认值 —— 在 805 s 发出,而内核会再等一个 TCP_RTO_MAX 才杀掉连接:net/ipv4/tcp_timer.c 里的 tcp_model_timeout() 算的是((2<<9)−1)·200 ms + 6·120 s =, 所以一条对端已经消失的连接是 15 分钟才报错,不是 13。ip-sysctl 给的区间是 13 到 30 分钟,因为真实 RTO 很少落在地板上。没有自己截止时间的服务,会把请求和线程一起攥满全程。

有一种丢包对上面这一切都是隐形的:最后一个。装上尾丢探测,再把同一段交换走一遍:

干等 RTO,第 1 步

丢在尾巴上的段后面没有别的段替它抱怨,所以永远产生不了重复 ACK,只剩计时器 —— 20 ms 路径上是 200 ms。探测改在 2·SRTT 开火,把尾部丢包变成普通的重复 ACK 情形,省下 160 ms。这就是 RACK-TLP(RFC 8985)成为 Linux 默认的原因。

03

接收方通告出来的那个窗口

发送方被压了两道。第一道是接收方唯一说得上话的,而且就驮在每个 ACK 上。

每个 ACK 都带着一个接收窗口: 那一瞬间接收方 socket 缓冲里剩的空位。发送方线上未确认的字节永不超过这个数,于是流控是无损的 —— 接收方从没被发过它装不下的量。

这个窗口不是谁定下的常数,它是缓冲上的算术,而应用的读指针是它的另一端。把读指针往后拖,看通告出去的窗口怎么关上:

0 个段未读。沿着缓冲拖动读指针;方向键一次移动一个段,Home 让它追上
0 个段未读 —— rwnd = 11 KB

注意这里没有一件事和网络有关。接收窗口变小是因为应用慢,应用一读它就立刻重开,所以接收侧的卡顿传到发送方那里,长得和带宽上限一模一样。线上抓的包分不出这两者。

把读指针一路拖到最后,窗口就到零,而协议必须有办法从这个状态出来。把持续计时器一步步走出来:

窗口已关,还没探测

窗口更新本身就是个 ACK,而 ACK 从不重传,于是丢一个更新就会让连接永远死锁。持续计时器是逃生口:发送方一直戳,间隔翻倍到 120 s 封顶。没有报错、没有超时、没有日志行 —— 一个卡住的消费者能在沉默里拖垮整条流水线。

窗口还有一个问题,这次是算术而不是行为。把 SYN 里协商的缩放位移抬上去,看原始字段差得有多远:

位移 0:能通告的最大窗口是 64 KB

16 个比特把通告窗口封在 64 KB, 这在 1981 年很阔绰,现在差了三个数量级。RFC 7323 把它乘上最多 214,能到 1 GB。这个选项只出现在 SYN 里,所以剥掉它的中间盒会把整条连接的一生封在 64 KB,悄无声息。

64 KB 是天花板还是宽裕,完全看链路。设定带宽和往返时间,把管子的容积和窗口填得下的部分对着读:

BDP = 146 KB

因为吞吐是窗口 ÷ RTT,没缩放的 64 KB 窗口在 70 ms 上只能到 7.5 Mbps,不管链路按什么卖的:一条 的路径在这个延迟下需要 8.3 MB 在途,只交得出标称速度的不到 1%。查这个,排在任何人怪罪拥塞之前。

04

发送方自己猜出来的那个窗口

第二道约束没人通告。发送方独自维护它,凭证据,猜一个看不见的网络。

拥塞窗口是发送方私下对这条路径能吃下多少的估计。它从不出现在头部里,也从不协商,而在途字节被两个窗口里小的那个压住,不是它们的和。

一条连接交付不足时,先要确定卡在哪一个,因为这决定你往哪儿查。把 rwnd 和 cwnd 对着推:

在途被压到 86 KB —— 卡在网络

注意这两个诊断有多不一样。rwnd 卡住,问题在接收方的缓冲或它的应用;cwnd 卡住,问题在网络,缓冲怎么调都没用。ss -ti 两个都打,所以这是个十秒钟的问题,不是猜谜。

新连接手上一点证据都没有,所以它从小开始,然后翻倍。把往返一步步走出来,看 cwnd 怎么去够那根管子:

0 个往返之后,cwnd = 10 段(14 KB)

cwnd 每个往返翻一倍,于是「慢」启动其实是最快的阶段: 10 个段(RFC 6928,14 KB)七轮之后变成 1,280。虚线是100 Mbps 路径在 70 ms 上填满的位置,854 KB —— 六轮,链路被用起来之前 420 ms 的爬坡。

一直翻倍下去会是灾难,所以第一次丢东西时它就停。把轮次走过两次丢包:

第 0 轮 —— cwnd 10 段

看第一次之后的形状。拥塞窗口先减半,然后每个往返涨一个段 —— 加性增、乘性减 —— 这正是多个发送方共享一个瓶颈时能收敛到公平划分而不是来回震荡的原因。它也让恢复时间正比于窗口。

这个正比关系就是 Reno 被换掉的全部原因。沿着 100 ms 路径上一个 2,000 段窗口的恢复过程拖动游标,把Reno 和 CUBIC 对着看:

丢包后 0 s. 沿着时间轴拖动游标;方向键一次十分之一秒,Home 回到丢包点
丢包后 0 s:CUBIC 1400 段,Reno 1000 段

Reno 每个往返只加一个段,于是它要一千个往返 —— 这里是 —— 才能爬回一个被单个包打掉的窗口。CUBIC 的增长是墙钟时间的三次函数而不是往返数的,所以不管 RTT 多少都在 11 s 内回来。越过旧峰值之后那条三次曲线还在加速,把它按住的不是算法: 30 s 时它撞上接收窗口并在那里压平,因为不论自己的窗口涨得多快,发送方听的都是 min(rwnd, cwnd)。 Linux 从 2006 年起就默认用它。

两者共享一个现代路径常常打破的假设:丢一个包就意味着拥塞。把链路的随机丢包率往上滑:

丢包 0.001% 时:Reno 32 Mbps,CUBIC 113 Mbps,BBR 1000 Mbps

掉得陡,而且两条基于丢包的曲线掉法不一样。Reno的上限大约是 MSS ÷ (RTT·√p) —— Mathis 等,1997 —— 所以在 140 ms 路径上 时,千兆链路上它只能跑到 3.2 Mbps。CUBIC有自己的响应函数(RFC 8312 §5.1),按 p−3/4 而不是 p−1/2 衰减:在 处这值 113 Mbps, 而 Reno 只有 32 Mbps —— 但丢包率高过 0.15% 就一文不值,因为 RFC 8312 的 TCP 友好区会把 CUBIC 交回给 Reno 的估计,两条线合成一条。BBR 估计瓶颈速率和最小 RTT,按二者的乘积起搏,所以随机丢包只让它付出真正丢掉的那些字节。

基于丢包的发送方之所以非得看到丢包,是因为它得先把什么东西填满。把瓶颈缓冲拖得更深,看吞吐纹丝不动:

缓冲 146 KB. 拖动缓冲深度;方向键一次一档,Home 恢复到一个 BDP
缓冲 146 KB —— 往返膨胀到 24 ms

基于丢包的发送方会先把面前的缓冲填满才退让,所以 100 Mbps 链路上把往返从 12 ms 推到 396 ms,却一个比特都没多给。解法是瓶颈处的主动队列管理 (fq_codel 是 Linux 默认),或者换一个看时延的发送方。

05

关闭,以及会咬人的那些零件

开一条连接是对称而且快的。关一条两样都不是,而且生产上大多数 TCP 问题都住在这儿。

一条连接是两条独立的字节流,所以关闭也是两件独立的事。每一端写完时发一个 FIN, 并确认对方的 —— 四个包,而且两半之间可以隔上几分钟。

有意思的不是这些包,是它们留下的状态。把关闭一步步走出来,看两列状态怎么分岔:

已发 0 / 4 个包

注意最后是哪一端手里还攥着东西。先调 close() 的那端进TIME_WAIT,把 4 元组攥住 2·MSL。在 Linux 上这是 include/net/tcp.h 里的TCP_TIMEWAIT_LEN —— 编译期写死的 60 s, 不是管 FIN_WAIT_2 的 tcp_fin_timeout。调低那个 sysctl 来缩短 TIME_WAIT 很流行,而且什么都不做。

六十秒是免费的,直到同一个客户端开连接的速度超过端口范围回收的速度。把对同一个对端的建连速率抬上去:

28,232 个端口里占了 6.0K 个

到固定目的地的 4 元组只有本地端口在变,于是ip_local_port_range 给你 28,232 个,耗尽速率是每秒 471 条。到时,connect() 开始返回 EADDRNOTAVAIL。解药是连接复用;而 tcp_tw_recycle 在 Linux 4.12 被删掉了,因为它会搞坏每一个躲在 NAT 后面的客户端。

另一个经典跟关闭完全没关系:两个各自都正确的优化。把一个用两次小 write()写出去的请求走一遍:

两次写,第 1 步 —— 多花 40 ms

看第一种模式里的那段空白。Nagle把第二次小写压着,等第一个段被确认;延迟 ACK 又把那个确认压住最多 40 ms, 指望驮在服务端的回复上 —— 而服务端还没看到完整请求,回复不出来。TCP_NODELAY 去掉了停顿,代价是线上多两个 40 字节的头;一次 writev() 同样去掉停顿,而且线上只有一个段。

到这儿为止没有任何东西会察觉一个干脆消失了的对端 —— 没有 FIN、没有 RST,只有沉默。把空闲计时器调短,读出检测时间:

一个已死的对端在 2.2 h 后才会被发现

tcp_keepalive_time 出厂是 7,200 s,配上九次相隔 75 s 的探测,一个已死的对端要 2.2 小时才被发现。只把空闲计时器降到 仍然剩 12 分钟,因为探测占了大头:空闲 60 s、间隔 10 s、3 次探测是 90 s, 三个得一起动。gRPC 干脆自带 keepalive,不指望谁真的设过这些。

06

速查

四个值得脱口而出的问题、三张装着答案的图,以及五面红旗。

是什么让 TCP 可靠?

一条不变量,而且它画得出来。ACK 号以下,每个字节都按序、恰好一次交付;ACK 号及以上,发送方手上还留着副本。拖动那条边界,看两行怎么交接:

ack = 1. 拖动确认边界;方向键一次一个段,Home 回到起点
发送方可以丢掉 0 B,手上还留着 11 KB

只有当 ACK 号越过某个字节,发送方才可以忘掉它,所以它的缓冲就是可靠性的价钱,而接收窗口是同一笔交易里接收方的那一半。重传、SACK 和所有计时器,都是在不破坏这条规则的前提下推进那一个数字的机械装置。

这些等待到底要多少钱?

这一页上最便宜和最贵的等待之间隔着九个数量级。让正在标价的那一级沿着尺子走下去:

同机架往返:0.2 ms

值得背下来的是第六级到第八级。到为止都是路径的属性;60 s 的 TIME_WAIT 和 2.2 小时的 keepalive 是配置文件的属性,而这两个才是你真能改的。

rwnd 还是 cwnd —— 到底谁在拖我?

用 ss -ti 两个都读出来。rwnd 小意味着接收侧的应用慢,或者 tcp_rmem 小;cwnd 小意味着丢包、慢启动,或者路径本来就长。把两者跟 BDP = 带宽 × RTT 一比,就知道窗口到底是不是瓶颈 —— 而如果连接是新建的,答案往往是两个都不是:

从 connect() 到最后一个字节 84 ms

在同区域链路上,一条新建连接 84 ms 里有 24 ms 花在请求发出去之前。时,同样 200 KB 的响应要 980 ms,其中 280 ms 是建连, 560 ms 是慢启动在爬响应。

哪些失败是无声的?

三种,而且都不写一行日志。在一次 5 MB 下载上把每一种都制造出来,看它给同一份预算加了多少:

144 ms,而且一切正常

每一种都是等待,不是报错。accept 队列满,代价是第一次 SYN 重传的 1.0 s;零窗口的代价是一个持续计时器间隔 1.6 s; 而在跨太平洋路径上是 9.9 s —— 因为传输不再受拥塞限制,而是受窗口限制。用 ss -ti 和 nstat -az TcpExtListenOverflows 读它们。

下面五面红旗里的最后一面,在这里就能亲手摸到。把流水线打开 —— 在响应回来之前,就把不止一个请求放上线 —— 链路一点没变,速率却动了:

每秒 14 个请求,和带宽无关
  • 先去动 TCP_NODELAY。它去掉了 40 ms 停顿,留下每个请求十个 40 字节的头。在用户态把写合并起来,两样一起没。
  • sysctl 文件里的 tcp_tw_recycle = 1。Linux 4.12 已经删掉了;它会静默丢掉一切 NAT 后面来的 SYN。你想要的旋钮是 tcp_tw_reuse。

下一面红旗丢的不是时间,是数据。把发送缓冲装上,再用两种方式各关一次 socket,看对端永远读不到的那部分:

5.7 KB 送达,然后是 EOF
  • 调低 tcp_fin_timeout 来缩短 TIME_WAIT。TIME_WAIT 是编译期写死的 60 s;tcp_fin_timeout管的是 FIN_WAIT_2,另一个状态。
  • 在真实流量上用超时为零的 SO_LINGER。它逼出一个 RST 而不是 FIN,于是在途数据被丢掉,对端看到连接被重置。
  • 一次只来回一条消息的协议。不管带宽多大,吞吐都被 payload ÷ RTT 压住:上每条连接每秒 14 个请求,四个在途时是 57 个。