tcp timewait 是个恼人的状态,它的恶心自两类恶心的询问,oncall 和面试。大概诸如 “如何减少 timewait socket 数量”,“tw_reuse 和 tw_recycle …”,如果只为应用,用 reset 关连接就够了。
timewait 状态的根本目的是 “防止旧连接的报文干扰复用相同四元组的新连接”。
由于所有 tcp 连接完全复用相同的平坦的 32bit 序列号空间,所有 tcp 连接的序列号就在这 32bit 空间绕来绕去,如何区别报文所属的连接以及连接内所属的位置就是个问题。
连接内部序列号回绕好说,限制 wscale 最大 14 即可约束 tcp window 不超过 1/4 个序列号空间(请参考 rfc1323 和 linux before,after 宏),足以区分了。而为了在连接间区别序列号,tcp 内置了 timewait 状态,在关闭连接时等待足够的时间后才能复用相同四元组创建新连接,这段时间足够久,以至于任何旧连接的报文在网络上死的透透的。
linux tcp timewait 等待时间为 60s,这时间太久,大多数短视频都播完了。
事实上 wscale-max 已经足够好,linux timewait 这 60s 也已经够短,但还不够,于是借助 rfc1323 timestamps 选项的 paws 被标准化。为此优化效果,linux 又有 tcp_tw_reuse 和 tcp_tw_recycle 配置选项,但这些选项又太复杂,必须听专家的:“It should not be changed without advice/request of technical experts.” 且默认又不开启,显然绝大部分好学成性的程序员就给这些 technical experts 找了不少麻烦。
说到底,为什么 tcp_tw_reuse 默认不开启呢?因为它相当于取消了 timewait 状态并可以立马复用相同四元组新建连接,该四元组旧连接迟到报文如果且恰恰命中 window(32bit 说大不大,命中不难),就会污染新连接,至于新建连接时对端 lastack 状态等 last ack 却等来个 syn 倒是次要,如果序列号空间是 128bit,就算 lastack 等来个 syn 又如何,正常建连就是了。
核心还是无法区别一个序列号属于哪个连接。tcp 用 timewait 状态解决这问题是一个非常拙劣的手法,说实在的就是 “搞不定就等一段时间”。这种手段如何 low 到被喷,我举个例子。在我们写内核模块时,卸载时总担心引用问题,如果卸载当时正好有一个线程要访问模块数据就会触发莫名其妙的问题,很多人会在 fini 函数最后加入 sleep(5) 这种调用来模拟一个类似 rcu 的逻辑,然后被经理猛喷,能喷好几年,逢人就跟说谁谁做的这个 sleep(5) 好 low,可经理却一句也不敢喷 tcp waitwait。
paws 作为解决 tcp 序列号区分的通用方案,tcp_tw_reuse 和 tcp_tw_recycle 为 paws 而来,然后 tcp_tw_recycle 又由于和 nat 胶着,为了解决非常稀有的序列号污染问题而引入了一开启就一定会出现的 syn 被默默丢弃的建连失败问题,最终取消了 tcp_tw_recycle,瞎折腾一圈。tcp_tw_recycle 的例子很像为了一盘粗,包了一顿猪肉馅饺子,结果有部分顾客是 msl,还要区分招待,自己给自己找事。
tcp timestamps 不符合高内聚性,它尝试解决很多问题,却每一个问题都解决不好。对于区分新旧连接序列号而言,timestamps 做得太过了,为这种区分根本没必要在连接内递增。
正确的做法是,为 tcp 引入一个 16bit(甚至 12bit,8bit) 的字段,相同四元组连接每新建一次递增 1 就好。松弛一下,四元组 hash 到同一个值的连接共享一个全局字段,每新建一个连接该全局字段递增 1。考虑到一个 host 的建连能力,该全局字段回绕一次的时间大概在 msl 就好了,而这是相对容易的,相当于把 timewait 时间平摊给了每一次连接。
这种方法非常常见,我们每天都在用,我们的分层计时,xx年xx月xx日xx时xx分xx秒,我们的十进制,60 进制计数,再或者我们的空间分层,xx市北大街,xx市东大街。我们可以将高一层的计数称作 “版本号”,以示区分,我们在软件更新,内核协议栈的路由表更新方面(可参考 linux kernel 实现)常用这种方式。相对而言的另一种拙劣方法就是 “等一会儿,等足够久”,让时间遗忘一切。
而 tcp timestamps 的做法是,秒针和分针,时针各自按各自的节奏进步,可想而知会多么混乱,复杂且低效。timestamps 也是要忘掉的,它不是问题的本质,它只是 workaround。本质是什么,本质就是为了区分一个序列号属于哪个连接。
如果一开始 tcp 的 seq 缩短为 16 bit,另外 16bit 作为 version,新旧连接间的序列号就区分出来了,如果觉得同一个连接内部 16bit 不够,那就 24bit,留给连接间 8bit… 总之,32bit 总空间,tcp 全部用于 seq 平坦编码,太粗了,彼时根本没有足以快速回绕的带宽,属过度设计了。
tcp 32bit 序列号空间可容纳 4g 数据,而在 msl 内传输完 4g 数据需要非常大的带宽,在 2010 年以前几乎很难满足这样的条件,即使考虑连接内回绕,最大 4g/4 的窗口约束也足足保证了当时的带宽利用率,tcp 在 1970 年代的考虑过度了,看起来当前的 ipv6 也有类似问题。
如果采用单独的全局 version,以 16bit 全局 ver 为例,连续相邻 65535 次连接才有可能冲突,显然已经超过 msl 了,如果不想占用 32bit 序列号空间就另开辟一个 16bit option 而不必复用 32bit timestampts。检查 paws 时只需要看收到报文携带的 ver 字段与当前连接 ver 是否一致即可。
没了 timewait 状态,很多复杂恼人的逻辑将彻底消失。finwait 收到 fin 并回复 ack 后方可直接关闭,对端 lastack 也不再需要坚持,类似 synack 那帮最多 retry n 次 fin 后亦可直接关闭而无后患。
设计新协议时,不要总觉得 tcp 足够久就是好的,不要什么都学 tcp。
姿势掌握了吗?
浙江温州皮鞋湿,下雨进水不会胖。