http3客户端实现
继上一篇实现HTTP长连接的HTTP/1.1连接池实现已经过去半年
这一段时间发现了HTTP/1.1连接池无法解决的致命缺点:
在突发高并发场景下,客户端由于需要额外建连很容易退化成短连接
在超高突增并发下和短链接几乎无异,我司由于业务特性就是这种情况
即使突增并发不高,只要并发持续增长超过一定比例后,由于新建连接的tcp握手+ssl握手需要耗费大量的cpu,造成客户端和服务端的cpu不稳定,叠加新建连接的tcp慢启动因素带来了更多的超时。
而HTTP/1.1一旦超时就需要断开连接重新建连,更多的建连带来了更多的超时,又带来了更多的建连,就造成了雪崩效应
HTTP/1.1压测
以下是一次模拟业务的简单压测,请求包使用了业务10K左右的post请求,响应包就只是“hello world”,qps缓慢增长以测试极限处理性能,可以发现在达到某个点以后,qps骤降,耗时突增


并且多次压测的结果均不一致(有时候3000qps就会过载),共同点是一旦发生过载,就立刻雪崩断崖下跌,并且大量耗费服务端(下图的go_server)的CPU

服务端不停地刷日志,提示TLS建连失败 reset by peer,因为我设置了客户端超时不进行四次挥手,而是直接reset以减少timewait

基于ngtcp2和nghttp3的实现
实际上在前文实现HTTP长连接中已经对HTTP的已知版本进行过调研了
当时的结论是,在客户端和服务端需要通过外网的长链接方案,要么简单使用HTTP/1.1,要么一步到位使用HTTP/3
当时对HTTP/1.1的雪崩其实有所预计,但是由于某个业务对接其他公司服务端的一些额外限制(单连接最多使用100次),更早的恶化了雪崩情况
因此HTTP/3的开发也就排入日程中了,根据http3的wiki,由于22年6月份才正式标准化HTTP/3,因此能选择的基础库余地也不多,最终选定了ngtcp2+nghttp3的方案
该方案通用性最好,并且在https://github.com/ngtcp2/ngtcp2/blob/release-1.9/examples/client.cc中有一个功能相当完备的实现,只需要在这个基础上修改一下即可
简化demo
由于这个demo近万行,核心代码文件client.cc也有3000行,我实现了一个简化demo在https://github.com/tedcy/nghttp3_simple_demo
编译步骤在README.md中
在https://github.com/tedcy/nghttp3_simple_demo/tree/master的主分支下
我提供了一个最简单实现,优化了一半的代码,去除了例如连接迁移等功能,并且大幅度的精简了配置项,从50项精简到了10多项核心配置
在https://github.com/tedcy/nghttp3_simple_demo/tree/epoll_dev的分支下
实现去除libev依赖
在https://github.com/tedcy/nghttp3_simple_demo/tree/epoll_with_taf_http_dev的分支下
对接taf_http这个请求序列化和响应反序列化的库
为接入我司项目进行准备
复用连接来投递请求
demo是写死的Client类在连接成功后就发出全部请求,然后退出,这是一次性的,没有复用连接
参考When can the client open new streams?中创建quic流的指导
我在Client类中引入请求队列,来复用连接通过查询请求队列持续的发起请求,详见check_pushed_requests函数
BoringSSL和OpenSSL的冲突问题
ngtcp2是依赖BoringSSL来实现TLS1.3的,而我司C++的taf框架是基于业界标准OpenSSL的
虽然BoringSSL是OpenSSL的超集,对外暴露的符号都是兼容的,在简单的编译场景下可以直接替换,但是私有符号不同还是会造成冲突:
taf框架提供给业务的是静态库例如libtaf_by_openssl.a,里面包含了OpenSSL的私有符号
业务部门会依赖taf框架去编译静态库或者动态库对内或对外使用,比如某个编译产物libxxx.so库,它依赖的私有符号也是基于OpenSSL的,如果和libtaf_by_boringssl.a一起编译,会大量报错undefine reference
graph LR
libtaf_by_openssl.a[原库:libtaf_by_openssl.a] --> OpenSSL[含有OpenSSL私有符号]
libtaf_by_boringssl.a[替换为新库:libtaf_by_boringssl.a]
libtaf_by_boringssl.a --> BoringSSL[含有BoringSSL私有符号]
基于libtaf_by_openssl.a编译的libxxx.so -->|依赖OpenSSL私有符号| 报错[编译错误:Undefined Reference]
基于我所知的编译和链接原理,只能使用动态库隔离解决这个问题
直接依赖的动态库隔离
ngtcp2在链接BoringSSL生成libngtcp2.so动态库的时候,很贴心的使用了-fvisibility=hidden隐藏了全部的符号,并且使用gcc宏导出了所需的符号,例如
1 |
|
编译并查看符号:
1 | ~ g++ -shared -fPIC -fvisibility=hidden -o libexample.so example.cpp |
hidden_function符号在动态库被加载的时候不会被引入到全局符号表中,甚至使用dlsym拿都无法提取
1 |
|
编译并运行:
1 | ~ gcc dlsym.c -ldl -lexample -L. |
但是这种方式会让业务的二进制强制新增一个动态库依赖,在一些特殊场景下,业务的二进制无法额外依赖某些动态库(该场景也不需要http3功能),因此我最终使用了可插拔的方案
基于dlopen的可插拔动态库隔离
核心思想是只导出需要使用的函数:
initHttp3
:用于初始化整个http3库,传入一些配置项createHttp3Conn
:用于创建http连接
基于C++的动态库可以全程使用接口方式来定义需要导出的符号,和需要导入给动态库使用的符号,初版实现在https://github.com/tedcy/nghttp3_simple_demo/tree/epoll_with_taf_http_dev_dynamic_build_hide_boringssl
大致的核心代码如下:
demo接口头文件
在这个接口头文件中,只声名了接口,供动态库和主程序一起使用
1 | // http3_interface.h |
demo动态库
动态库实现了Http3连接,可以把请求投递到连接中进行处理
1 | // http3_library.cpp |
demo主程序
1 | // http3_client.cpp |
编译执行:
1 | ~ g++ -std=c++11 -shared -fPIC http3_library.cpp -o libhttp3.so |
core问题
后续实现多连接方案的时候,遇到了ucontext_x86协程库,C++单例模式多协程使用dlopen时的coredump问题
这个问题查了整1周才发现,从现象上看疑似爆栈写坏内存,无法解决,只能规避
最后不得已,把dlopen部分放到服务器的初始化线程中解决,也就是HTTP3Lib
类不能随时初始化,必须预先初始化,再进行使用
小结
基于dlopen的可插拔动态库隔离,相对于直接依赖的方式,主程序对动态库的耦合更少,在动态库缺失的时候,依然可以降级到不支持HTTP/3的版本
再通过共享接口头文件的技术,可以让C++的主程序和动态库的互相调用相对于C没有那么痛苦,需要一个一个dlsym导出各个符号
perHost连接池实现
每个Host对应一个连接池,这在HTTP/1.1是很容易理解的事情
但是在HTTP/3,由于QUIC已经抽象出Stream来连接复用,并且不存在HTTP/2.0的TCP队头阻塞问题,因此应该只需要一条QUIC连接即可
主流实现方案
大部分的HTTP/3实现也是这样的,以下是各语言的选连接核心逻辑:
java:https://github.com/ptrd/flupke/blob/master/src/main/java/tech/kwik/flupke/impl/Http3ConnectionFactory.java#L38
go:https://github.com/quic-go/quic-go/blob/master/http3/transport.go#L269
python:https://github.com/aiortc/aioquic/blob/main/examples/http3_client.py#L388
那么为什么我还要实现perHost连接池呢?
这是因为我的HTTP客户端遵循taf框架历史包袱的语义,Client是一个单例,使用以下方式发起请求
1 | Http::getInstance()->get("www.baidu.com"); |
而主流的HTTP实现都是可以自己创建Client(go的话是可以指定非默认Transport),在复杂问题上可以自己创建多个Client自己管理
1 | //以下代码每个Client各能创建一条quic连接 |
我的实现方案
作为单例实现,我必须提前于业务同学考虑,对于单个Host,是否有必要创建多条连接?
答案是有必要的,这有两个好处:
假设对端单个Host有非常多IP的时候(实际业务有遇到过DNS解析出100多个IP的情况)
多开连接有助于负载均衡
QUIC的服务端对单条QUIC会有各种各样的限制
在握手阶段,通过TLS扩展的EncryptedExtensions消息发送,完整定义在https://www.iana.org/assignments/quic/quic.xhtml#quic-transport
例如initial_max_streams_bidi指定了双向流的最大并发数,在大部分的服务端实现中默认为100,这显然算不上高并发
由于没有什么作业可以抄了,我的设计思路是遵循HTTP/1.1连接池的核心思想:LRU,最近使用的连接是最活跃的,不会因为空闲断开连接,优先使用
实际实现上可以大量复用HTTP/1.1连接池代码,抽象出连接类,把HTTP1.1的连接看作是并发为1的连接,把HTTP3.0的连接看作是并发为N的连接
每次取连接从队头取
取到连接后当并发耗尽时,将其从连接池中取出不再使用
每个请求处理完毕后,将其重新加入连接池队头
每个连接上已有并发的定义是:已经打开的stream + 进入这条连接待处理队列的req总和
压测结果
模拟业务场景300ms后端处理时间(go的服务处理逻辑sleep 300ms,限制单条连接100并发限制),调用链路:
1 | 压测服务-》Http3Proxy服务-》go服务 |
压测模型:

初版单服务只单连接(100并发限制):
Http3Proxy服务超过300并发以后Proxy服务队列积压满,过载,几乎没有成功的qps
Http3Proxy服务主调go服务成功率21%
这是一个20秒为粒度的统计图,因此均值qps大约是,\(6715/20 = 335.75\),后端耗时300ms,因此并发是\(335.75 * 0.3 = 100.725\),和预计大致相当
新版单服务可用多连接(无并发限制):
Http3Proxy服务被调9700的qps
Http3Proxy服务主调go服务成功率100%
最大并发\(10000 * 0.3 = 3000\),大约开启了30条连接
后续优化方案
当go服务不设置延迟返回300ms的时候,单连接版本实际上可以达到11000的qps,比多连接的版本性能更好
这是因为多连接的实现方案还不够好,从日志观察到,最大并发3000的压测,预期拉起30条100并发的连接,实际上拉起了大概100条连接,造成了性能损失
这是因为对并发的定义:已经打开的stream + 进入这条连接待处理队列的req总和
这会造成大量并发同时进入客户端后,由于连接还未建立,大量的req积压,造成创建大量新连接,实际上当连接创建以后,req可以很快消化完毕,不需要那么多连接
可以引入探测性扩容的思想:
限制每秒的新建连接数
例如固定基数10条新连接 + 百分比20%的比例
固定基数用于冷启动的时候快速拉起,百分比在较大基数时起作用
每秒检查扩容效果,当扩容无效时,禁止一段时间的扩容
那么在被限制时,每个连接的待处理队列会进入更多的req
可以使用两个并发定义池子,严格定义的池子以已经打开的stream + 进入这条连接待处理队列的req总和
来找连接,宽松定义的池子用已经打开的stream
找
伪代码:
1 | //streams + reqs是更严格的池子,连接更少 |
两个细节:
- 宽松的池子显然不能再用MRU来取连接,否则最近使用的连接会塞满请求,这里要轮询
- 限流器要用基于时间戳计数的版本,并且通过传入不同的rate从而计算新的可用时间戳,来控制限流器qps的平滑更新
深入理解QUIC
QUIC基础
QUIC的基本功能和位置
QUIC层结构

UDP以UDP四元组(源IP地址、源端口号、目的IP地址、目的端口号)标识,其中IP地址是为了在网络中传递时寻址,端口号是为了在主机上复用时派发。
连接(Connection)是最基本的QUIC实例,一个连接代表客户端和服务器之间的单次会话。一个QUIC连接可以使用多个连接标识识别。
流(Stream)是QUIC提供给应用层的有序字节流抽象,在QUIC协议内部以流标识(Stream ID)区分,在QUIC报文中封装为STREAM帧。
PMTU探测
在互联网中,客户端和服务器之间的网络路径一般会穿越多个设备,设备接口的MTU(Maximum Transmission Unit,最大传输单元)可能各不相同,如果报文超过了设备接口的MTU值,就会被IP层分片或者丢弃。
封装QUIC的IP数据包需要设置DF(Don't Fragment,不分片)位,这是为了避免分片带来的传输效率和传输可靠性问题。
对于QUIC来说,一个IP数据包包含一个UDP数据报,一个UDP数据报包含一到多个QUIC报文,选择一个UDP数据报载荷的大小对于传输来说很重要。如果使用过小的UDP数据报长度,就会浪费网络资源,无法达到最优吞吐量;如果使用过大的UDP数据报长度,会增加丢包的可能性,也增加了重传的压力,甚至可能导致头部阻塞。
PMTU探测有两种:
- PMTUD(Path MTU Discovery):依赖于ICMP的PTB(Packet Too Big,数据包过大)消息来确定路径的MTU。
- DPLPMTUD(Datagram Packetization Layer Path MTU Discovery):更通用,主要依赖于黑洞探测(也就是发包以后超时丢失),也可以选择依赖ICMP的PTB消息。
PMTUD
中间路由器收到带有DF位的IP数据包,如果IP数据包的大小大于路由器出口的MTU,则生成ICMP PTB消息。在PTB消息中包含了下一跳MTU和原始报文的引用。发送方收到PTB消息后,验证原始报文确实是自己发送的报文,且原始报文已确认丢失,则确认是合法PTB报文。然后根据下一跳MTU调整探测报文的大小,直到收到对端的确认。
PMTUD的例子下图所示,发送方C先以本地出接口MTU大小5000发送一个探测报文。路径MTU值是取所有段上最小的MTU值,所以不可能超过本地出接口的MTU。
R1收到该报文后发现下一跳MTU是4000,所以生成一个ICMP PTB消息,指示下一跳的MTU值。
发送方C收到PTB报文后更改探测报文大小为4000字节后发送。以此类推,直到确定该路径的最小MTU值2000。
DPLPMTUD
DPLPMTUD的例子发送方C从BASE_PLPMTU大小的报文开始探测,这个探测报文得到了接收方S的确认,PLPMTU即设置为1200;
然后使用下一个MTU得出下一个PROBE_SIZE为2000,这个探测报文仍然没有超过路径上任何一个MTU,也得到了确认,PLPMTU即设置为2000;
继续增加探测报文大小到下一个PROBE_SIZE值3000,这个探测报文因为超过了R1的下一跳MTU,R1返回了PTB报文。所以,最终探测到的PLPMTU为2000。
ngtcp2的实现
实现细节都在https://github.com/ngtcp2/ngtcp2/blob/v1.11.0/lib/ngtcp2_pmtud.c中了
预定义探测序列
代码中硬编码了分层探测的MTU值列表(已扣除48字节头部开销):
1
2
3
4
5
6static size_t mtu_probes[] = {
1454 - 48, // 日本光纤常见MTU
1390 - 48, // 典型隧道MTU
1280 - 48, // IPv6最小MTU
1492 - 48 // PPPoE MTU
};这些值按从大到小排列,作为探测目标。
初始化筛选
在
ngtcp2_pmtud_new()
中:1
2
3
4
5
6
7
8
9// 跳过所有小于当前max_udp_payload_size 或超过hard_max的值
for (; pmtud->mtu_idx < NGTCP2_MTU_PROBESLEN; ++pmtud->mtu_idx) {
if (mtu_probes[pmtud->mtu_idx] > pmtud->hard_max_udp_payload_size) {
continue;
}
if (mtu_probes[pmtud->mtu_idx] > pmtud->max_udp_payload_size) {
break; // 找到第一个有效探测目标
}
}
初始化时直接跳过无效值,确保首次探测值既在允许范围内,又比当前MTU大。
探测推进策略
在
pmtud_next_probe()
中实现核心调整逻辑:1
2
3
4
5
6
7
8
9
10
11
12
13++pmtud->mtu_idx; // 指向下一个候选MTU
for (; pmtud->mtu_idx < NGTCP2_MTU_PROBESLEN; ++pmtud->mtu_idx) {
// 条件1: 必须大于当前有效MTU
// 条件2: 不能超过hard上限
// 条件3: 必须小于已记录的最小失败MTU(若有)
if (mtu_probes[pmtud->mtu_idx] <= pmtud->max_udp_payload_size ||
mtu_probes[pmtud->mtu_idx] > pmtud->hard_max_udp_payload_size) {
continue;
}
if (mtu_probes[pmtud->mtu_idx] < pmtud->min_fail_udp_payload_size) {
break; // 找到有效候选
}
}该循环会跳过以下情况:
- 候选MTU ≤ 当前已确认的最大有效MTU
- 候选MTU超过协议规定的绝对上限 (
hard_max
) - 候选MTU ≥ 之前记录的最小失败MTU值(避免无效重试)
探测反馈处理
成功时 (
ngtcp2_pmtud_probe_success()
)1
2
3
4
5// 提升当前最大有效MTU
pmtud->max_udp_payload_size =
ngtcp2_max(pmtud->max_udp_payload_size, payloadlen);
// 推进到下一个更大的候选MTU
pmtud_next_probe(pmtud);若探测包成功到达,立即更新最大有效值,并尝试更大的MTU。
超时失败时(
ngtcp2_pmtud_handle_expiry()
)1
2
3
4
5// 记录当前探测值为最小失败MTU
pmtud->min_fail_udp_payload_size =
ngtcp2_min(pmtud->min_fail_udp_payload_size, mtu_probes[pmtud->mtu_idx]);
// 推进到更小的候选MTU
pmtud_next_probe(pmtud);当连续发送3个探测包(
NGTCP2_PMTUD_PROBE_NUM_MAX
)均超时,标记该MTU不可用,并尝试更小的值。
终止条件
当遍历完所有预定义的
mtu_probes
值时,探测结束:1
2
3int ngtcp2_pmtud_finished(ngtcp2_pmtud *pmtud) {
return pmtud->mtu_idx >= NGTCP2_MTU_PROBESLEN;
}
ngtcp2的日志验证
编译https://github.com/tedcy/nghttp3_simple_demo/tree/epoll_with_taf_http_dev_dynamic_build_hide_boringssl
并运行访问本地的go_server
1 | ~ ./test localhost:4433 2>&1|grep -i 'mtu\|udp\|QUIC handshake' |
可以看到,quic在建连以后继续发包验证MTU,最后定到了1444,也就是mtu_probes的最后一项1492 - 48
QUIC连接
握手
TLS1.3 首次握手
参考这个:https://zhuanlan.zhihu.com/p/686461033,我进行简单总结:
TLS1.2虽然足够安全,但是需要四次握手,主要是为了协商算法,并且多次交互用证书协商密钥
因此出现了TLS1.3:只支持唯一的 DH 算法协定密钥,不再需要协商算法,密钥由一个随机数和单次交互的签名算法生成
因此服务端在发送了证书链Certificate
报文后还需要发送Certificate Verify
报文来校验密钥,总流程如下(其中红色箭头表示传输的都是加密数据):

简单来说:
ClientHello和Server Hello包含了各自的签名所需随机数
EncryptedExtensions消息携带可以加密的扩展
Certificate消息包含服务器的证书或者证书链
CertificateVerify消息中是签名的算法和签名的内容
简化了密钥协商后,TLS 握手阶段,服务端需给客户端发送证书(含公钥),但服务器的证书是“公开”信息,任何人都能拿到。
光有证书根本不足以证明对方真的拥有对应证书的私钥,也就无法证明该方“真的是”证书所描述的目标服务器。
为此TLS 协议设计了Certificate Verify报文。
要求服务端使用自己服务器私钥对指定内容签名。客户端接收后,用证书里的公钥解密、核实。
若解密校验通过,即证明该服务端真的拥有证书相应的私钥,从而确认服务端身份确实为该证书的合法拥有者。
Finished消息用于校验握手内容完整性和有效性,确保双方握手数据一致
使用传输秘钥(握手过程中生成的密钥)对之前所有握手消息的Hash进行HMAC计算。
1
Finished = HMAC(handshake_traffic_secret, Hash(握手过程中所有之前的消息))
QUIC 首次握手
总览如下:
细节如下:

客户端初始报文
客户端首先发送一个初始报文给服务器,请求建立连接。初始报文中,首字节中第一位是首部格式位,长首部报文为1;第二位是QUIC固定位,值固定为1;第三位和第四位是长首部报文类型位,初始报文类型是00;第五位和第六位是保留位,固定为0;第七位和第八位是报文编号长度字段,因为这是初始报文编号空间的第一个报文,所以报文编号为0,这两位就是00。综上所述,首字节的值为二进制11000000,对应十六进制0xc0,注意这是明文值,后四位会被首部保护变成密文。接下来的32位是版本字段,版本1使用值0x1;再往下8位是目标连接标识长度(以字节为单位),目标连接标识是客户端随机选择的值,简单起见这里记为x,用来生成加密密钥和验证服务器的回复;再后面是8位的源连接标识长度(以字节为单位),这是源连接标识的字节长度,源连接标识是客户端为该连接选定的标识,为简单起见这里记为c1;首次建立连接没有令牌信息,所以令牌长度经过变长编码后占8位,值为0;接下来的报文长度字段指明了这个初始报文剩余部分的字节长度,包含报文编号和负载加密后的长度;因为最后的报文编号是初始报文编号空间的第一个报文,所以经过变长编码后占8位,值为0。
客户端的第一个初始报文包含一个CRYPTO帧,该帧的偏移从0开始,内容是TLS提供的ClientHello消息,其中包含了QUIC的传输参数,传输参数initial_source_connection_id设置为c1。服务器收到客户端的初始报文后,需要按照限制放大攻击的长度回复。为了防止服务器没有足够的长度回复初始报文,另外也为了探测路径是否支持QUIC最低的MTU要求,这个初始报文需要填充至1200字节,所以增加了PADDING帧。CRYPTO帧和PADDING帧作为报文的负载受到AEAD的保护。
这个初始报文使用客户端初始报文的目的连接标识
服务端初始报文
收到客户端的初始报文后,服务器回复一个初始报文和数个握手报文,还有可能有一个1-RTT报文,但在对客户端一无所知的情况下发送1-RTT报文是不安全的,所以本例中只有一个初始报文和一个握手报文。
服务器的初始报文中首字节仍然是0xc0:版本号也是1;目的连接标识是客户端初始报文中的源连接标识c1;源连接标识则是服务器选择的值s1;服务器不能在初始报文中发送令牌,所以这里的令牌长度必须是0;接下来是这个初始报文剩下部分的字节长度;作为服务器在初始报文编号空间发送的第一个初始报文,这里的报文编号是0;负载部分包含CRYPTO帧和ACK帧,CRYPTO帧携带了TLS的ServerHello消息,偏移从0开始,ACK帧确认了客户端的初始报文。
这个服务器初始报文使用客户端初始报文的目的连接标识x衍生的密钥保护。
服务器握手报文
服务器的握手报文首字节是0xf0,跟初始报文的首字节只有报文类型不同;版本号也是1;目的连接标识也是c1,源连接标识也是服务器选择的值s1;长度也是报文剩余字节长度;作为握手编号空间的第一个报文,报文编号也是0。
报文负载中的CRYPTO帧携带了TLS的几个消息:EE指的是EncryptedExtensions消息,CERT指的是Certificate消息,CV指的是CertificateVerify消息,FIN指的是Finished消息。QUIC将自己的传输参数传递给TLS, TLS将其包含在EncryptedExtensions消息中,这其中包含了用于验证服务器初始源连接标识的传输参数initial_source_connection_id,以及用于验证客户端初始目的连接标识的传输参数original_destination_connection_id。
这个握手报文使用TLS协商的握手密钥衍生的密钥保护。
客户端确认初始报文
客户端收到服务器的初始报文后,还需要回复一个初始报文以确认服务器的初始报文。这个初始报文受第一个初始报文一样的密钥的保护,但目的连接标识使用服务器选择的值s1,报文编号是1,首部中其余字段跟第一个初始报文一样。这个初始报文携带了一个ACK帧,确认了服务器报文编号为0的初始报文。
客户端握手报文和1-RTT报文
客户端收到服务器的握手报文后,验证完服务器的证书和Finished消息,就会发送握手报文,其中携带了TLS提供的Finished消息。除此之外,这个握手报文中还要确认服务器的握手报文,所以还包含一个ACK帧,确认了服务器报文编号为0的握手报文。
此时客户端已经从初始报文中得到加密参数并计算出密钥,通过握手报文中Certificate消息和CertificateVerify消息验证了服务器的身份,且通过握手报文中的Finished消息验证了整个握手过程,可以发送1-RTT报文了。
服务器握手报文和1-RTT报文
服务器收到客户端的握手报文后,需要确认该握手报文,所以还需要回复一个报文编号为1的握手报文,携带了确认报文编号为0的ACK帧。服务器验证完客户端的握手报文,即客户端的Finished消息,认为握手完成并且已确认,在1-RTT报文中发送HANDSHAKE_DONE帧,通知客户端握手流程已经结束。
断开连接
空闲超时
如果一个连接上长时间没有网络活动,既没有发送报文的需求,也没有接收到对端的报文,连接就会因为空闲超时而关闭。
空闲超时时间是由连接建立时QUIC传输参数max_idle_timeout确定的,空闲超时时间是两端选择的max_idle_timeout的最小值。
立即关闭
发送CONNECTION_CLOSE帧立即关闭连接。
端点发送CONNECTION_CLOSE帧后就进入了关闭状态
关闭状态下不能再发送出CONNECTION_CLOSE帧以外的其他帧,如果收到了对端的报文,就使用包含CONNECTION_CLOSE帧的报文回应。
端点接收到CONNECTION_CLOSE帧后就进入了排空状态
不能再发送任何报文,也不能回复任何报文,可以删除1-RTT密钥了。被动关闭的端点不经过关闭状态,直接进入排空状态。
无状态重置
连接建立后,分发新的连接标识时,在NEW_CONNECTION_ID帧中携带连接标识对应的无状态重置令牌,连接标识c2对应无状态重置令牌token_c2,连接标识s2对应无状态重置令牌token_s2。
如果连接的一端因为某些原因丢失了连接状态,收到对端发送的报文就可以回复状态重置报文。这可以让对端尽快清理旧的连接、建立新的连接。
实现
ngtcp2的example里面,ngtcp2_callbacks的recv_stateless_reset默认是不填的,也就是不实现无状态重置报文
quic-go的quic协议栈是支持无状态重置的,https://github.com/quic-go/quic-go/blob/v0.49.0/transport.go#L83
1 | type Transport struct { |
但是github.com/quic-go/quic-go/http3
创建http3客户端的时候只能操作quic.Config,无法操作quic.Transport,因此需要直接修改quic-go源码才能加上无状态重置
1 | func main() { |
QUIC收发包
流控
流控是保护端点,而拥塞控制则是保护网络。
在发送方发送太快的情况下,接收方的缓存可能被占满,接收方只能丢弃后续收到的报文,白白浪费了网络资源,这一般是通过流控解决的。流控还能防止发送方过量消耗接收方的缓存,影响其他网络连接,甚至导致接收方崩溃。
TCP的流控机制是用滑动窗口实现的:
1 | No.| Direction | Flags | Window(RV) | Payload | Note |
不同于TCP的滑动窗口流控机制,QUIC是基于offset限制的流控,由接收方发送总字节数的限制,发送方遵循该限制发送流数据。
假设初始状态:
- 接收方的流最大接收数据上限为 5000 字节;
- 发送方目前已发送数据:4000 字节
步骤 | 方向 | 帧类型 | offset值 | 数据长度(len) | 当前流控上限 | 状态说明 |
---|---|---|---|---|---|---|
1 | 发送方→接收方 | STREAM | 4000 | 1000 | 5000 | 发送数据后达到流控上限,后续数据暂停发送 |
2 | 发送方→接收方 | STREAM_DATA_BLOCKED | 5000 | - | 5000 | 告知对端发送方已达到流控上限,无法再发送数据 |
3 | 接收方→发送方 | (暂无动作) | - | - | 5000 | 应用暂未读取数据,缓冲区未释放 |
4 | 发送方→接收方 | STREAM_DATA_BLOCKED (周期发送/重传) | 5000 | - | 5000 | 周期性告知对方仍处于流控阻塞状态,避免连接超时 |
5 | - | 接收方应用读取数据 | - | - | 5000→8000 | 应用层读取已接收的数据,释放3000字节缓冲区空间 |
6 | 接收方→发送方 | MAX_STREAM_DATA (+ACK帧合并发送) | - | - | 8000 | 告知发送方流控上限提高,可以继续发送数据 |
7 | 发送方→接收方 | STREAM | 5000 | 2000 | 8000 | 流控限制解除,发送方继续发送后续数据 |
8 | 发送方→接收方 | STREAM | 7000 | 1000 | 8000 | 继续发送更多数据,逐步接近新流控上限 |
9 | 接收方→发送方 | ACK | - | - | 8000 | 确认数据已收到,告知发送方继续正常通信 |
连接的流控也是同理,但是使用MAX_DATA
和DATA_BLOCKED
帧
并发数流控
初次以外,quic还存在最大并发数的流控
连接级别的流控在连接建立期间,通过传输参数initial_max_data通知对端初始连接级别流控限制,通过传输参数initial_max_streams_bidi(接收方创建的双向流总数)、initial_max_streams_uni(接收方创建的单向流总数)通知对端可以创建的相应流的总数。
在连接建立后,通过MAX_STREAMS帧增加打开流的总数量限制。单向流和双向流的总数分别控制,单向流使用帧类型为0x13的MAX_STREAMS帧,而双向流使用帧类型为0x12的MAX_STREAMS帧。
因为流的总数限制包含了关闭的流,所以随着流的关闭,流的总数量限制需要随之增加,一般是在流关闭后增加。
实现
在ngtcp2的example里面,ngtcp2_callbacks的extend_max_streams_bidi标识着对端更新了流上限,可以在此时发起新的双向流了
https://github.com/ngtcp2/ngtcp2/blob/v0.15.0/examples/client.cc#L447
1 | int extend_max_streams_bidi(ngtcp2_conn *conn, uint64_t max_streams, |
在我的http3客户端实现里面https://github.com/tedcy/nghttp3_simple_demo/blob/epoll_with_taf_http_dev_dynamic_build_hide_boringssl/client.h
request被提交到quic的client里面
1 | void push_request(shared_ptr<taf::TC_HttpRequest> &req) { |
随后通过在epoll的空闲时间内,尝试使用requests_
创建新流,来复用创建流的逻辑:
只要有新增的requests_
和对端通过MAX_STREAMS帧更新了最大流限制,都尝试创建流
流关闭
正常关闭
流的发送方在STREAM帧中设置FIN位为1,表示该流上数据已发送完毕,一般正常的流关闭都采用这种方式
异常关闭
流的发送方在流上发送RESET_STREAM帧,表示终止发送
流的接收方也可以主动请求关闭流,一般是由于应用出现错误,通知QUIC关闭流的接收,这时接收方通过发送STOP_SENDING帧告诉发送方不会再读取流上的数据,并在应用层错误码中携带具体原因。发送方应该回复RESET_STREAM帧,携带收到STOP_SENDING帧中的应用层错误码,并告知接收方当前发送的最大数据偏移,这样两端可以对数据发送和接收有一致的状态,对于连接级别的流控也有一致的处理
附录
HTTP3细节实现
类图
classDiagram
class TC_HttpConnKey {
string proxyAddr
int proxyPort
string targetAddr
int targetPort
bool isHttps
}
class HttpConnInterface {
+uint64_t getId()
}
class Timer {
+pure virtual void onTimeout()
}
class HttpAsyncRequstInterface {
+pure virtual void onRsp()
+pure virtual void onException()
}
class Http1AsyncRequst {
+void BindConn(Http1Conn*)
}
class Http1Conn {
-shared_ptr~Http1AsyncRequst~ reqPtr
+void setReqPtr(shared_ptr<Http1AsyncRequst>)
}
class Http1ConnPool {
+void asyncGetConn(TC_HttpConnKey, shared_ptr~Http1AsyncRequst~)
+void idleFunc()
-map<TC_HttpConnKey, TC_LRU<unordered_map, uint64_t>> pool
}
class Http3AsyncRequst {
+void BindConn(Http3Conn*)
}
class Http3AsyncRequstInterface {
+int64_t stream_id
}
class Http3Conn {
-list~shared_ptr~Http1AsyncRequst~~ reqPtrs
+Http3Conn(Epoller, TimeoutQueue)
+void push_requests(shared_ptr~Http3AsyncRequst~)
+void check_pushed_request()
}
class Http3ConnPool {
+void asyncGetConn(TC_HttpConnKey, shared_ptr~Http3AsyncRequst~)
+void idleFunc()
-map~TC_HttpConnKey, uint64_t~ pool
}
class TimeoutQueue {
+void push_timer(shared_ptr~HttpTimer~ timer)
}
class EventLoop {
-Epoller poll;
-TimeoutQueue q;
+void run()
}
class HttpAsync {
+void doAsyncHttp1Request()
+void doAsyncHttp3Requst()
}
Http3ConnPool ..> Http3Conn
Http3Conn ..> Http3AsyncRequst
Http3Conn ..|> HttpConnInterface
Http3AsyncRequst --|> HttpAsyncRequstBase
Http3AsyncRequst --|> Http3AsyncRequstInterface
Http1ConnPool ..> Http1Conn
Http1Conn ..> Http1AsyncRequst
Http1Conn ..|> HttpConnInterface
Http1AsyncRequst --|> HttpAsyncRequstBase
HttpAsyncRequstBase ..|> Timer
HttpAsyncRequstBase ..|> HttpAsyncRequstInterface
Http3Conn ..|> Timer
EventLoop ..> Timer
EventLoop ..> TimeoutQueue
Http3Conn ..> TimeoutQueue
HttpAsync ..> EventLoop
HttpAsync ..> Http1ConnPool
HttpAsync ..> Http3ConnPool
隔离HTTP1Req类和HTTP3Req类的独有成员
由于HTTP1和HTTP3的Conn都继承自HttpConnectionInterface这个Interface,HttpConnectionInterface的push_requests函数只能接受最基础的HttpAsyncRequstInterface
因此传入的Http3AsyncRequst类在变成HttpAsyncRequstInterface接口类后,需要通过dynamic_cast强制转换成Http3AsyncRequstInterface类,以获取到stream_id这个对HTTP1的Req类不透明的成员
细化HTTPReq和HTTPConn类图
classDiagram
class HttpConnectionInterface {
<<interface>>
+push_requests(shared_ptr~HttpAsyncRequestInterface~ req)
}
class HttpAsyncRequestInterface {
<<interface>>
+onRsp()
+onException()
}
class HttpAsyncRequestBase {
+onRsp()
+onException()
}
class Http1AsyncRequest {
+BindConn(Http1Conn*)
}
class Http3AsyncRequestInterface {
<<interface>>
+int64_t stream_id
+static Http3AsyncRequestInterface* cast(req)
}
class Http3AsyncRequest {
+BindConn(Http3Conn*)
}
class Http1Conn {
+push_requests(shared_ptr~HttpAsyncRequestInterface~ req)
}
class Http3Conn {
+push_requests(shared_ptr~HttpAsyncRequestInterface~ req)
+processRequests()
}
HttpConnectionInterface <|-- Http1Conn
HttpConnectionInterface <|-- Http3Conn
HttpAsyncRequestInterface <|.. HttpAsyncRequestBase
HttpAsyncRequestBase <|-- Http1AsyncRequest
HttpAsyncRequestBase <|-- Http3AsyncRequest
Http3AsyncRequestInterface <|.. Http3AsyncRequest
Http3Conn ..> Http3AsyncRequestInterface
note for HttpConnectionInterface "push_requests函数只能接受HttpAsyncRequestInterface"
note for Http3Conn "push_requests函数内通过dynamic_cast<br>获取Http3AsyncRequestInterface,以访问Http3独有成员"
note for Http3AsyncRequestInterface "包含Http3独有成员,如stream_id"
接口头文件核心代码
1 | // 基础异步请求接口 |
动态库核心代码
1 | // Http3 连接类 |