lsquic实战《一》—— 架构解析与核心概念入门
1. 初识lsquic它是什么以及为什么选择它如果你正在寻找一个用C语言实现的、功能齐全且文档相对友好的QUIC协议库来开发你的网络应用那么lsquic很可能已经进入了你的视野。我当初选择它也是基于类似的考量项目需要引入QUIC协议来优化传输性能在对比了几个开源实现后lsquic以其清晰的架构、对最新协议版本如QUIC v1和多个草案版本的支持以及相对活跃的社区成为了我的首选。简单来说lsquic就是一个工具包它帮你处理了QUIC协议里所有复杂、繁琐的底层细节比如加密握手、连接管理、流的多路复用、丢包重传等等让你能更专注于自己的业务逻辑。但lsquic有一个非常鲜明且重要的特点这也是它和很多“大而全”的网络库最不一样的地方它本身不提供网络I/O和事件循环机制。这句话什么意思呢回想一下我们用过的Libevent、Libuv或者甚至是简单的select/poll这些库的核心工作之一就是帮你管理socket告诉你什么时候可以读数据、什么时候可以写数据。lsquic把这块工作完全交给了使用者。它不会自己去创建socket也不会内置一个事件循环来驱动整个程序。听起来是不是有点“麻烦”但恰恰是这种设计赋予了它极大的灵活性。你可以把它轻松地“嵌入”或“嫁接”到你现有的任何网络框架中无论你这个框架是基于epoll、kqueue的还是基于某个异步IO库的。你只需要告诉lsquic“当有数据需要发送时请调用我这个函数当有外部事件比如定时器到期需要处理时也请调用我这个函数。” 剩下的协议逻辑就全交给lsquic了。这种“回调驱动”的架构模式对于已经有一套成熟网络处理模型的开发者来说其实非常友好。你不需要为了迁就lsquic而重写整个网络层只需要实现几个约定的回调接口就能把QUIC这颗强大的“引擎”装进你自己的“汽车底盘”里。在接下来的内容里我会带你一步步拆解lsquic的核心部件看看这个引擎到底由哪些零件构成以及我们该如何动手把它组装和运行起来。2. 核心架构拆解Engine, Connection, Stream的三层世界要驾驭lsquic首先得理解它的核心架构模型。你可以把它想象成一个三层结构最底层是负责全局调度和管理的Engine引擎中间层是代表一次端到端会话的Connection连接最上层是承载实际应用数据的Stream流。这个分层非常清晰每一层各司其职共同协作。2.1 Engine全局的大管家Engine是lsquic世界的总指挥部。你的程序启动后第一件要做的事就是创建一个Engine实例。这个Engine负责管理所有从这个程序发起的或者接收到的QUIC连接Connection。它内部会处理很多全局性的事务比如协议版本的协商、全局配置参数的生效、以及最重要的——调度。Engine的调度工作很有意思。因为它自己不跑事件循环所以它需要你告诉它两件事第一什么时候该处理内部事务比如检查超时、触发重传第二如何把要发送的数据包送出去。这是通过两个关键的回调函数实现的一个是处理外部事件的回调比如你告诉Engine“现在有100毫秒的空闲时间你可以做内部处理了”另一个是发送数据包的回调Engine准备好数据包后调用你提供的函数由你负责用socket发送出去。Engine可以初始化为客户端模式或服务器模式。如果你的应用角色比较特殊既需要连接别人也需要被别人连接那么通常的做法是创建两个Engine实例一个客户端一个服务端分别管理不同方向的连接。2.2 Connection可靠的会话通道在Engine的管理之下每一个独立的QUIC会话就是一个Connection。它对应着网络世界中两个端点之间的一条虚拟通道。这条通道是可靠的保证了数据包按序、无误地送达并且所有QUIC协议层面的细节比如加密解密、拥塞控制、连接迁移等都在这一层被处理掉了。从开发者的视角看客户端的Connection是我们主动调用lsquic_engine_connect()函数创建的意图是去连接一个服务器。而服务端的Connection则是在完成TLS握手后由lsquic在内部自动创建然后通过回调函数通知给我们的业务代码。一个Connection可以同时承载多个Stream这正是QUIC多路复用的精髓所在避免了HTTP/2中“队头阻塞”的问题。2.3 Stream应用数据的载体Stream才是我们最终传输业务数据的地方。每一个HTTP/3的请求和响应或者你自定义的应用协议消息通常都会在一个独立的Stream里传输。Stream是双向的意味着两端都可以在上面发送和接收数据。它必须依附于一个Connection不能单独存在。当我们想发送一段数据时操作的是Stream的接口。我们把数据交给某个StreamStream会把它切分成合适大小的帧然后交给它所属的Connection。Connection再将这些帧连同其他Stream的帧以及协议控制帧一起组装成QUIC数据包最后递交给Engine由Engine通过我们设置的回调函数发送出去。接收数据则是相反的过程。这种设计使得应用层逻辑Stream和传输层逻辑Connection得到了很好的分离。3. 关键特性解读不只是“更快”的协议lsquic实现了一系列QUIC协议的关键特性这些特性不仅仅是让协议“更快”更是为了解决传统TCP/TLS协议栈中一些固有的痛点。理解它们能帮助我们在实际项目中更好地利用QUIC的优势。DPLPMTUDDatagram Packetization Layer Path MTU Discovery翻译过来是“数据包化层路径MTU发现”。这听起来很拗口但解决的问题很实际我一次性能发多大的UDP包才能既不被中间的路由器分片分片会降低性能和增加丢包风险又能最大限度地利用网络带宽在TCP里MTU发现是协议栈底层自动做的。但在QUIC里因为整个数据包包括头部和载荷都是被加密的底层网络设备看不到端口信息传统的MTU发现机制可能失效。DPLPMTUD就是QUIC自己实现的一套智能探测机制。它会尝试发送不同大小的探测包根据是否收到对方的确认来动态地找到当前路径上最优的包大小。这个特性默认是开启的对于提升在高MTU网络比如数据中心内部下的传输效率非常有用。Path Migration路径迁移想象一下你的手机从WiFi切换到4G网络IP地址瞬间变了。如果是TCP连接这次连接基本上就断了得重连。但QUIC的连接是使用连接IDConnection ID来标识的而不是传统的四元组源IP、源端口、目的IP、目的端口。当客户端的网络地址发生变化时它可以沿用之前的连接ID通过新的IP地址继续发送数据告诉服务器“我还是我只是换了个地址。” 服务器验证后连接就能无缝地迁移到新的路径上。这对于移动端应用的用户体验是质的提升视频会议、在线游戏等场景受益巨大。lsquic完整地支持了这一特性。NAT Rebinding这和路径迁移解决的是类似但不同的问题。有时候客户端的IP没变但是NAT设备比如你家路由器背后的映射端口超时重建了导致服务器看到客户端的源端口变了。在TCP看来这也是一个陌生的新连接。但QUIC同样能通过连接ID来保持连接的连续性。lsquic在处理这类网络环境变化时表现得相当稳健。其他重要特性ECN显式拥塞通知允许网络设备在数据包上标记拥塞信号让端点更早地调整发送速率避免更严重的丢包。Spin Bit是一个轻量级的机制允许路径上的网络监控设备估算连接的往返时间RTT便于网络诊断。TLS Key Update允许在连接不断开的情况下更新加密密钥增强前向安全性。Datagrams提供了不可靠、无序的数据报传输能力适合音视频流、游戏状态同步等场景。ACK确认延迟则通过适当延迟发送确认包有机会将多个ACK合并发送减少协议开销提升吞吐。4. 实战第一步初始化流程与核心配置了解了架构和特性我们来看看如何把lsquic用起来。整个初始化过程可以概括为配置Engine - 实现回调 - 创建Engine - 将Engine融入你的事件循环。下面我们以客户端为例拆解每一步。4.1 配置的基石lsquic_engine_api和lsquic_stream_if在创建Engine之前我们需要填充两个至关重要的结构体lsquic_engine_api和lsquic_stream_if。它们是你和lsquic引擎之间的“契约”。lsquic_engine_api是给Engine的配置单。里面包含了Engine运行所需的所有信息必须实现的回调ea_packets_out和ea_packets_out_ctx这是最核心的回调。当lsquic有数据包需要发送到网络时就会调用这个函数。你的实现里应该把传入的数据包可能是一个数组通过你的socket发送出去。ea_stream_if指向你实现的lsquic_stream_if结构体的指针告诉Engine你的Stream回调函数在哪里。ea_get_ssl_ctx服务端或ea_ssl_ctx客户端用于提供TLS上下文SSL_CTXlsquic内部使用BoringSSL或类似的库进行加密握手。你需要自己初始化和配置这个SSL_CTX。重要参数ea_version指定你的应用希望使用的QUIC版本号比如LSQVER_I001代表QUIC v1。ea_conn_ctx一个用户自定义的上下文指针它会传递给后续创建的Connection。ea_settings指向一个lsquic_engine_settings结构体里面包含了海量的调优参数比如各种超时时间、流量控制窗口、拥塞控制算法选择等。刚开始可以用默认值后续优化时会经常和它打交道。lsquic_stream_if定义了Stream和Connection生命周期中各个事件发生时lsquic要调用的你的业务函数。其中几个关键的回调是on_new_conn/on_conn_closed当一个新的Connection被创建或关闭时触发。on_new_stream/on_stream_close当一个新的Stream被创建或关闭时触发。对于HTTP/3服务器收到一个客户端发来的新请求时就会触发on_new_stream。on_read当Stream上有可读的数据时触发。你在这个函数里调用lsquic_stream_read()来读取数据。on_write当Stream的写缓冲区有空闲可以继续写入数据时触发。你在这个函数里调用lsquic_stream_write()来发送数据。4.2 创建Engine并融入事件循环配置好lsquic_engine_api后调用lsquic_engine_new()函数就能创建出Engine实例。但这只是开始这个Engine现在还是静止的。要让这个引擎转起来你需要做两件事喂数据给它在你的主事件循环中当你从socket收到UDP数据包时你需要调用lsquic_engine_packet_in()函数把这个原始的数据包交给Engine去处理。Engine会负责解密、解析并触发相应的回调比如on_read。给它处理时间Engine内部有很多后台任务比如检查定时器、处理重传队列。你需要定期地比如每隔10毫秒或在你的事件循环空闲时调用lsquic_engine_process_conns()函数。这个函数给Engine一个“时间片”让它处理这些内部事务。同时在调用这个函数之前或之后你还需要调用lsquic_engine_send_unsent_packets()来触发发送那些待发送的数据包。这个过程清晰地体现了lsquic的“嵌入”式设计你的网络事件循环是主人lsquic Engine是一个高效的工人。你负责把原材料网络包递给工人并定期给他工作时间他负责把原材料加工成产品协议解析并把需要运走的产品输出包交还给你。整个流程的控制权始终在你手里。5. 数据流详解从Socket到应用层的旅程让我们追踪一个数据包在lsquic处理下的完整生命周期这能帮你把前面所有的概念串联起来。5.1 接收数据的旅程假设我们是一个服务器从UDP socket收到一个来自客户端的数据包。Socket层你的事件循环比如epoll告诉你某个socket可读了。你调用recvfrom读到一个UDP数据报datagram。注入引擎你立即调用lsquic_engine_packet_in()将这个数据报的缓冲区、长度、来源地址等信息传入lsquic Engine。Engine处理Engine首先会检查这个包属于哪个已有的Connection通过连接ID。如果是新连接的第一个包Initial包它会开始握手流程。然后Engine会对数据包进行解密和协议解析。触发回调解析后如果这个包里包含某个Stream的应用数据Engine就会调用你在lsquic_stream_if中设置的on_read回调函数。应用读取在你的on_read回调函数实现里你调用lsquic_stream_read()。这个函数会从该Stream的接收缓冲区中把解密后的、有序的、完整的应用层数据拷贝到你提供的缓冲区里。至此你的业务代码才真正拿到了可用的数据。关键点你从socket读到的始终是加密的、原始的QUIC数据包。只有经过lsquic_stream_read()出来的才是你的纯业务数据。这个解密和组序的过程对你是透明的。5.2 发送数据的旅程现在你的业务逻辑想要通过某个Stream发送一段数据。应用写入你调用lsquic_stream_write()将你的数据缓冲区、长度等信息传入。这个函数是非阻塞的它会尽可能多地将数据放入该Stream的发送缓冲区。流与连接处理Stream会按照QUIC帧的格式封装你的数据。然后这些帧被递交给它所属的Connection。连接组装与调度Connection会收集来自各个Stream的帧加上ACK帧、流量控制帧等协议帧按照当前拥塞窗口大小组装成一个或多个QUIC数据包并对其进行加密。然后将这些待发送的包放入Engine的发送队列。引擎通知此时数据并没有被立即发送。Engine会在下次你调用lsquic_engine_send_unsent_packets()时或者在你调用lsquic_engine_process_conns()导致有数据需要立即发送比如ACK时通过你之前设置的ea_packets_out回调函数来通知你。网络写出在你的ea_packets_out回调函数实现里你会收到一个或多个lsquic_out_spec结构体每个里面包含了要发送的数据包缓冲区、目标地址等信息。你的责任就是遍历这个数组调用sendto或类似的socket函数将它们发送到网络上。关键点发送的触发是异步的。你调用写函数只是把数据交给了lsquic真正的网络发送动作发生在lsquic调用你的回调时。你需要确保你的回调函数能高效地将数据包送出避免阻塞。6. 避坑指南与实战心得最后分享几个我在实际使用lsquic过程中踩过的坑和总结的经验希望能帮你少走弯路。第一个坑定时器与事件循环的集成。lsquic内部有很多定时器超时重传、PING保活、空闲超时等。它通过ea_get_event如果实现或通过lsquic_engine_process_conns()的返回值来告诉你下一个内部事件最早在多少毫秒后发生。你必须重视这个返回值一种常见的做法是在你的主事件循环中使用一个高精度的定时器如timerfd将这个返回值设置为定时器的超时时间。这样既能保证lsquic的内部任务得到及时处理又避免了不必要的空转忙等待节省CPU。第二个坑发送回调的性能。ea_packets_out回调可能会被高频调用尤其是在高速传输时。这里的实现一定要高效。避免在回调中进行复杂的逻辑或内存分配。通常的做法是直接遍历数据包数组调用sendmmsg如果系统支持进行批量发送这比循环调用sendto性能好得多。同时要做好EAGAIN/EWOULDBLOCK错误的处理如果socket发送缓冲区满需要将未发送的数据包妥善管理起来等待下次可写事件。第三个坑内存管理。lsquic的很多API如读数据需要你提供缓冲区。你需要清楚这些缓冲区的生命周期。例如lsquic_stream_read()读出的数据其内存是由lsquic内部管理的你不应该释放它。而通过lsquic_stream_write()写入的数据在你提供的回调函数返回之前缓冲区必须保持有效。仔细阅读文档理解每一处内存所有权ownership的归属。第四个心得从默认配置开始逐步调优。lsquic_engine_settings里的参数多达几十个一开始很容易让人头晕。我的建议是除非你有明确的需求否则先从默认配置开始。让你的应用跑起来建立起基本的连接和数据传输。然后通过日志和监控观察是否有性能瓶颈如吞吐上不去、延迟高。再针对性地去调整参数比如增大流量控制窗口、调整PING间隔、尝试不同的拥塞控制算法等。lsquic的日志系统很强大编译时开启调试日志能在开发阶段帮你快速定位问题。第五个心得好好设计你的上下文Context。ea_conn_ctx和lsquic_stream_ctx是你把自己的业务状态和lsquic对象关联起来的桥梁。比如你可以把一个代表用户会话的结构体指针作为Connection的上下文再把其中某个具体请求的处理状态作为Stream的上下文。这样在回调函数里你就能快速定位到对应的业务数据而不需要自己去维护复杂的映射表。刚开始接触lsquic时可能会觉得它比直接使用TCP socket编程要复杂不少。但一旦你理解了它的回调驱动模型并成功地将它集成到你的事件循环中你就会发现它带来的收益是巨大的更低的连接延迟、更好的多路复用能力、以及对移动网络环境的天然友好。更重要的是你对自己的网络栈有了更深层次的控制力。希望这篇架构解析和概念入门能为你开启lsquic实战之旅打下一个扎实的基础。接下来的系列文章我们会深入代码一步步搭建一个可运行的客户端和服务端示例。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2415748.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!