欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

探究QUIC协议与HTTP3.0技术的深入剖析

最编程 2024-07-31 16:27:12
...

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 定会在各类场景大展身手。