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协议来实现。

在这里插入图片描述

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

2.队头阻塞问题

    队头阻塞 Head-of-line blocking(缩写为HOL blocking)是计算机网络中是一种性能受限的现象,通俗来说就是:一个数据包影响了一堆数据包,它不来大家都走不了。队头阻塞问题可能存在于HTTP层和TCP层,在HTTP1.x时两个层次都存在该问题。
在这里插入图片描述

    HTTP2.0协议的多路复用机制解决了HTTP层的队头阻塞问题,但是在TCP层仍然存在队头阻塞问题。TCP协议在收到数据包之后,这部分数据可能是乱序到达的,但是TCP必须将所有数据收集排序整合后给上层使用,如果其中某个包丢失了,就必须等待重传,从而出现某个丢包数据阻塞整个连接的数据使用。多路复用是 HTTP2 最强大的特性 ,能够将多条请求在一条 TCP 连接上同时发出去。但也恶化了 TCP 的一个问题,队头阻塞 ,如下图示:
在这里插入图片描述

    HTTP2 在一个 TCP 连接上同时发送 4 个 Stream。其中 Stream1 已经正确到达,并被应用层读取。但是 Stream2 的第三个 tcp segment 丢失了,TCP 为了保证数据的可靠性,需要发送端重传第 3 个 segment 才能通知应用层读取接下去的数据,虽然这个时候 Stream3 和 Stream4 的全部数据已经到达了接收端,但都被阻塞住了。不仅如此,由于 HTTP2 强制使用 TLS,还存在一个 TLS 协议层面的队头阻塞.
在这里插入图片描述

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

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

3.0RTT 建链

衡量网络建链的常用指标是RTT Round-Trip Time,也就是数据包一来一回的时间消耗。
在这里插入图片描述

RTT包括三部分:往返传播时延、网络设备内排队时延、应用程序数据处理时延。
在这里插入图片描述

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

  • 传输层 0RTT 就能建立连接
  • 加密层 0RTT 就能建立加密连接
    在这里插入图片描述

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

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

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

在这里插入图片描述

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

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

首次连接

在这里插入图片描述

非首次连接

    前面提到客户端和服务端首次连接时服务端传递了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切换时,或者不同基站之间切换都不会重连,从而提高业务层的体验。

在这里插入图片描述

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 重传的歧义问题。

在这里插入图片描述

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

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

在这里插入图片描述

    如上图所示,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
来保证应用数据的顺序。

在这里插入图片描述

  如图所示,发送端先后发送了 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 计算误差。如下图:

在这里插入图片描述

可以认为 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 没有接收到,它的滑动只取决于接收到的最大偏移字节数。

在这里插入图片描述

  • 针对 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  阅读(1521)  评论(0编辑  收藏  举报