QUIC 协议介绍与分析

luckycdy 2022-01-19 18:30:47

QUIC的基本原理及简明配置使用指南

背景:

在长期发展中,TCP存在着以下问题:

TCP队头阻塞:由于TCP的可靠性机制,HTTP2是在一个TCP连接上并行发送多个资源,TCP队头阻塞会导致所有资源的传输需要停等,对网络质量影响较大。
握手延迟:TCP需要进行3次握手和4次挥手来建立和断开连接,至少需要消耗一次RTT。而主流的HTTPS协议,需要2消耗两次RTT握手才能建立连接。
网络中间设备僵化:网络中间设备在传输TCP协议时设置了很多潜规则,例如部分防火墙只允许通过80和443端口;部分NAT网关在转换网络地址时会重写传输层头部,可能导致双方无法使用新的传输格式;部分中间代理有时候出于安全需要,删除一些它不认识的选项字段。因此升级基于TCP的网络协议时,就必须要考虑和兼容这些影响。
协议僵化:TCP是在操作系统内核和中间设备固件中实现的。要对TCP进行大更改,就必须要通信双方升级操作系统,中间设备更新固件。这基本难以大规模实现。

因为这些问题的困扰,人们研发出了新一代的互联网传输协议 QUIC 并投入使用,并在后续被重命名为 HTTP/3。

概念:

QUIC 全称 Quick UDP Internet Connection, 是谷歌公司研发的一种基于 UDP 协议的低时延互联网传输协议。在2018年IETF会议中,HTTP-over-QUIC协议被重命名为HTTP/3,并成为 HTTP 协议的第三个正式版本。
QUIC协议就是基于UDP重新实现了一遍HTTP2的特性。
以下是HTTP协议的相关比较:

img

img

TCP最少需要花费1RTT的时间来建立连接。可以看到,TLS1.2下,首次建立连接,首先需要1次RTT建立连接(蓝色线),2次RTT交换密钥和加密策略(黑色线),然后开始通信。再次建立连接时,由于已缓存了密钥,因此少1次RTT。 TLS1.3和QUIC都采用了Diffie-Hellman密钥交换算法来交换密钥。该算法的优点是交换密钥只需要1次RTT。在QUIC下,只有首次建立连接交换密钥时消耗1RTT时间,再次连接时就是0RTT了。这已最大限度的减少握手延迟带来的影响。这个特性在连接延迟较大的移动网络上有较好的性能提升。

img

比较TCPQUIC
连接时间至少1RTT首次1RTT,再次0RTT
连接标识SrcIp,SrcPort,DstIp,DstPort64位随机数
流量控制滑动窗口机制在Connection和Stream两个级别分别进行流控
多路复用TCP协议有队头阻塞问题,如果某个资源的某个包丢失了,由于TCP是保证时序的,就会在接收端形成队头阻塞。TCP协议无法区分各个资源的包是否关联,因此会停止处理所有资源,直到丢包恢复不存在队头阻塞问题。如果某个资源的某个包丢失了,只会影响单个资源,其他资源会继续传输
升级迭代迭代周期长,以年为单位迭代周期短,以月/周为单位

优缺点:

优点:

1)利用缓存,显著减少连接建立时间;

2)改善拥塞控制,拥塞控制从内核空间到用户空间;

3)没有 head of line 阻塞的多路复用;

4)前向纠错,减少重传;

5)连接平滑迁移,网络状态的变更不会影响连接断线。

缺点:

首先是因为这些年性能的优化提升都针对 TCP ,使得 UDP 性能没有任何改进。当然随着 QUIC3 的发布,相信后续应该会有相对的投入。

其次是安全问题,也就是反射攻击,即伪造原地址。这个指发送数据包时的原地址是伪造的,不是真正的地址,会引起放大攻击。原因是 QUIC 握手过程是不对称的,特别是第一次请求时,客户端只需要发送几个字节的信息到服务器,而服务器则需要把证书等很多东西返还给客户端,这个不对称的机会造成了放大。草案 27 定义了两个规则和机制来限制反射攻击:客户端发送Initial包,即第一个数据包时,其长度必须在 1200 bytes以上,不足部分用 Padding 帧填充,同时,当服务端不确定客户端可靠性时,可以发送 Retry 包要求客户端再次提供验证信息。

QUIC源代码的构建和性能测评

开源实现:

目前支持 QUIC 协议的 web 服务只有 0.9 版本以后的 Caddy 。其他常用 web 服务如 nginx、apache 等都未开始支持。

开源实现有哪些:

1)Chromium

这个是官方支持的。优点自然很多,Google 官方维护基本没有坑,随时可以跟随 chrome 更新到最新版本。不过编译 Chromium 比较麻烦,它有单独的一套编译工具。暂时不建议考虑这个方案。

2)proto-quic

从 chromium 剥离的一个 QUIC 协议部分,但是其 github 主页已宣布不再支持,仅作实验使用。不建议考虑这个方案。

3)goquic

goquic 封装了 libquic 的 go 语言封装,而 libquic 也是从 chromium 剥离的,好几年不维护了,仅支持到 quic-36, goquic 提供一个反向代理,测试发现由于 QUIC 版本太低,最新 chrome 浏览器已无法支持。不建议考虑这个方案。

4)quic-go

quic-go 是完全用 go 写的 QUIC 协议栈,开发很活跃,已在 Caddy 中使用,MIT 许可,目前看是比较好的方案。

那么,对于中小团队或个人开发者来说,比较推荐的方案是最后一个,即采用 caddy 来部署实现 QUIC。caddy 这个项目本意并不是专门用来实现 QUIC 的,它是用来实现一个免签的 HTTPS web 服务器的(caddy 会自动续签证书)。而QUIC 只是它的一个附属功能。

性能评测

img

从上图的数据可以看出,QUIC的总体性能比HTTP2略有提升

img

从上图的数据可以看出,在弱网络、高丢包率的情况下,QUIC才能凸显其独特优势。

QUIC源代码分析

  1. 分析浏览器是否支持 QUIC:

img

  1. 分析网站是否支持 QUIC:

img

当出现如上图这行alt-svc: quic=xxx时,即表示该网站支持QUIC。该行具体的含义是服务器在443端口开启了QUIC支持,最大缓存时间是2592000秒(30天),支持的QUIC版本号是44、43、39。

  1. 部分客户端源代码分析

roundTripper,实现net.RoundTripper接口,使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)
}

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:一个接口,表示连接会话,通过它可以调用一些方法来完成后续操作。

receiveStream

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

sendSatream

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

客户端最后返回的是stream。

  1. 部分服务端源代码:

ListenAddr

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



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())


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

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

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

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地址作为key,连接管理器作为value。

参考:
QUIC网络协议简介
天下武功,唯'QUICK'不破,揭秘QUIC的五大特性及外网表现
QUIC协议和HTTP3.0技术研究
HTTP/3 Deep Dive

作者:NP118

...全文
334 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

566

社区成员

发帖
与我相关
我的任务
社区描述
软件工程教学新范式,强化专项技能训练+基于项目的学习PBL。Git仓库:https://gitee.com/mengning997/se
软件工程 高校
社区管理员
  • 码农孟宁
加入社区
  • 近7日
  • 近30日
  • 至今

试试用AI创作助手写篇文章吧