探究QUIC协议与HTTP3.0技术的深入剖析
QUIC 概述
快速UDP网络连接(英语:Quick UDP Internet Connections,缩写:QUIC)是由 Google 开发的实验性的网络传输协议。QUIC 使用 UDP 协议,它在两个端点间创建连线,且支持多路复用连线。QUIC 希望能够提供基于 TLS/DTLS 的网络安全保护,减少资料传输及创建连线时的延迟时间,双向控制带宽,以避免网络拥塞。QUIC 增加了 TCP 网络应用程序的性能,它通过使用 UDP 在两个端点之间建立一系列多路复用(multiplexing)的连接实现这个目的。Google 希望使用这个协议来取代 HTTPS/HTTP 协议,使网页传输速度加快。2015 年 6 月,QUIC 的网络草案被正式提交至互联网工程任务组。2018 年 10 月,互联网工程任务组 HTTP 及 QUIC 工作小组正式将基于 QUIC 协议的 HTTP(英语:HTTP over QUIC)重命名为 HTTP/3 以为确立下一代规范做准备。
与 TCP 相比,QUIC 可以减少延迟。QUIC 协议可以在 1 到 2 个数据包内,完成连接的创建。QUIC 与现有 TCP + TLS + HTTP/2 方案相比,有以下几点主要特征:
1)利用缓存,显著减少连接建立时间;
2)改善拥塞控制,拥塞控制从内核空间到用户空间;
3)没有 head of line 阻塞的多路复用;
4)前向纠错,减少重传;
5)连接平滑迁移,网络状态的变更不会影响连接断线。
从图上可以看出,QUIC 底层通过 UDP 协议替代了 TCP,上层只需要一层用于和远程服务器交互的 HTTP/2 API。这是因为 QUIC 协议已经包含了多路复用和连接管理,HTTP API 只需要完成 HTTP 协议的解析即可。
QUIC 特性
1. 建立连接低延迟
与 TCP 相比,建立连接的时间大大减少。QUIC 客户端第一次连接到服务器时,客户端必须执行 1 次往返握手,以获取完成握手所需的信息。客户端发送早期(empty)客户端 Hello 问候(CHLO),服务器发送拒绝(rejection)(REJ),其中包含客户端前进所需的信息,包括源地址令牌和服务器的证书。客户端下次发送CHLO时,可以使用以前连接中的缓存凭据来立即将加密的请求发送到服务器。
首次连接时,QUIC协议可以在1个RTT中启动一个连接并且获取完成握手所需的必要信息。在QUIC中,服务器的配置是完全静态的,而且配置是有过期时间的,由于服务器配置是静态的,因而不是每个连接都需要重新进行签名操作,一个签名可以适用于多个连接。后续连接只需 0RTT 即可建立安全连接。
2. 改进的拥塞控制
QUIC 协议当前默认使用了 TCP 协议的 Cubic 拥塞控制算法,同时也支持 CubicBytes, Reno, RenoBytes, BBR, PCC 等拥塞控制算法。
QUIC 拥塞控制特点:
可插拔
- 可以灵活的使用拥塞算法,一次选择一个或几个拥塞算法同时工作
- 在应用层实现拥塞算法,而以前实现对应的拥塞算法,需要部署到操作系统内核中。现在可以更快的迭代升级
- 不同的平台具有不同的底层和网络环境,现在我们能够灵活的选择拥塞控制,比如选择A选择Cubic,B则选择显示拥塞控制
- 应用程序不需要停机和升级,我们在服务端进行的修改,现在只需要简单的reload一下就能实现不同拥塞控制切换
包编号单调递增
QUIC 使用 Packet Number,每个 Number 严格递增,所以如果Packet N丢失了,重传Packet N的Packet Number已不是N,而是一个大于N的值。这样就很容易解决TCP的重传歧义问题。
禁止Reneging
QUIC不允许重新发送任何确认的数据包,也就禁止了接收方丢弃已经接受的内容。
更多ACK帧
TCP只能有3个ACK Block,但是Quic Ack Frame 可以同时提供 256 个 Ack Block,在丢包率比较高的网络下,更多的 Block 可以提升网络的恢复速度,减少重传量。
更精准的发送延迟
QUIC端点会测量接收到数据包与发送相应确认之间的延迟,使对等方可以保持更准确的往返时间估计
3. 多路复用
HTTP/2 的最大特性就是多路复用,而 HTTP/2 最大的问题就是队头阻塞。例如,HTTP2在一个TCP连接上同时发送3个stream,其中第2个stream丢了一个Packet,TCP为了保证数据可靠性,需要发送端重传丢失的数据包,虽然这时候第3个数据包已经到达接收端,但被阻塞了。这就是所谓的队头阻塞。
而QUIC多路复用可以避免这个问题,因为QUIC的丢包、流控都是基于stream的,所有stream是相互独立的,一条stream上的丢包,不会影响其他stream的数据传输。
4. 前向纠错
QUIC使用了FEC(前向纠错码)来恢复数据,FEC采用简单异或的方式,每发送一组数据,包括若干个数据包后,并对这些数据包依次做异或运算,最后的结果作为一个FEC包再发送出去。接收方收到一组数据后,根据数据包和FEC包即可以进行校验和纠错。比如:10个包,编码后会增加2个包,接收端丢失第2和第3个包,仅靠剩下的10个包就可以解出丢失的包,不必重新发送,但这样也是有代价的,每个UDP数据包会包含比实际需要更多的有效载荷,增加了冗余和CPU编解码的消耗。
5. 连接迁移
QUIC 连接不再以 IP 及端口四元组标识,而是以一个 64 位的随机数作为 ID 来标识,这样就算 IP 或者端口发生变化时,只要 ID 不变,这条连接依然维持着,上层业务逻辑感知不到变化,不会中断,也就不需要重连。
QUIC 编译运行
这里采用 quic-go 这一实现进行分析。
首先搭建环境,安装好 go(1.14+) 的运行环境即可,这里使用 linux 子系统下的 go。然后下载源码:
客户端样例
在 /example/client
里包含了一个客户端样例。首先修改一下 QUIC 版本信息:
在 /example/client/main.go
中添加:qconf.Versions = []protocol.VersionNumber{protocol.VersionDraft29}
:
在 /internal/protocol/version.go
中修改 SupportedVersions
,添加VersionDraft29
:
然后进行编译。
运行:./main -insecure -keylog quic.log https://quic.rocks:4433/
抓包分析
在 Wireshark 中设置 首选项 -> Protocols -> TLS -> (pre)-master-secret log filename
为上面输出的 quic.log 文件,用来对 quic 的 payload 进行解密,之后可以看到客户端的完整的请求过程,包括1-RTT的握手,HTTP3数据发送,断开连接等:
服务端样例
进入/example
,编译go build main.go
,运行./main
再开一个终端,用之前的客户端连接过去:
cd /example/client
./main https://localhost:6121/demo/tile
响应返回 200,协议为 HTTP/3。
代码分析
客户端源码
client.go/DialAddr
函数建立了连接:
func DialAddr(
addr string, tlsConf *tls.Config, config *Config,
) (Session, error) {
return DialAddrContext(context.Background(), addr, tlsConf, config)
}
addr 表示服务端的地址,tlsConf 表示tls的配置,config 表示QUIC的配置
配置类 Config
的一些选项:
- HandshakeIdleTimeout: 握手延迟
- MaxIdleTimeout: 双方没有发送消息的最大时间,超过这个时间则断开
- AcceptToken: 令牌接收
- MaxReceiveStreamFlowControlWindow: 最大的接收流控制窗口(针对Stream)
- MaxReceiveConnectionFlowControlWindow: 最大的针对连接的可接收的数据窗口(针对一个Connection可以有多少最大的数据窗口)
- MaxIncomingStreams: 一个连接最大有多少Stream
返回类型为 Session 和 error,error 为 go 语言标准的错误处理,Session 是一个接口,表示连接会话,通过它可以调用一些方法来完成后续操作。
继续追溯 DialAddr
,进入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)
}
ctx 参数提供了 go 上下文支持,用来支持并发操作。该函数首先解析udp地址,然后进行监听。我们继续追踪进 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
}
函数首先进行了配置,然后使用多路复用,添加了连接,我们后面对这个函数进行详细分析。最后newClient
返回一个新的客户端。
服务端源码
进入 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
}
同样的,首先是解析udp地址,然后udp监听。进入listen
函数查看:
func listen(conn net.PacketConn, tlsConf *tls.Config, config *Config, acceptEarly bool) (*baseServer, error) {
if tlsConf == nil {
return nil, errors.New("quic: tls.Config not set")
}
if err := validateConfig(config); err != nil {
return nil, err
}
config = populateServerConfig(config)
for _, v := range config.Versions {
if !protocol.IsValidVersion(v) {
return nil, fmt.Errorf("%s is not a valid QUIC version", v)
}
}
sessionHandler, err := getMultiplexer().AddConn(conn, config.ConnectionIDLength, config.StatelessResetKey, config.Tracer)
if err != nil {
return nil, err
}
tokenGenerator, err := handshake.NewTokenGenerator(rand.Reader)
if err != nil {
return nil, err
}
s := &baseServer{
// ... 一系列配置
}
go s.run()
sessionHandler.SetServer(s)
s.logger.Debugf("Listening for %s connections on %s", conn.LocalAddr().Network(), conn.LocalAddr().String())
return s, nil
}
首先进行了配置,然后获取多路复用添加连接,最后新建token,返回服务端。
进入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 1.2.3.4:1234"
)作为key,连接管理器作为value。
总结
相较于传统 TCP 连接,QUIC 具有连接快、延迟低、安全性强等优点,但由于网络服务商对 UDP 的支持较差等原因,以及该项目仍处于草案阶段,距离实际应用还有一段距离。不过相信随着 5G 等技术的发展,以及项目本身越来越成熟,QUIC 和 HTTP/3 定会在各类场景大展身手。