QUIC协议和HTTP3.0技术研究

一、QUIC详解

  tcp具有3次握手、4次挥手、队头阻塞、拥塞控制等特点。现有HTTP2.0基于tcp,速度稍慢。为了解决速度上的问题,Http3.0基于UDP。

1.TCP的缺点和UDP的优点:

  • 基于TCP研发的设备和协议多,兼容困难

  • TCP协议栈是Linux内部的重要部分,修改和升级成本大;UDP改造成本小

  • TCP有队头阻塞和拥塞控制;UDP没有

  • TCP有建立连接和断开连接成本,三次握手和四次挥手;UDP没有

  • QUIC协议是在UDP基础上改造的具有TCP优点的新协议。

  QUIC提高了当前正在使用TCP的面向连接的Web应用程序的性能。它在两个端点之间使用UDP建立多个复用连接来实现此目的。QUIC的次要目标包括减少连接和传输延迟,在每个方向进行带宽估计以避免拥塞`。它还将拥塞控制算法移动到用户空间,而不是内核空间,此外使用前向纠错(FEC)进行扩展,以在出现错误时进一步提高性能。

HTTP3.0又称为HTTP Over QUIC,其弃用TCP协议,改为使用基于UDP协议的QUIC协议来实现。

![在这里插入图片描述]( https://cp777-image.oss-cn-beijing.aliyuncs.com/cp777-image/Typora/watermark ,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dvbGZHdWlEYW8=,size_16,color_FFFFFF,t_70.png)

QUIC协议必须要实现HTTP2.0在TCP协议上的重要功能,同时解决遗留问题,我们来看看QUIC是如何实现的。

2.队头阻塞问题

    队头阻塞 Head-of-line blocking(缩写为HOL blocking)是计算机网络中是一种性能受限的现象,通俗来说就是:一个数据包影响了一堆数据包,它不来大家都走不了。队头阻塞问题可能存在于HTTP层和TCP层,在HTTP1.x时两个层次都存在该问题。
![在这里插入图片描述]( https://img-blog.csdnimg.cn/20200922124452630.png?x-oss-process=image/watermark ,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dvbGZHdWlEYW8=,size_16,color_FFFFFF,t_70#pic_center)

    HTTP2.0协议的多路复用机制解决了HTTP层的队头阻塞问题,但是在TCP层仍然存在队头阻塞问题。TCP协议在收到数据包之后,这部分数据可能是乱序到达的,但是TCP必须将所有数据收集排序整合后给上层使用,如果其中某个包丢失了,就必须等待重传,从而出现某个丢包数据阻塞整个连接的数据使用。多路复用是 HTTP2 最强大的特性 ,能够将多条请求在一条 TCP 连接上同时发出去。但也恶化了 TCP 的一个问题,队头阻塞 ,如下图示:
![在这里插入图片描述]( https://img-blog.csdnimg.cn/20200922135620387.png?x-oss-process=image/watermark ,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dvbGZHdWlEYW8=,size_16,color_FFFFFF,t_70#pic_center)

    HTTP2 在一个 TCP 连接上同时发送 4 个 Stream。其中 Stream1 已经正确到达,并被应用层读取。但是 Stream2 的第三个 tcp segment 丢失了,TCP 为了保证数据的可靠性,需要发送端重传第 3 个 segment 才能通知应用层读取接下去的数据,虽然这个时候 Stream3 和 Stream4 的全部数据已经到达了接收端,但都被阻塞住了。不仅如此,由于 HTTP2 强制使用 TLS,还存在一个 TLS 协议层面的队头阻塞.
![在这里插入图片描述]( https://img-blog.csdnimg.cn/20200922135807354.png?x-oss-process=image/watermark ,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dvbGZHdWlEYW8=,size_16,color_FFFFFF,t_70#pic_center)

    QUIC 的多路复用和 HTTP2 类似。在一条 QUIC 连接上可以并发发送多个 HTTP 请求 (stream)。但是 QUIC 的多路复用相比 HTTP2 有一个很大的优势。

    QUIC 一个连接上的多个 stream 之间没有依赖。这样假如 stream2 丢了一个 udp packet,也只会影响 stream2 的处理。不会影响 stream2 之前及之后的 stream 的处理。这也就在很大程度上缓解甚至消除了队头阻塞的影响。QUIC协议是基于UDP协议实现的,在一条链接上可以有多个流,流与流之间是互不影响的,当一个流出现丢包影响范围非常小,从而解决队头阻塞问题.

3.0RTT 建链

衡量网络建链的常用指标是RTT Round-Trip Time,也就是数据包一来一回的时间消耗。
![在这里插入图片描述]( https://img-blog.csdnimg.cn/20200922124623833.png?x-oss-process=image/watermark ,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dvbGZHdWlEYW8=,size_16,color_FFFFFF,t_70#pic_center)

RTT包括三部分:往返传播时延、网络设备内排队时延、应用程序数据处理时延。
![在这里插入图片描述]( https://img-blog.csdnimg.cn/20200922124649743.png?x-oss-process=image/watermark ,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dvbGZHdWlEYW8=,size_16,color_FFFFFF,t_70#pic_center)

    一般来说HTTPS协议要建立完整链接包括:TCP握手和TLS握手,总计需要至少2-3个RTT,普通的HTTP协议也需要至少1个RTT才可以完成握手。然而,QUIC协议可以实现在第一个包就可以包含有效的应用数据,从而实现0RTT,但这也是有条件的。0RTT 建连可以说是 QUIC 相比 HTTP2 最大的性能优势。那什么是 0RTT 建连呢?这里面有两层含义:

比如上图左边是 HTTPS 的一次完全握手的建连过程,需要 3 个 RTT。就算是 Session Resumption,也需要至少 2 个 RTT。

而 QUIC 呢?由于建立在 UDP 的基础上,同时又实现了 0RTT 的安全握手,所以在大部分情况下,只需要 0 个 RTT 就能实现数据发送,在实现前向加密 的基础上,并且 0RTT 的成功率相比 TLS 的 Sesison Ticket 要高很多。

    简单来说,基于TCP协议和TLS协议的HTTP2.0在真正发送数据包之前需要花费一些时间来完成握手和加密协商,完成之后才可以真正传输业务数据。但是QUIC则第一个数据包就可以发业务数据,从而在连接延时有很大优势,可以节约数百毫秒的时间。

![在这里插入图片描述]( https://img-blog.csdnimg.cn/20200922124817379.png?x-oss-process=image/watermark ,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dvbGZHdWlEYW8=,size_16,color_FFFFFF,t_70#pic_center)

    QUIC的0RTT也是需要条件的,对于第一次交互的客户端和服务端0RTT也是做不到的,毕竟双方完全陌生。

因此,QUIC协议可以分为首次连接和非首次连接,两种情况进行讨论:

首次连接

![在这里插入图片描述]( https://img-blog.csdnimg.cn/20200922125320566.png?x-oss-process=image/watermark ,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dvbGZHdWlEYW8=,size_16,color_FFFFFF,t_70#pic_center)

非首次连接

    前面提到客户端和服务端首次连接时服务端传递了config包,里面包含了服务端公钥和两个随机数,客户端会将config存储下来,后续再连接时可以直接使用,从而跳过这个1RTT,实现0RTT的业务数据交互。客户端保存config是有时间期限的,在config失效之后仍然需要进行首次连接时的密钥交换。

4.前向纠错

前向纠错是通信领域的术语,看下百科的解释:

前向纠错也叫前向纠错码Forward Error Correction 简称FEC;是增加数据通讯可信度的方法,在单向通讯信道中,一旦错误被发现,其接收器将无权再请求传输。

FEC是利用数据进行传输冗余信息的方法,当传输中出现错误,将允许接收器再建数据。

就是做校验的,看看QUIC协议是如何实现的:
    QUIC每发送一组数据就对这组数据进行异或运算,并将结果作为一个FEC包发送出去,接收方收到这一组数据后根据数据包和FEC包即可进行校验和纠错。

5.连接迁移

    网络切换几乎无时无刻不在发生。TCP协议使用五元组来表示一条唯一的连接,当我们从4G环境切换到wifi环境时,手机的IP地址就会发生变化,这时必须创建新的TCP连接才能继续传输数据。

    QUIC协议基于UDP实现摒弃了五元组的概念,使用64位的随机数作为连接的ID,并使用该ID表示连接。基于QUIC协议之下,我们在日常wifi和4G切换时,或者不同基站之间切换都不会重连,从而提高业务层的体验。

![在这里插入图片描述]( https://img-blog.csdnimg.cn/20200922130333415.png?x-oss-process=image/watermark ,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dvbGZHdWlEYW8=,size_16,color_FFFFFF,t_70#pic_center)

6.改进的拥塞控制

    TCP 的拥塞控制实际上包含了四个算法:慢启动,拥塞避免,快速重传,快速恢复。
    QUIC 协议当前默认使用了 TCP 协议的 Cubic 拥塞控制算法 ,同时也支持 CubicBytes, Reno, RenoBytes, BBR, PCC 等拥塞控制算法。

    从拥塞算法本身来看,QUIC 只是按照 TCP 协议重新实现了一遍`,那么 QUIC 协议到底改进在哪些方面呢?主要有如下几点:

6.1 可插拔

能够非常灵活地生效,变更和停止,体现在如下方面:

  • 应用程序层面就能实现不同的拥塞控制算法,不需要操作系统,不需要内核支持。这是一个飞跃,因为传统的 TCP 拥塞控制,必须要端到端的网络协议栈支持,才能实现控制效果。而内核和操作系统的部署成本非常高,升级周期很长,这在产品快速迭代,网络爆炸式增长的今天,显然有点满足不了需求。
  • 即使是单个应用程序的不同连接也能支持配置不同的拥塞控制。就算是一台服务器,接入的用户网络环境也千差万别,结合大数据及人工智能处理,我们能为各个用户提供不同的但又更加精准更加有效的拥塞控制。比如 BBR 适合,Cubic 适合。
  • 应用程序不需要停机和升级就能实现拥塞控制的变更,我们在服务端只需要修改一下配置,reload 一下,完全不需要停止服务就能实现拥塞控制的切换。STGW 在配置层面进行了优化,我们可以针对不同业务,不同网络制式,甚至不同的 RTT,使用不同的拥塞控制算法。

6.2单调递增的 Packet Number

    TCP 为了保证可靠性,使用了基于字节序号的 Sequence Number 及 Ack 来确认消息的有序到达。QUIC同样是一个可靠的协议,它使用 Packet Number 代替了 TCP 的 sequence number,并且每个 Packet Number 都严格递增,也就是说就算 Packet N丢失了,重传的 Packet N 的 Packet Number 已经不是 N,而是一个比 N 大的值。而 TCP 呢,重传 segment 的 sequence number 和原始的 segment 的 Sequence Number 保持不变,也正是由于这个特性,引入了 Tcp 重传的歧义问题。

![在这里插入图片描述]( https://img-blog.csdnimg.cn/20200922133727910.png?x-oss-process=image/watermark ,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dvbGZHdWlEYW8=,size_16,color_FFFFFF,t_70#pic_center)

    如上图所示,超时事件 RTO 发生后,客户端发起重传,然后接收到了 Ack 数据。由于序列号一样,这个 Ack 数据到底是原始请求的响应还是重传请求的响应呢?不好判断。如果算成原始请求的响应,但实际上是重传请求的响应(上图左),会导致采样 RTT 变大。如果算成重传请求的响应,但实际上是原始请求的响应,又很容易导致采样 RTT 过小。

    由于 Quic 重传的 Packet 和原始 Packet 的 Pakcet Number 是严格递增的,所以很容易就解决了这个问题。

![在这里插入图片描述]( https://img-blog.csdnimg.cn/20200922133916874.png?x-oss-process=image/watermark ,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dvbGZHdWlEYW8=,size_16,color_FFFFFF,t_70#pic_center)

    如上图所示,RTO 发生后,根据重传的 Packet Number 就能确定精确的 RTT 计算。如果 Ack 的 Packet Number 是 N+M,就根据重传请求计算采样 RTT。如果 Ack 的 Pakcet Number 是 N,就根据原始请求的时间计算采样 RTT,没有歧义性。但是单纯依靠严格递增的 Packet Number 肯定是无法保证数据的顺序性和可靠性。QUIC 又引入了一个 Stream Offset 的概念。即一个 Stream 可以经过多个 Packet 传输,Packet Number 严格递增,没有依赖。Packet 里的 Payload 如果是 Stream 的话,就需要依靠 Stream 的 Offset
来保证应用数据的顺序。

![在这里插入图片描述]( https://img-blog.csdnimg.cn/20200922134146426.png?x-oss-process=image/watermark ,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dvbGZHdWlEYW8=,size_16,color_FFFFFF,t_70#pic_center)

  如图所示,发送端先后发送了 Pakcet N 和 Pakcet N+1,Stream 的Offset 分别是 x 和 x+y。假设 Packet N 丢失了,发起重传,重传的 Packet Number 是 N+2,但是它的 Stream 的 Offset 依然是 x,这样就算 Packet N + 2 是后到的,依然可以将 Stream x 和 Stream x+y 按照顺序组织起来,交给应用程序处理。

6.3 不允许 Reneging

  什么叫 Reneging 呢?就是接收方丢弃已经接收并且上报给 SACK 选项的内容 。TCP 协议不鼓励这种行为,但是协议层面允许这样的行为。主要是考虑到服务器资源有限,比如 Buffer 溢出,内存不够等情况。Reneging 对数据重传会产生很大的干扰。因为 Sack 都已经表明接收到了,但是接收端事实上丢弃了该数据。QUIC 在协议层面禁止 Reneging,一个 Packet 只要被 Ack,就认为它一定被正确接收,减少了这种干扰。

6.4 更多的 Ack 块

  TCP 的 Sack 选项能够告诉发送方已经接收到的连续 Segment 的范围,方便发送方进行选择性重传。由于 TCP 头部最大只有 60 个字节,标准头部占用了 20 字节,所以 Tcp Option 最大长度只有 40 字节,再加上 Tcp Timestamp option 占用了 10 个字节 ,所以留给 Sack 选项的只有 30 个字节。每一个 Sack Block 的长度是 8 个,加上 Sack Option 头部 2 个字节,也就意味着 Tcp Sack Option 最大只能提供 3 个 Block。但是 Quic Ack Frame 可以同时提供 256 个 Ack Block,在丢包率比较高的网络下,更多的 Sack Block 可以提升网络的恢复速度,减少重传量。

6.5 Ack Delay 时间

  Tcp 的 Timestamp 选项存在一个问题 ,它只是回显了发送方的时间戳,但是没有计算接收端接收到 segment 到发送 Ack 该 segment 的时间。这个时间可以简称为 Ack Delay。这样就会导致 RTT 计算误差。如下图:

![在这里插入图片描述]( https://img-blog.csdnimg.cn/20200922134723577.png?x-oss-process=image/watermark ,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dvbGZHdWlEYW8=,size_16,color_FFFFFF,t_70#pic_center)

可以认为 TCP 的 RTT 计算:
在这里插入图片描述

而 Quic 计算如下:
在这里插入图片描述

6.6 基于 stream 和 connecton 级别的流量控制

  QUIC 的流量控制 类似 HTTP2,即在 Connection Stream 级别提供了两种流量控制。为什么需要两类流量控制呢?主要是因为 QUIC 支持多路复用。Stream 可以认为就是一条 HTTP 请求。Connection 可以类比一条 TCP 连接。多路复用意味着在一条 Connetion 上会同时存在多条 Stream。既需要对单个 Stream 进行控制,又需要针对所有 Stream 进行总体控制。

QUIC 实现流量控制的原理:

通过 window_update 帧告诉对端自己可以接收的字节数,这样发送方就不会发送超过这个数量的数据。通过 BlockFrame 告诉对端由于流量控制被阻塞了,无法发送数据。QUIC 的流量控制和 TCP 有点区别,TCP 为了保证可靠性,窗口左边沿向右滑动时的长度取决于已经确认的字节数。如果中间出现丢包,就算接收到了更大序号的 Segment,窗口也无法超过这个序列号。但 QUIC 不同,就算此前有些 packet 没有接收到,它的滑动只取决于接收到的最大偏移字节数。

![在这里插入图片描述]( https://img-blog.csdnimg.cn/20200922135106709.png?x-oss-process=image/watermark ,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dvbGZHdWlEYW8=,size_16,color_FFFFFF,t_70#pic_center)

  • 针对 Stream:
    在这里插入图片描述
  • 针对 Connection:
    在这里插入图片描述
    同样地,STGW 也在连接和 Stream 级别设置了不同的窗口数。最重要的是,我们可以在内存不足或者上游处理性能出现问题时,通过流量控制来限制传输速率,保障服务可用性。

二.QIUIC编译运行与测试

  由于go语言的简洁性以及编译的便捷性,本文将选用quic-go进行quic协议的分析,该库是完全基于go语言实现,可以用于构建客户端或服务端。

1.首先在Liunx18.04下下载golang编译器,要求go版本为1.14+

sudo snap install go

2.接着在本地下载quic-go项目源码

git clone https://github.com/lucas-clemente/quic-go.git

下载完成后找到quic-go文件并进入cd quic-go

测试quic-go源码:go test -v ./...

截屏2021-01-30 上午10.12.53

项目测试正确,可以进行编译。

3.编译

先进入example文件夹,里面是一个quic实例

  • 服务端

    编译:go build main.go

    运行:./main -qlog -v -tcp

    注意1:必须带上-tcp参数。

      虽然quic是基于UDP的协议,但是因为浏览器第一次访问时仍然是要通过TCP进行的。

    注意2:

      从quic-go v0.19.x开始,可能会看到有关接收缓冲区大小的警告。

      高带宽连接上的QUIC传输可能受到UDP接收缓冲区大小的限制。该缓冲区保存内核已接收但应用程序尚未读取的数据包(在这种情况下为quik-go)。一旦该缓冲区填满,内核将丢弃任何新的传入数据包。因此,quic-go尝试增加缓冲区大小。要做到这一点的方式是特定于操作系统的,而我们现在有一个实现linuxwindowsdarwin。但是,仅允许应用程序将缓冲区大小增加到内核中设置的最大值。不幸的是,在Linux上,该值很小,对于高带宽QUIC传输来说太小了。

      建议通过运行以下命令增加最大缓冲区大小:sysctl -w net.core.rmem_max=2500000;此命令会将最大接收缓冲区大小增加到大约2.5 MB。

    截屏2021-01-30 下午5.54.27

  • 客户端

    进入client文件夹cd quic-go/example/client

    先选择quic的draft-29版本,打开main.go,在60行上添加

    qconf.Versions =[]protocol.VersionNumber{protocol.VersionDraft29},因为使用了protocol,所以需要引入这个文件,在文件头添加"github.com/lucas-clemente/quic-go/internal/protocol"

    截屏2021-01-30 下午3.25.47

    当使用-tcp后,自动使用默认设置,在默认设置中并未开启draft-29版本的支持,因此需要手动添加版本支持,打开internal/protocol/version.go文 件,

    将第30行改为var SupportedVersions = []VersionNumber{VersionTLS, VersionDraft29}

    截屏2021-01-30 下午3.24.33

    准备工作完成了,开始编译go build main.go

    接着使用./main -v -insecure -keylog ssl.log 即可访问支持quic协议的网站。

4.测试

  • 客户端测试

      使用./main -v -insecure -keylog key.log https://quic.rocks:4433/访问测试网站,可以看见最后成功输出了网页的内容 “You have successfully loaded quic.rocks using QUIC!”,使用的协议为HTTP/3,并且错误代码为0x100,即未发生错误。

    截屏2021-01-30 下午3.06.33

      在wireshark中的Edit -> Preferences -> protocol -> TLS -> (pre)-master-secret log filename设置为上面输出的key.log文件,用来对quic的payload进行解密,之后可以看到客户端的完整的请求过程,包括1-RTT的握手,HTTP3数据发送,断开连接等。

    截屏2021-01-30 下午8.57.53

    可以看到,第一个QUIC包的类型为Initial,进行了0-RTT的初始化。

    注意:

    旧版wireshark检测不到QUIC数据包,必须更新成新版wireshark。

    Ubuntu新版wireshark升级详情:https://blog.csdn.net/bryanting/article/details/53327575

  • 服务器测试

    在example下运行./mian

    在client下运行./main https://localhost:6121/demo/tile

    截屏2021-01-30 下午9.13.16

状态码200表示成功,HTTP/3协议。

三、QUIC源代码分析

客户端源代码:

roundTripper

在客户端(example/main.go)代码中,通过http3.RoundTripper建立了一个中间件,之后将roundTripper传递给http.Client建立了一个http客户端,并以此来发起http请求。

roundTripper := &http3.RoundTripper{
		TLSClientConfig: &tls.Config{
			RootCAs:            pool,
			InsecureSkipVerify: *insecure,
			KeyLogWriter:       keyLog,
		},
		QuicConfig: &qconf,
	}
	defer roundTripper.Close()
	hclient := &http.Client{
		Transport: roundTripper,
	}

	var wg sync.WaitGroup
	wg.Add(len(urls))
	for _, addr := range urls {
		logger.Infof("GET %s", addr)
		go func(addr string) {
			rsp, err := hclient.Get(addr)
			if err != nil {
				log.Fatal(err)
			}
			logger.Infof("Got response for %s: %#v", addr, rsp)

			body := &bytes.Buffer{}
			_, err = io.Copy(body, rsp.Body)
			if err != nil {
				log.Fatal(err)
			}
			if *quiet {
				logger.Infof("Request Body: %d bytes", body.Len())
			} else {
				logger.Infof("Request Body:")
				logger.Infof("%s", body.Bytes())
			}
			wg.Done()
		}(addr)
	}


type RoundTripper interface { 
       RoundTrip(*Request) (*Response, error)
}

  http3.RoundTripper实现了net.RoundTripper接口,使http客户端将发起请求的过程交由该中间件来处理当只有一个函数RoundTrip接受一个http请求,返回http响应。

  在http3.RoundTripper的实现中,将请求交给了RoundTripOpt函数来处理。该函数中首先判断请求是否合法,如果不合法就关闭请求,合法就会通过cl, err := r.getClient(hostname, opt.OnlyCachedConn)来获取quic客户端。在getClient函数中,通过hash表来获取quic的client,如果不存在就会通过newClient函数建立新client。当获取到client之后,又就会通过client.RoundTrip函数发起请求。在client.RoundTrip中,在发起请求之前,会调用authorityAddr来确保源地址不是伪造的。当第一次发送请求时会调用dial函数进行握手,如果使用0rtt请求,就立即发送请求,否则在当握手完成后通过doRequest发出请求。

DialAddr

func DialAddr(addr string,tlsConf *tls.Config,config *Config,
) (Session, error) {
	return DialAddrContext(context.Background(), addr, tlsConf, config)
}

函数的参数列表:

addr表示服务端的地址,tlsConf表示tls的配置,最后一个config表示QUIC的配置,当填入nil的时候将使用默认配置。

config *Config常用选项:

  • HandshakeIdleTimeout:握手延迟
  • MaxIdleTimeout:双方没有发送消息的最大时间,超过这个时间则断开
  • AcceptToken:令牌接收
  • MaxReceiveStreamFlowControlWindow:最大的接收流控制窗口(针对Stream)
  • MaxReceiveConnectionFlowControlWindow:最大的针对连接的可接收的数据窗口(针对一个Connection可以有多少最大的数据窗口)
  • MaxIncomingStreams:一个连接最大有多少Stream

返回值(Session, error)

  • error错误处理
  • Session:一个接口,表示连接会话,通过它可以调用一些方法来完成后续操作。

DialAddr只是一个封装函数,现在向下追溯DialAddrContext,

func DialAddrContext(ctx context.Context,addr string,tlsConf *tls.Config,config *Config,
) (Session, error) {
	return dialAddrContext(ctx, addr, tlsConf, config, false)
}

相比于DialAddr,唯一的不同在于多加了一个context.Context类型。

上下文context.Context是用来设置截止日期、同步信号,传递请求相关值的结构体。可以理解为一个同步信号,即对信号同步以减少资源浪费。常用的context.Background()返回一个预定义的类型。

到这里发现DialAddrContext依然只是一个包装函数,现在继续向下追溯DialAddrContext

func dialAddrContext(
	ctx context.Context, addr string, tlsConf *tls.Config,
	config *Config, use0RTT bool,
) (quicSession, error) {
	udpAddr, err := net.ResolveUDPAddr("udp", addr)
	if err != nil {
		return nil, err
	}
	udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
	if err != nil {
		return nil, err
	}
	return dialContext(ctx, udpConn, udpAddr, addr, tlsConf, config, use0RTT, true)
}

因为QUIC基于udp,先调用net.ResolveUDPAddr("udp", addr),接着在UDP上听

net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0}),最后根据返回的结果调用dialContext函数统一处理。继续看dialContext函数

func dialContext(
	ctx context.Context, pconn net.PacketConn, remoteAddr net.Addr,
	host string, tlsConf *tls.Config, config *Config,
	use0RTT bool, createdPacketConn bool,
) (quicSession, error) {
	if tlsConf == nil {
		return nil, errors.New("quic: tls.Config not set")
	}
	if err := validateConfig(config); err != nil {
		return nil, err
	}
	config = populateClientConfig(config, createdPacketConn)
	packetHandlers, err := getMultiplexer().AddConn(pconn, config.ConnectionIDLength, config.StatelessResetKey, config.Tracer)
	if err != nil {
		return nil, err
	}
	c, err := newClient(pconn, remoteAddr, config, tlsConf, host, use0RTT, createdPacketConn)
	if err != nil {
		return nil, err
	}
	c.packetHandlers = packetHandlers

	if c.config.Tracer != nil {
		c.tracer = c.config.Tracer.TracerForConnection(protocol.PerspectiveClient, c.destConnID)
	}
	if err := c.dial(ctx); err != nil {
		return nil, err
	}
	return c.session, nil
}

getMultiplexer建立一个多路复用。接着返回一个新的客户端结构newClient

session.OpenStreamSync

stream, err := session.OpenStreamSync(context.Background())
OpenStreamSync`:打开一个新的双向的`QUIC Stream

追踪这个函数,我们找到一个函数OpenStreamSync,这个函数里面是一些处理多线程的语句,后面有一个openStream函数。

func (m *outgoingBidiStreamsMap) openStream() streamI {
	s := m.newStream(m.nextStream)
	m.streams[m.nextStream] = s
	m.nextStream++
	return s
}

因为Session是一个connection,它包含多个Stream,这个函数就是新建一个流,然后加入这个Session,现在继续查看newStream,它返回一个新创建的Stream

type stream struct {
	receiveStream
	sendStream

	completedMutex         sync.Mutex
	sender                 streamSender
	receiveStreamCompleted bool
	sendStreamCompleted    bool

	version protocol.VersionNumber
}

Stream里面有receiveStreamsendSatream、同步用的锁completedMutex

receiveStream

  • StreamID:流ID
  • io.Reader:读接口
  • CancelRead:是否禁止接收流
  • SetReadDeadline:读超时设置

sendSatream

  • StreamID:流ID
  • io.Write:写接口
  • CancelWrite:是否禁止写
  • Context:上面提到过的用来同步的结构体
  • SetWriteDeadline:设置写超时

最后返回的是stream。

服务端源代码:

ListenAddr

ListenAddr的核心代码如下:

listener, err := quic.ListenAddr(addr, generateTLSConfig(), nil)

进入server.go/ListenAddr:

func ListenAddr(addr string, tlsConf *tls.Config, config *Config) (Listener, error) {
	return listenAddr(addr, tlsConf, config, false)
}
func listenAddr(addr string, tlsConf *tls.Config, config *Config, acceptEarly bool) (*baseServer, error) {
	udpAddr, err := net.ResolveUDPAddr("udp", addr)
	if err != nil {
		return nil, err
	}
	conn, err := net.ListenUDP("udp", udpAddr)
	if err != nil {
		return nil, err
	}
	serv, err := listen(conn, tlsConf, config, acceptEarly)
	if err != nil {
		return nil, err
	}
	serv.createdPacketConn = true
	return serv, nil
}

这个函数返回一个baseServer,这是一个QUIClistener

该结构体添加了如下的结构体成员

  • sessionQueue:一个客户端的Session队列
  • sessionQueueLen:客户端的Session队列的长度

接着新建一个线程,不断地监听端口,等待一个新的客户端连接请求

Accept

核心代码:

sess, err := listener.Accept(context.Background())

进入Accept函数

func (s *baseServer) Accept(ctx context.Context) (Session, error) {
	return s.accept(ctx)
}

atomic.AddInt32(&s.sessionQueueLen, -1)

这里当收到一个新的请求的时候添加到sessionQueue中,返回一个客户端Session,用这个Session可以和客户端进行发送和接收数据。

AddConn

进入liten函数的AddConn,查看多路复用添加连接的过程。

type connMultiplexer struct {
	mutex sync.Mutex
	conns                   map[string] /* LocalAddr().String() */ connManager
	newPacketHandlerManager func(net.PacketConn, int, []byte, logging.Tracer, utils.Logger) (packetHandlerManager, error)
	logger utils.Logger
}
func (m *connMultiplexer) AddConn(
	c net.PacketConn, connIDLen int, statelessResetKey []byte, tracer logging.Tracer,
) (packetHandlerManager, error) {
	m.mutex.Lock()
	defer m.mutex.Unlock()

	addr := c.LocalAddr()
	connIndex := addr.Network() + " " + addr.String()
	p, ok := m.conns[connIndex]
	if !ok {
		manager, err := m.newPacketHandlerManager(c, connIDLen, statelessResetKey, tracer, m.logger)
		if err != nil {
			return nil, err
		}
		p = connManager{
			connIDLen:         connIDLen,
			statelessResetKey: statelessResetKey,
			manager:           manager,
			tracer:            tracer,
		}
		m.conns[connIndex] = p
	} else {
		if p.connIDLen != connIDLen {
			return nil, fmt.Errorf("cannot use %d byte connection IDs on a connection that is already using %d byte connction IDs", connIDLen, p.connIDLen)
		}
		if statelessResetKey != nil && !bytes.Equal(p.statelessResetKey, statelessResetKey) {
			return nil, fmt.Errorf("cannot use different stateless reset keys on the same packet conn")
		}
		if tracer != p.tracer {
			return nil, fmt.Errorf("cannot use different tracers on the same packet conn")
		}
	}
	return p.manager, nil
}

connMultiplexer这个结构定义了互斥锁、连接map、新建函数和日志处理。AddConn函数首先加了互斥锁,然后将连接信息新建了一个连接管理器connManager,加入到conns这个map当中,map以ip地址(如:"udp xx.xx.xx.xx:80")作为key,连接管理器作为value。

四、QUIC请求流程分析

时序图

img

发送包

  握手的函数调用栈:

dial -> dialAddr -> DialAddrEarly -> DialAddrEarlyContext -> dialAddrContext -> dialContext -> newClient -> client.dial -> newClientSession -> session.run -> RunHandshake ->

conn.Handshake -> clientHandshake

  最终在Conn.clientHandshake函数中完成了握手的设置,之后通过clientHandshakeState.handshark函数完成了发送等工作。

  在newClient函数中,通过generateConnectionID和generateConnectionIDForInitial生成了srcConnIDdestConnID

  在handshark函数中,调用establishKeys函数,完成了密钥的生成,之后调用sendFinished函数,将Client Hello帧写入到TLS Record层,完成握手包的发送。

接收包

  在session.run中的runloop中,通过select对接收通道进行监听,当收到数据包时,就会调用handlePacketImpl -> handleSinglePacket -> handleUnpackedPacket函数进行处理。

  在handleUnpackedPacket函数中,如果是第一个包,就会读取其SrcConnectionID,将其设置为该连接的destination connection ID;之后对包中的帧依次进行读取,并使用parseFrame函数进行判断,并调用对应函数进行解析,最后调用handleFrame函数中调用相关函数进行处理。

  在握手过程中,接收的第一个Initial包为合并包(coalesced packet),第一个帧为ACK帧,通过parseAckFrame进行解析,使用handleAckFrame函数进行处理;第二个帧为Crypto帧,消息为Server Hello,通过parseCryptoFrame函数解析,handleCryptoFrame函数进行处理,该函数会通过session.cryptoStreamManager对密钥信息进行处理。之后第二个Handshake包中只有一个Crypto帧,消息类型为Encrypted Extensions。第三个quic包中包含了一个Stream帧,stream id为3,这个帧会通过handleStreamFrameImpl进行处理,在该函数中会将数据push到frameQueue队列中去,之后通过signalRead函数来通知数据包的到达。该帧的内容为HTTP3的SETTINGS帧。

HTTP3数据传输

  在第二个RTT中,client先通过Initial包发送ACK帧对收到的包进行确认,之后再通过Handshake包发送了CRYPTO帧和ACK帧,此CRYPTO帧的消息为Handshark protocol: Finished。最后再分别发送了streamid为0和2的HTTP3 HEADERS帧和SETTINGS帧。streamid为0的HEADERS包即为http请求,该包使用了QPACK方法进行压缩,该方法与HTTP2的HPACK类似,而根据QPACK的定义,streamid为2和3的stream分别为encoder streamdecoder stream,即两个SETTINGS帧。之后client接收到了Handshark包,其中包含一个ACK帧。
此时,1-RTT的握手过程已经结束,因此接下来收到的包的类型就变为了Short header packet,收到的第一个包HANDSHARK_DONE类型,说明握手完成。最后,服务端返回了一个HTTP3的DATA帧,该帧中即包含了请求的响应数据,在收到数据后,客户端就发送了一个CONNECTION_CLOSE的帧关闭连接

五、总结

  虽然QUIC相比于以前的通信协议有更大的进步,能具有更低的延迟和更好的安全性,但距离广泛应用还有很有一段时间。QUIC 作为一个新的传输层协议,它在设计上针对 TCP的不足进行了很多优化。它提供的多路传输、快速握手等新特性使得它和 TCP相比在理论上可以获得更低的数据传输延时。QUIC在大部分情况下的确能比 TCP 达到更低的传输延时,但是仍然有部分情况下 QUIC 的表现不如 TCP。这些 QUIC 性能表现较差的场景往往是拥塞算法的选择、服务器部署等外部因素造成的,而非QUIC本身的设计缺陷。因此,QUIC 的软件实现仍然有很大的进步空间。受限于个人的水平,文章依然还有很多不足之处,希望读者批评指正。

posted @ 2021-01-29 23:59  程鹏777  阅读(1953)  评论(0)    收藏  举报