QUIC协议和HTTP3.0技术研究
1. 现状
1.1 HTTP 1.0
? 我们可以自己打开浏览器的控制台就可以发现目前主流web服务的http协议都基本是1.1版本了。HTTP/1.0最初实现了可用性。对每个请求都需要TCP三次握手建立单独链路。HTTP/1.1优化了传输效率。新增keep-alive特性使多个请求可以复用同一条TCP链路(TCP keep-alive是传输层特性,防止NAT路由断开连接);它支持持续连接.通过这种连接,就有可能在建立一个TCP连接后,发送请求并得到回应,然后发送更多的请求并得到更多的回应.通过把建立和释放TCP连接的开销分摊到多个请求上,则对于每个请求而言,由于TCP而造成的相对开销被大大地降低了
存在的缺陷
- 队头堵塞导致:虽然通过持久性连接得到改善,但是每一个请求的服务端响应依然需要按照顺序排队,如果前面的响应处理较为耗费时间,那么同样非常耗费性能。
1.2 HTTP 2.0
- 在 HTTP/1.1 协议中浏览器客户端在同一时间,针对同一域名下的请求有一定数量限制。超过限制数目的请求会被阻塞。
- 而 HTTP/2 的多路复用(Multiplexing) 则允许同时通过单一的 HTTP/2 连接发起多重的请求-响应消息。因此 HTTP/2 可以很容易的去实现多流并行而不用依赖建立多个 TCP 连接,HTTP/2 把 HTTP 协议通信的基本单位缩小为一个一个的帧,这些帧对应着逻辑流中的消息。并行地在同一个 TCP 连接上双向交换消息。
- HTTP/2在 应用层(HTTP/2)和传输层(TCP or UDP)之间增加一个二进制分帧层。在不改动 HTTP/1.x 的语义、方法、状态码、URI 以及首部字段的情况下, 解决了HTTP1.1 的性能限制,改进传输性能,实现低延迟和高吞吐量。在二进制分帧层中, HTTP/2 会将所有传输的信息分割为更小的消息和帧(frame),并对它们采用二进制格式的编码 ,其中 HTTP1.x 的首部信息会被封装到 HEADER frame,而相应的 Request Body 则封装到 DATA frame 里面。
假设一个页面要发送三个独立的请求,一个获取css,一个获取js,一个获取图片jpg。如果使用HTTP1.1就是串行的,但是如果使用HTTP2.0,就可以在一个连接里,客户端和服务端都可以同时发送多个请求或回应,而且不用按照顺序一对一对应
HTTP2.0的缺陷 因为还是基于TCP协议的原因,基于连接的TCP协议在往返时百延(RTT)上仍是一个问题(如图是TCP三次握手的过程)
当其中一个数据包遇到问题,TCP连接需要等待整个包完成重传之后才能继续进行,虽然HTTP2.0通过多个stream,使得逻辑上一个tcp连接上的并行内容,进行多路数据的传输,然而这中间没有关联的数据,一前一后,前面stream2的帧没有收到,后面stream1的帧也会因此堵塞
2. QUIC(Quick UDP Internet Connections)
是由Google提出的一种基于UDP改进的低时延的互联网传输层(其实有疑义,QUIC基于UDP,其实更像应用层协议)协议。
因为TCP的重传机制,只要一个包丢失就得判断丢包并且重传,导致发生队头阻塞的问题,但是UDP没有这个限制。除此之外,它还有如下特点:
- 实现了自己的加密协议,通过类似TCP的TFO机制实现0-RTT,当然TLS1.3已经实现了0-RTT。支持重传和纠错机制,在只丢失一个包的情况下不需要重传,使用纠错机制恢复丢失的包。
- 纠错机制:通过异或的方式,算出发出去的数据的异或值并单独发出一个包,服务端在发现有一个包丢失的情况下,通过其他数据包的异或值包算出丢失包。 在丢失两个包及以上的情况就是用重传机制,因为算不出来了。
基于UDP,就可以在QUIC自己的逻辑里面维护连接的机制,不再以四元组标识,而是以一个64 位的随机数作为ID来标识,而且UDP是无连接的,所以当ip或者端口变化的时候,只要ID不变,就不需要重新建立连接
TCP的流量控制是通过滑动窗口协议。QUIC的流量控制也是通过window_update,来告诉对端它可以接受的字节数。但是QUIC的窗口是适应自己的多路复用机制的,不但在一个连接上控制窗口,还在一个连接中的每个steam控制窗口。
3. QUIC源码分析
? 由于谷歌chromium源码太过庞大,我们这里采用github上使用go实现的quic-go来分析quic的实现过程.
3.1 客户端部分
func DialAddr(
addr string,
tlsConf *tls.Config,
config *Config,
) (EarlySession, error) {
return DialAddrContext(context.Background(), addr, tlsConf, config)
}
? 客户端采用 DialAddrEarly来向服务端创建一个quic连接
addr 存储服务器的地址
tlsConf tls加密相关配置
config 链接相关配置
// Config contains all configuration data needed for a QUIC server or client.
type Config struct {
// The QUIC versions that can be negotiated.
Versions []VersionNumber
// The length of the connection ID in bytes.
ConnectionIDLength int
// HandshakeIdleTimeout is the idle timeout before completion of the handshake.
HandshakeIdleTimeout time.Duration
// MaxIdleTimeout is the maximum duration that may pass without any incoming network activity.
MaxIdleTimeout time.Duration
// AcceptToken determines if a Token is accepted.
AcceptToken func(clientAddr net.Addr, token *Token) bool
// The TokenStore stores tokens received from the server.
TokenStore TokenStore
// MaxReceiveStreamFlowControlWindow is the maximum stream-level flow control window for receiving data.
MaxReceiveStreamFlowControlWindow uint64
// MaxReceiveConnectionFlowControlWindow is the connection-level flow control window for receiving data.
MaxReceiveConnectionFlowControlWindow uint64
// MaxIncomingStreams is the maximum number of concurrent bidirectional streams that a peer is allowed to open.
MaxIncomingStreams int64
// MaxIncomingUniStreams is the maximum number of concurrent unidirectional streams that a peer is allowed to open.
MaxIncomingUniStreams int64
// The StatelessResetKey is used to generate stateless reset tokens.
StatelessResetKey []byte
// KeepAlive defines whether this peer will periodically send a packet to keep the connection alive.
KeepAlive bool
// Datagrams will only be available when both peers enable datagram support.
EnableDatagrams bool
Tracer logging.Tracer
}
? 以上是关于quic连接的相关配置。我们再来看 DialAddrEarlyContext(context.Background(), addr, tlsConf, config), 其中context是go中并发编程管理上下文切换的标准库。与本次的quic协议无关这里我们不多做叙述。
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)
}
? 这里新增了一个参数 use0RTT 我们知道tcp协议在通信之前需要先握手,这个rtt的时间内发送的帧是无法携带有效信息的,但是采用quic通信的双方可以使采用0RTT的方式通信(但是这种方式也是有前提的,如果双方是第一次建立通信,就不可以使用这种方式).下面我们来看一下diaiContext() 这个函数.
func dialContext(
ctx context.Context,
pconn net.PacketConn,
remoteAddr net.Addr,
host string,
tlsConf *tls.Config,
config *Config,
use0RTT bool,
createdPacketConn bool,
) (quicSession, error) {
// ...(0)...
if err := validateConfig(config); err != nil {
return nil, err
}
config = populateClientConfig(config, createdPacketConn)
packetHandlers, err := getMultiplexer().AddConn(pconn, config.ConnectionIDLength, config.StatelessResetKey, config.Tracer)
// ...(1)...
c, err := newClient(pconn, remoteAddr, config, tlsConf, host, use0RTT, createdPacketConn)
// ...(2)...
if err := c.dial(ctx); err != nil {
return nil, err
}
return c.session, nil
}
? 这个函数体内部在(0)处进行相关配置工作 packetHandlers, err := getMultiplexer().AddConn(pconn, config.ConnectionIDLength, config.StatelessResetKey, config.Tracer) packet的处理方式是多路复用 , 在(1)处创建了一个新的client,在(2)处进行与服务端的连接.最后返回一个session.
3.2 服务端部分
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地址,然后进入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
}
? listen函数中
- sessionHandler, err := getMultiplexer().AddConn(conn, config.ConnectionIDLength, config.StatelessResetKey, config.Tracer)添加多路复用连接;
- tokenGenerator, err := handshake.NewTokenGenerator(rand.Reader)新建一个token,交给服务端方便客户端进行连接时,将token交给客户端;
- s := &baseServer{} 将相关配置加入到baseServer中. go s.run()运行一个协程;
func (s *baseServer) run() {
defer close(s.running)
for {
select {
case <-s.errorChan:
return
default:
}
select {
case <-s.errorChan:
return
case p := <-s.receivedPackets:
if bufferStillInUse := s.handlePacketImpl(p); !bufferStillInUse {
p.buffer.Release()
}
}
}
}
? 进入一个死循环,等待收到消息,进行处理,并释放缓存,以及错误处理返回.
4 QUIC-GO运行
? 首先我们进入到example文件夹下。运行go build main.go
,然后运行./main
;
随后进入到client文件夹下面,go build main.go
,然后运行 ./main https://localhost:6212/demo/echo
;
我们可以看到服务端响应为 200:OK, 说明服务端和客户端都运行从正常。
5. 总结
? QUIC协议相比于之前的协议有了很大的进步,具备更低的延迟和更高的安全性。尤其是现在短视频,直播的兴起,非常适合QUIC协议的应用。但是QUIC协议目前还在草案阶段,相关网站应用的完善还有很远的路要走。本文在写作时参考了几位同学写的文章,对我完成此文提供了很大的帮助,在此感谢。
参考资料
[1]. https://www.cnblogs.com/CatYe/p/14179075.html
[2]. https://zhuanlan.zhihu.com/p/137073979
[3]. https://github.com/lucas-clemente/quic-go