用 Rust 从零开始写 QUIC:实现 QUIC 连接关闭和错误处理

on 2025-05-03

TCP 的连接是怎么关闭的


从 TCP 四次挥手说起

一说到 TCP 四次挥手,想必大家脑海里第一反应,就是那张逆天的状态机切换图,刚接触的时候,确实感叹为什么要这么复杂。说到这,我不由想到了,我当年在西二旗一边实习一边在准备校招面试的时候,每天早上挤地铁,都会把 TCP 状态机拿出来温习一下(话说西二旗的地铁真的是逆天的难挤,居然每次还有工作人员在外边推,一定要确保每趟班次人上的足够满 😒)。说句实在话,我现在来直接画这个状态机,也不能百分百画对。但是,如果理解了 TCP 挥手要解决的关键问题,那状态机至少不用死记硬背,每次都能八九不离十的回忆出来。就像 dog250 说的那样,大家本质上都是技术工人,熟悉 TCP 只是必备的搬砖技能罢了,TCP 不是圣经,不要吹毛求疵。

arch-01

Ok,现在说一下 TCP 挥手复杂的原因,其实很简单,TCP 是一个全双工传输层协议,所以理论上,TCP 协议中主动发起挥手的一方,只能关闭自身写方向的传输。这意味着,TCP 挥手不得不考虑对方是否也发起了关闭传输的操作(即发送 FIN 包),这也是 FIN WAITCLOSING 状态的区别。想想,如果传输层协议的连接关闭设计中,只需要考虑自身连接的关闭,以及通知对端连接已经关闭,该多好(等下开始表扬 QUIC)。

另外,大家也注意到,主动关闭连接的一方,最终都会进入 TIME WAIT 状态,这又是一个经典的面试题。毕竟 TIME WAIT 状态会让内核还在维持着 TCP 连接的状态,导致对应的四元组还被占用,TIME WAIT 状态一多,很容易导致新建的 socket 进行 connect 的时候失败,算是高并发工程实践中比较容易遇到的一个问题,所以在面试时候出现的频率也比较高。

但是 TIME WAIT 状态本身在设计上,只是为了确保 TCP 老连接的消息不会影响到新的连接。举个例子,TCP 某一端进行关闭的时候,必然是发出 FIN 包,但是 FIN 包是必须被对端显式 Ack 的。主动关闭的 TCP 状态接收到对端的 FIN 包,然后进入 TIME WAIT 之后,必须要回应一个 FIN-Ack 报文,不然对端会持续重传 FIN 包,FIN 包可能会影响到后续的新创建的 TCP 连接。说到这,大家都很清楚,这里会影响新创建的 TCP 连接,其实要求是很苛刻的。首先,要满足新老连接的四元组必须完全一致,另外,还需要老连接结束时候的序列号区间和新建连接随机协商出的序列号区间重叠,这样 FIN 包才能影响到新建连接。当然,这里会影响新建连接的,不仅仅是 FIN 包,老连接最后的一些数据包文,也可能会影响。

不管怎么说,既然有发生的可能性,那么 TCP 设计上就得考虑到这种边边角角的问题,总不能视而不见吧。而 TCP 的解法,就是设计出 TIME WAIT 状态,并且在最后维持两倍的 MSL 时间,确保属于老连接的报文全部消逝掉,不会影响可能存在的新连接。

其实,我在这里拉扯了这么多大家耳熟能详的东西,只是为了方便我批判 TCP 挥手的设计。第一个就是 TCP 对连接划分还是过于简单,四元组的设计还是存在一定的撞车可能,TCP 握手的时候被它折磨,到了挥手还是得考虑怎么解决这个问题。第二个就是 TCP 的 FIN 包非得被确认,才能算挥手结束,有点太过于追求完美了,在工程上不够简洁,真实世界里,很多实现不会那么按部就班来做,FIN 包确认丢失是家常便饭的事情。

TCP 连接非正常关闭的途径

这里,再补充下 TCP 其他关闭连接的方式。第一种是 TCP reset 机制,这也是 TCP 连接关闭的一种补充,四次挥手是 happy path 的话,reset 机制就是各种异常情况的时候,TCP 快速关闭连接的一种手段。举个比较经典的例子,如果某个 TCP 客户端向没有监听的目标地址发起握手,一般内核协议栈的实现,都是快速回复一个 TCP RST 报文。虽然说,TCP 协议栈会对 RST 报文进行一些校验限制,比如说序列号必须在接收窗口范围内。但是,这里必然是存在一定的安全隐患的,之前我提到过的 tcpkill 就是一个经典例子。

第二种的话,是 TCP Keepalive 机制,如果 TCP 打开了心跳保活机制的话,到了规定的时间,TCP 协议栈会进行心跳探测,如果对端没有回应,那么 TCP 协议栈就会静默销毁,既不走四次挥手流程,也不发送 RST 报文。一个有趣的地方是,如果对端只是相关连接的 TCP 状态销毁了,而不是传说中机器都失联了,那么对端会根据 TCP reset 机制,返回一个 RST 报文,帮助本端快速销毁掉。还有一个比较有意思的地方,是 TCP 协议栈怎么去做心跳探测的,这个实现倒是很简单,直接发一个长度为零的空报文,这里有个技巧,是怎么让对端一定回复 ACK。就是这个空报文的序列号故意设置成之前已经被确认的数据,这样对端会认为是一个乱序报文,会快速发送一个当前最新接收情况的 ACK 报文来通知本端。

QUIC 的连接关闭设计


QUIC 如何解决 TCP 挥手面临的问题

由于在多路流复用的博客里面,我大篇幅的介绍了 QUIC Stream 关闭的设计,所以想必大家都清楚,QUIC 连接关闭其实并不需要考虑全双工的问题。因为全双工是数据传输通道需要考虑的,而 QUIC Stream 已经很好的解决了这个问题。QUIC 连接层面只需要考虑单纯连接关闭的问题,例如如何优雅的关闭本端连接,如何通知对端,又或者自己是如何被对端关闭。

由于 TCP 连接是通过四元组来划分的,当然也可以说是五元组,即 TCP-sip-sport-dip-dport。考虑新老连接报文互相影响的问题,是 TCP 设计的必修课。而对于 QUIC 而言,则基本不需要考虑这个问题。首先,QUIC 并不是基于 UDP 的四元组来划分连接的,而是 QUIC 两端协商的 scid 和 dcidd,连接碰撞的概率比 TCP 小得多。另外,还有一个非常关键的点在于,QUIC 千辛万苦融合了 TLS 层,所以 QUIC 报文必须被解密成功之后,QUIC frame 才会被处理,在协议栈生效。而 AEAD 加密算法可以保证,只有真正被当前连接派生密钥加密的报文,才能被解密成功,这样 QUIC 其他连接的报文,即使误打误撞混进了当前连接的处理逻辑,也无法通过解密这最后一道关卡。

具体设计细节

终于来谈 QUIC 挥手的具体设计细节了。和 TCP 非常相似的一点是,QUIC RFC 上来就开门见山,强调 QUIC 连接关闭只存在三种情况。分别是超时关闭、主动立即关闭、以及强制终止。正好分别对应上文中 TCP 的 Keepalive 超时机制、四次挥手的正常关闭和 Reset 异常关闭机制。而又因为 QUIC 协议本身设计的优越性,上面提及的一些难题,QUIC 都从协议设计层面化解掉了,所以 QUIC 在连接关闭上的设计对比 TCP 来说会大大简化。

Idle timeout

QUIC 的 idle timeout 机制和 TCP keepalive 机制在设计上有一些不同的。首先,QUIC idle timeout 多了一个协商的环节,双方会在 QUIC 握手中确认彼此的数值,并且以最小值作为实际生效值,而 TCP keepalive 机制是需要应用层主动开启,并且是单方面生效的。其次,QUIC idle timeout 并不会在连接空闲时间达标之后,像 TCP keepalive 机制那样进行特定的探测,而是直接认为连接应该立即被关闭。

但他们也有一些相同的点,比如说连接空闲定时器触发之后,都选择了静默关闭,而不是走常规的关闭流程。还有的话,QUIC idle timeout 机制可以通过一些 ACK 触发帧(比如说 PING 帧),来重新刷新 idle 的计时器。当然,这一点反馈的是,他们对于连接闲置标准的判断是基本相同的,只有 ACK 触发帧的相关数据发出或者接收,才应该重置空闲定时器。

最后,QUIC RFC 建议 QUIC 协议栈实现的时候,给用户提供可以让底层自动保活的能力。比如说,应用层没有数据,但是 QUIC 协议栈可以通过 ACK 触发帧来保活连接,不被 idle timeout 机制杀死连接。但是,我觉得这种保活能力,应该是应用层自己用心跳去维持的,而不是让传输层协议栈来实现,毕竟传输层协议栈对于应用层来说是黑盒,不可控。在这种可能影响传输效率的实现上,让应用层自己动手,灵活度和效率可能会更好。

常规关闭(QUIC 挥手)

arch-01

刚才我们说了 QUIC 不再需要考虑全双工传输和新老连接数据包干扰的问题。所以,QUIC 常规关闭流程会非常简单明了。大家可以从图中看到(不小心画了两张,意思都差不多,大家对付着看看),QUIC 在关闭的过程中,主要是存在两个状态,分别是 Closing Connection StateDraining Connection State,前者是主动发起关闭的时候会进入的状态,后者是被对端通知关闭的时候进入的状态。一旦进入了 Closing Connection State 之后,QUIC 连接如果再接收到新的报文,只需要重复返回 Connection close frame,无脑回绝对端。而接收到关闭消息进入 Draining Connection State 的 QUIC 协议栈,则更简单,在简单的回复一个 no-error 的 Connection close frame 之后,就不要再发送任何数据了。

arch-01

QUIC 设计了 Connection close frame,用来让连接的一端去主动关闭另外一端。这里,有两处非常棒的设计,一个是可以携带自定义错误码和错误描述。可能大家看到只是能够携带错误码和错误描述,感觉没什么大不了的。但是要知道,如果一个传输层协议,很容易就把错误码大盘给采集出来,那传输层协议上线后,稳定性和质量都会是很有保障的。而 TCP 不能自行携带这些错误信息,只能依赖应用层的额外设计。QUIC 能够这么轻松实现这样的效果,也多亏了 QUIC 协议在设计一开始,就把控制消息和传输数据给拆解开,都是独立的 QUIC Frame,而 TCP 仅仅在自己的头部中留了一比特给 FIN,所以只能望洋兴叹。

不仅如此,Connection close frame 还特别将错误信息分成了 QUIC 协议层传输错误和应用层错误。这个可谓非常贴心了,直接节约了应用层不少的工作量。同时,QUIC RFC 中还特别详细的定义 QUIC 协议层传输错误各种错误码,这样的话,快速处理定位 QUIC 相关的问题,也能起到非常大的帮助。比如说,我们都可以直接使用 WireShark 尝试快速定位 QUIC 连接断开的原因。其中, TLS 协议规定相关错误码,还特别映射到了 QUIC 传输错误码中,我只能说给 QUIC 设计者点赞,真的是面面俱到。

另外一个是 Connection close frame 不需要被 ACK,这个实现也极大的简化了 QUIC 常规关闭的流程。毕竟,主动发起关闭 QUIC 连接的一方,也不需要考虑对端的情况,毕竟既然应用层决定关闭连接,那么必然是所有事情都处理完毕了。而对端哪怕没有接收到 Connection close frame 的关闭消息,对端也可以短时间内通过发送常规数据,来反复获取 Connection close frame 来快速关闭。如果对端暂时没有数据要发送,也错过了本端三倍 PTO 的 Closing Connection State 状态,那也可以通过自身的 Idle timeout 机制来确保自己关闭。这里,肯定会有人问,如果 idle timeout 没有设置限制,同时也阴差阳错错过了对端所有的 Connection close frame 消息。那对端还有什么办法能及时关闭呢,至少不要浪费被关闭一方的资源。下面我们就介绍 QUIC 异常关闭机制,不过具体工程落地的时候,QUIC idle timeout 推荐最好还是一定要设置,因为 QUIC 异常关闭并不是包治百病。

异常关闭

关于 QUIC 的异常关闭机制,其实就是 QUIC 的 reset 消息,当 QUIC 连接收到了对应的 reset 消息之后,会立刻进入Draining Connection State,然后就是连接自我销毁的流程。但是 QUIC RFC 通篇都在说怎么通过随机生成、协商以及校验 token 来增强 reset 消息的安全性。大家肯定要问了,QUIC 不是有加解密算法确保消息是可信的吗,为什么要额外给 reset 消息做安全校验。

其实答案很简单,就是 QUIC stateless reset 消息是在 QUIC 协议栈销毁了活跃连接状态之后的关闭方案。所以,对应连接的加解密上下文已经被销毁了,自然无法通过正常的 Connection close frame 来关闭连接。这里,可以回顾上文的一个例子,内核 TCP 协议栈在处理一个目标无监听的握手请求时,往往是回复一个 RST 报文。QUIC 其实也想去覆盖这个场景,确保哪怕没有活跃的 QUIC 连接,也能帮助对端 QUIC 协议栈快速关闭连接,避免不必要的资源浪费。

可能会有人说,QUIC stateless reset 为了确保安全,有安全校验机制固然很好。但是没有活跃连接的 QUIC 协议栈也不能长时间持有 stateless reset token,因为这也是有资源开销的。不过,QUIC 协议早就考虑到了这种情况,QUIC RFC 中规定了,reset token 可以通过固定的静态密钥、无效请求包的 dcid 通过协议规定的加密方式来生成。这样的设计,就可以让 QUIC 协议栈不需要浪费资源记录 token,并且如果是一个 QUIC 集群的话,也不需要特别去浪费精力使用额外的中间件去同步记录这样的 token 信息。集群中,每个接收到无效报文请求的 QUIC 协议栈都能够自行生成一样的 token,确保和对端自己记录原先协商的 token 是一致的,从而安全又快速的关闭对端 QUIC 连接。这让我想起,之前我参加的 UDP 自研协议项目,token 的实现也是这么一个思路,不然集群内机器状态同步会有点重了。

但是正如上面强调的那样,这个方案不是包治百病的。哪怕 QUIC 已经考虑到了一切,但是,万一整个集群都挂了呢😂,所以一个贴近自身业务场景需要的 idle timeout 设置是必不可少的。

边界场景

防止放大攻击

在 QUIC 关闭流程里面,基本有两个地方要考虑防止放大攻击。如果我没记错,之前的博客应该已经讨论过什么是放大攻击这个话题了,所以我们直接说 QUIC 关闭的时候是怎么防御的。首先是 QUIC 进入 Closing Connection State 之后,按照协议的规定,是会主动回应对端的报文一个 Connection close frame,来帮助对端快速关闭连接。但是在进入 Closing Connection State 之后,QUIC 协议栈其实有两个选择,一个是还正常保留 QUIC 连接的状态,然后遇到可以正确解密的数据包,再回应一个 Connection close frame,这样的话,因为数据包是可信的,没有放大攻击的风险(这也是 feather-quic 选择的实现)。另外一种,则是为了更快的回收资源,选择销毁连接大部分状态,包括了加解密的能力,仅保存之前的 Connection close frame 消息,这个时候 QUIC 连接已经无法分辨到来的 QUIC 报文是否是可信的,所以必须限制好 Connection close frame 消息的发送频率,规避可能的放大攻击。虽然 Closing Connection State 维持时间一般非常短(三个 PTO 的时长),但是我们还是要做限制发送的策略。

还有一处可能有放大攻击的地方,就是 QUIC stateless reset 机制了。毕竟,只要有非法报文打过来,QUIC 协议栈在没有匹配活跃连接的情况下,都会算出十六个字节的 reset token,并且组装一个 reset 消息回应回去。这里一般会有两种攻击的场景,第一种是攻击者直接模拟报文的源 ip 为受害者的地址,然后发送非法流量,欺骗合法对端发送 QUIC reset token,但是只要 QUIC 协议栈实现坚持一个原则,就是响应的 reset 消息决不能大于触发报文的长度,那放大攻击自然迎刃而解。

第二个场景比较有意思,我一开始也没想到,是 QUIC RFC 提及的,就是攻击者模拟报文的源 ip 是另外一个支持 QUIC 协议的集群。这样的话,响应的 reset 消息也可能会触发另外一个 QUIC 集群的 reset 消息,这样就有可能无限死循环,两端对打,攻击者基本不消耗资源,就可以撬动杠杆。所以,QUIC 协议要求一方面确保 reset 消息长度不仅不能大于触发报文,还必须要求小于触发报文。这样的话,由于 reset 消息有最小 21 字节的长度限制,过小的触发报文无法继续触发,那死循环也就跳出。另外,QUIC 协议栈在发送 reset 消息的时候,也需要做发送频率等一些限制进行兜底,确保更快速的跳出这个死循环。

握手时关闭连接

首先,QUIC Initial 和 Handshake 空间都支持传递 Connection close frame,当然这个并不让我惊讶,我在之前实现握手的时候,遇到太多次对面正规军的 QUIC 协议栈给我发送关闭消息了😂。在握手时候关闭连接,这次我是要实现某些情况下主动关闭,所以 QUIC RFC 还是提示了不少要注意的细节。

首先,是尽量使用最高数据保护的数据包里面发送 Connection close frame,这是为了防止对端已经丢弃了较低级别的对称密钥。又要防止对端可能还没有协商出更高一级的对称密钥信息,而无法解密。所以做法就更加简单,那就是 Initial 和 Handshake 空间最好都发一下,反正最后都是塞到一个 UDP 数据报文里面发出去。

另外,协议推荐我们要注意,如果是 Initial 包发生错误的时候,不要直接发送 Connection close frame,因为这依然可能为攻击者留下空间。毕竟,on-path 攻击者可以轻松伪造 Initial 报文,来影响正常连接的握手。所以,推荐的做法是直接丢弃缺乏身份校验的数据包,我这里实现是,必须满足解密成功,并且是非 Initial 空间的数据包出现错误,才会回应 Connection close frame

阶段性感受


先用 tokei 看了下项目代码数量,没想到只是简单的实现了 QUIC 协议栈的基本功能,就写了这么多代码,虽然里面有不少集成测试的代码。这让我觉得有点不应该,如果我投入的精力再多一些,我应该能优化一下,把代码数量减少一些。虽然说 rust 写了不少,但是我觉得只是增加了一些对 rust 的基础了解,很多 rust 有意思的部分,我还没有深度体验。比如说,我这里还是使用了经典的网络库方案,单线程 + 事件循环,所以没有机会实践中体验 rust 的线程安全设计。另外,我还没有尝试使用 rust async 协程机制来让 feather-quic 变成一个更现代的协议库。当然,还有 rust 模板元编程之类的,我也是很少使用。所以,希望后面能多用一用,再深度体验一下 rust。

arch-01

终于结束了 QUIC 基础功能,这比我预想的时间多了好多,我本来以为前面可以切瓜砍菜一样很快搞定,但是写代码、测试然后再写对应的技术博客,这个流程耗费的精力比我预想的要多得多。我在博客里面尝试把一些经典问题讲出新意,我觉得新意最重要的地方,就是站在设计者的角度,去理解当时面临哪些难点以及历史包袱,再去看实际的解法,这样的回答应该是比普通流水账好一些。当然,受限于个人实力,我觉得我很多地方表述的不够好,只能等日后有机会再去修改润色了。

另外还有点遗憾的地方是,我觉得目前项目的完成度并不是特别高,代码里面 应该还 有不少 Bug,并且有一些实现细节还需要反复推敲。还是那句话,我本来以为很快就完成,因为我还想启动一个想了很久的排查工具项目(大概是基于 eBPF 和 DWARF 来实现的针对用户态进程的调试工具),但是高估了自己技术能力和投入度😂,导致目前战线拉得很长。

最后,终于可以向 QUIC 一些高级特性进发了,这次该不会花比之前更多的时间吧,八成花费的时间要比之前多得多。老规矩,放上这篇博客相关的 pr,嗯,还有一些 bugfix,写着博客的时候,发现不太对劲,自我纠正的。对了,ea fc25 开始赛季蓝活动了,要不要再给 ea 一个机会