1. 什么是QUIC
QUIC(Quick UDP Internet Connections),即快速UDP?络连接,是被设计?在传输层的?络协议,最初由Google的Jim Roskind 提出,最初实现和部署在2012年,截??前仍然是?个因特?草案,但已经被?泛应?于Google浏览器和Google服务器之间。?前Chorme 、MicrosoftEdge 、Firefox 、Safari 均已经?持QUIC,尽管不常?。QUIC增加了?向连接的TCP?络应?程序的性能,它通过使?UDP在两个端点之间建??系列多路复?(multiplexing) 的连接实现这个?的,它同时被?来代替(obsolesce)TCP在?络层的作?,因此也被戏称为TCP/2.QUIC与HTTP/2 的多路复?连接紧密结合,允许多个数据流独?的到达终端,因此?个数据包与其他的数据流传输的数据包丢失?关。与之相对的是,TCP如果有任何数据包的丢失或延迟,就会发?队头阻塞QUIC的另?个?标是减少连接和传输时候的延迟,以及评估每?个?向的带宽来避免阻塞。它还将拥塞控制算法移动到两个端点的?户空间,?不是内核空间,根据QUIC的实现,这将会提升算法的性能。此外,当遇到预期的错误的时候,QUIC协议可以使?前向纠错(forward error correction)FEC 来提升性能。2018年10?,IETF的HTTP和QUIC?作组共同决定将QUIC上的HTTP映射称为HTTP/3 ,以使其在全球范围内标准化。
为什么需要QUIC
传统的TCP ?络通信协议旨在提供?个接?,然后再两个端?之间发送数据流。TCP的传输需要保证数据报按顺序来接收,如果发现接收顺序错误,就需要使??动重传请求来通知发送?重新发送数据包,同时建?连接的三次握?在复杂的?络环境和地理限制也是?个重要的考虑内容。此外,由于TCP设计像?个"数据管道" ,如果单个数据包有问题,后续的所有数据报的发送将会被阻塞。现代社会的应?场景对更低延迟、良好的传输性能的要求越来越?,于是提出?个新的解决?案就?分有必要了。
QUIC做了什么
QUIC的?标?乎等同于TCP连接,但是延迟却会更少。它通过两个更改来实现
1.减少连接期间的开销
2.提??络交换事件期间的性能。例如从wifi切换到移动?络能更快的切换
QUIC?致可以通过如下公式概扩
从公式可看出:QUIC协议虽然是基于UDP,但它不但具有TCP的可靠性、拥塞控制、流量控制等,且在TCP协议的基础上做了?些改进,?如避免了队?阻塞;另外,QUIC协议具有TLS的安全传输特性,实现了TLS的保密功能,同时?使?更少的RTT建?安全的会话。
2.QUIC特点及优势
quic 在功能上等同于 TCP + TLS + HTTP/2, 但是基于 UDP 之上。 QUIC 对比 TCP + TLS + HTTP/2 的优势有连接建立的延迟、弹性拥塞控制、没有头部阻塞的多路复用、Header 以及 Payload 的认证与加密、stream 以及 connection的流控管理、前向纠错、连接迁移等。
连接建立的延迟
简单来说, quic 的握手通常只需要 0 个RTT,对比 TCP + TLS 的话通常需要1-3 个RTT 当一个 quic clent 首次连接到一个 server 的时候, client 必须执行 1 RTT的握手,这是为了获取到完成握手的必要信息。client 发送一个不完全的 hello(CHLO)。 server 发送一个 rejection(REJ)包,REJ 包中包含有 client 接下来所需要的信息。 其中包含一个源地址的token,这个token会在接下来的 CHLO 中用于验证 client 的IP。 REJ 包中叶包含了 server 端的证书,接下来client 端发送 CHLO 的时候,client 端可以用之前连接中缓存认证信息来立即发送加密的请求。
弹性拥塞控制
quic 有可插拔的拥塞控制, 相比 TCP quic有更多的信号,因此可以给拥塞控制算法提供更多的信息, 同时 google 在实现 quic 的时候也重新实现了 TCP的拥塞控制, 同时也在实验其他的方案。 对于原始的数据包以及重传的包都携带一个新的序列号,这样发送方就能区分 ACK 是原始包的ACK或者是重传包的ACK。 QUIC 的ACK 包也携带了每一个帧的延迟信息,这样使得RTT计算更精确
没有头部阻塞的多路复用
- 基于 TCP 的HTTP/2 会受到 TCP 的头部阻塞影响, 因为HTTP/2 的多路复用中的不同流是基于TCP的单个子节流,一个TCP数据报文的丢失会阻塞所有的数据报的传输,直到重传包的到来
- 因为quic是为了多路复用而设计的,所以当一个流的数据包丢失只会影响到特定的流,其他的流可以正常的传输数据
- 警告: quic 现在压缩头部是通过 HTTP/2 HPACK 头部压缩,将会对header 加强header帧的头部阻塞
stream 以及 connection的流控管理
- quic 提供了基于 stream 和基于 connection 的流控管理
- quic 数据的收发过程中都在特定stream上进行。接收者发送一个WINDOW_UPDATE 帧来告知自己希望接受的数据偏移量。 允许同伴发送更多的数据
- 对于 connection 的流控管理和 stream 的类似,但是对于所有流的集合而言的。
前向纠错
quic 现在使用简单的 XOR-based FEC 计划, 当一组packet中的一个 packet 丢失后, quic 可以根据 FEC 帧和组里的其他帧恢复出这组帧里的数据。
连接迁移
一个TCP连接采用 4元组来标识每一个连接(clientIP:clientPort:serverIp:serverPort), 所以当四元组的某一项发生改变的时候,(例如 client 从4G 切换到WIFI), 那么TCP的连接就会断开。
QUIC 的连接通过一个随机的64位 connectionID来标识每一个连接, 所以当IP地址改变或者NAT重新绑定的时候 Connection ID 保持不变,QUIC 也提供了连接迁移时候的自动加密验证。迁移后的连接使用同样的 session Key 来加密和解密数据帧。
QUIC握手流程
具体握手过程如下:
(1) 客户端判断本地是否已有服务器的全部配置参数,如果有则直接跳转到(5),否则继续
(2) 客户端向服务器发送inchoate client hello(CHLO)消息,请求服务器传输配置参数
(3) 服务器收到CHLO,回复rejection(REJ)消息,其中包含服务器的部分配置参数
(4) 客户端收到REJ,提取并存储服务器配置参数,跳回到(1)
(5) 客户端向服务器发送full client hello消息,开始正式握手,消息中包括客户端选择的公开数。此时客户端根据获取的服务器配置参数和自己选择的公开数,可以计算出初始密钥。
(6) 服务器收到full client hello,如果不同意连接就回复REJ,同(3);如果同意连接,根据客户端的公开数计算出初始密钥,回复server hello(SHLO)消息,SHLO用初始密钥加密,并且其中包含服务器选择的一个临时公开数。
(7) 客户端收到服务器的回复,如果是REJ则情况同(4);如果是SHLO,则尝试用初始密钥解密,提取出临时公开数
(8) 客户端和服务器根据临时公开数和初始密钥,各自基于SHA-256算法推导出会话密钥
(9) 双方更换为使用会话密钥通信,初始密钥此时已无用,QUIC握手过程完毕。之后会话密钥更新的流程与以上过程类似,只是数据包中的某些字段略有不同
Packet格式
QUIC 有四种 packet 类型
- Version Negotiation Packets
- Frame Packets
- FEC Packets
- Public Reset Packets
所有的 QUIC packet 大小都应该低于路径的 MTU, 路径 MTU 的发现由进程负责实现, QUIC 在IPv6 最大支持 1350 的packet,IPv4最大支持 1370
QUIC普通帧头部
所有的QUIC 帧都有一个 2-21 字节的头部, 头部的格式如下
3. ?个简单的QUIC通信实现
分析的源码基于quic-go :quic-go
先简单的贴出服务端和客户端的核?代码:
服务端
listener, err := quic.ListenAddr(addr, generateTLSConfig(), nil)
客户端
session, err := quic.DialAddr(addr, tlsConf, nil)
3.1 源码分析
先来分析客户端:
tlsConf := &tls.Config{ InsecureSkipVerify: true, NextProtos: []string{"quic-echo-example"}, }
?先在QUIC 配置TLS 来保证安全性
session, err := quic.DialAddr(addr, tlsConf, nil)
拨号,即连接指定的IP地址。session 类似于TCP/IP的套接字
stream, err := session.OpenStreamSync(context.Background())
创建流,在stream 上发送和接收信息。context.Background() 类似于管道,相当于给予QUIC?个通信的手段
// 发送数据 stream.Write([]byte(message)) // 接收数据 buf := make([]byte, len(message)) io.ReadFull(stream, buf)
发送和接收数据,?此,?个完整的client就分析完了
继续分析服务端:
listener, err := quic.ListenAddr(addr, generateTLSConfig(), nil)
监听addr , generateTLSConfig() 代表TLS的配置,最后?个参数是quic.config ?般是nil
sess, err := listener.Accept(context.Background())
sess 与上?的session 类似
stream, err := sess.AcceptStream(context.Background())
创建stream ,接收信息,在server 端新建?个context 专?对这个连接进?通信
3.2 仿照?例??实现
分析完上?主要的代码之后,我们现在??实现?个简单的QUIC通信,实现客户端发送Hello ,服务端发送Hi 。
为了?便,我们把客户端和服务端写到?个?件夹?。
const addr = "localhost:6688" const clientMessage = "Hello" const serverMessage = "Hi"
?先定义?下本地监听端?号和要发送的数据
客户端:
session, err := quic.DialAddr(addr, tlsConf, nil)
为了简单,我们使?最简单的TLS 配置安全传输
stream, err := session.OpenStreamSync(context.Background())
创建个流,使?流传输数据
_, err = stream.Write([]byte(clientMessage))
客户端发送数据
_, err = stream.Read(buf)
读取服务端发来的信息到buf ,buf是?个字节数组
客户端?此完成
服务端:
listener, err := quic.ListenAddr(addr, generateTLSConfig(), nil)
服务端使?TLS 检测安全, generateTLSConfig() 类似于通?配置,可?定义
sess, err := listener.Accept(context.Background())
接收新的连接请求
stream, err := sess.AcceptStream(context.Background())
在刚才的连接请求上创建新的接收流
_, err = stream.Read(buf) _, err = stream.Write([]byte(serverMessage))
发送和接收数据
完整代码在附录
3.3 QUIC通信总结
因为QUIC是在UDP的基础上实现的,所以?部分与UDP的机制相同,下?是我根据??理解画的?张图
4. QUIC实现源码剖析
4.1 客户端分析
4.1.1 DialAddr
我们从客户端开始分析:
session, err := quic.DialAddr(addr, tlsConf, nil)
进入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
:根据代码注释,Session是一个在两个端点之间的connection
即连接。
继续深入源代码,我们知道DialAddr
只是一个封装函数,我们继续向下追溯DialAddrContext
,
func DialAddrContext(ctx context.Context,addr string,tlsConf *tls.Config,config *Config, ) (Session, error) { return dialAddrContext(ctx, addr, tlsConf, config, false) }
唯一的不同在于多加了一个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) //... udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) //... 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
函数干了什么,在函数里我们可以找到关键的代码行
//... packetHandlers, err := getMultiplexer().AddConn(pconn, config.ConnectionIDLength, config.StatelessResetKey, config.Tracer) //... c, err := newClient(pconn, remoteAddr, config, tlsConf, host, use0RTT, createdPacketConn)
getMultiplexer
建立一个多路复用,在前面我们分析原理的时候提到了多路复用(multiplexing)
。接着返回一个新的客户端结构newClient
。
至此,我们终于分析完了DialAddr
的调用路径。
4.1.2 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 }
流里面有接收流receiveStream
,发送流sendStream
,以及同步用的锁completedMutex
我们可以看看接受流里面有什么:
-
StreamID
:流ID -
io.Reader
:读接口 -
CancelRead
:是否禁止接收流 -
SetReadDeadline
:读超时设置
再看看发送流:
-
StreamID
:流ID -
io.Write
:写接口 -
CancelWrite
:是否禁止写 -
Context
:上面提到过的用来同步的结构体 -
SetWriteDeadline
:设置写超时
到这里,我们就分析完了OpenStreamSync
调用路径,最后返回一个流
4.1.3 Read
我们再来看看Stream
的读(接收)操作
_, err = stream.Read(buf)
找到具体的Read
函数
func (s *receiveStream) Read(p []byte) (int, error) { s.mutex.Lock() completed, n, err := s.readImpl(p) s.mutex.Unlock() //... }
s
就是我们基于的接收流的名字,发送具体的接收使用了readImpl
函数
看看readImpl
函数
if s.currentFrame == nil || s.readPosInFrame >= len(s.currentFrame) { s.dequeueNextFrame() }
看看还有没有可用来读的MaxReceiveStreamFlowControlWindow
,有的话读数据
copy(p[bytesRead:], s.currentFrame[s.readPosInFrame:])
把数据放到我们接收的地方,这里是从s
目前的窗口复制到p
里
4.1.4 Write
接着看Stream
的写(发送)操作
找到Write
函数,因为太长,下面分析一些关键的代码
if s.canBufferStreamFrame() && len(s.dataForWriting) > 0{ //... }
先检查还能不能发送
copy(s.nextFrame.Data[l:], s.dataForWriting)
接着讲发送的数据放到Frame
里面,调用底层发送出去
4.2 服务端分析
4.2.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) }
刚才我们在客户端分析过addr
,tls
,config
的意义,这里不再赘述
继续进入listenAddr
func listenAddr(addr string, tlsConf *tls.Config, config *Config, acceptEarly bool) (*baseServer, error) { udpAddr, err := net.ResolveUDPAddr("udp", addr) conn, err := net.ListenUDP("udp", udpAddr) serv, err := listen(conn, tlsConf, config, acceptEarly) return serv, nil }
前两个函数与客户端差不多,我们来看看listen
函数发生了什么
这个函数返回一个baseServer
,这是一个QUIC
的listener
,它是一个数据结构
主要添加了如下的结构体成员
-
sessionQueue
:一个客户端的Session
队列 -
sessionQueueLen
:客户端的Session
队列的长度
接着新建一个线程,不断地监听端口,等待一个新的客户端连接请求
4.2.2 Accept
sess, err := listener.Accept(context.Background())
看看Accept
源代码
func (s *baseServer) Accept(ctx context.Context) (Session, error) { return s.accept(ctx) }
继续查看accept
,关键代码为
atomic.AddInt32(&s.sessionQueueLen, -1)
当收到一个新的请求的时候添加到sessionQueue
中,返回一个客户端Session
,用这个Session
可以和客户端进行发送和接收数据
服务端的Write
和Read
不再分析,同样和客户端一样使用Stream
进行发送和接收
5. 与传统的TCP?较与应?前景
5.1 QUIC性能分析
业界应?情况:
● 腾讯QQ应?情况
● 微博移动端全??持QUIC协议
5.2 展望
虽然QUIC相比于以前的通信协议有更大的进步,能具有更低的延迟和更好的安全性,但应用落地依然还具有一段距离
- QUIC现在仍然是草案,虽然
Chormium
和QUIC-GO
是两个已经落地使用的协议,但距离大规模应用仍然具有距离 - 由于历年的潜规则, 很多路由器对于
UDP数据包
直接丢弃 - 网络服务商对UDP持消极态度
- 硬件的更新是遥遥无期的问题
但基于QUIC的优势,期待着QUIC正式成为互联网标准,并且大规模应用落地
我只是简单的分析了一下QUIC协议,受限于个人的水平,文章依然还有很多不足之处,请多多包涵
参考资料
[1]. https://en.wikipedia.org/wiki/QUIC
[2]. https://blog.csdn.net/chenhaifeng2016/article/details/79011059
[3]. https://docs.google.com/document/d/1gY9-YNDNAB1eip-RTPbqphgySwSNSDHLq9D5Bty4FSU/
edit
[4]. https://zhuanlan.zhihu.com/p/44980381
[5]. https://tools.ietf.org/html/draft-ietf-quic-transport-20#page-23
[6]. https://tools.ietf.org/html/draft-ietf-quic-recovery-20#page-4
[7]. https://zhuanlan.zhihu.com/p/32553477
[8]. https://tools.ietf.org/html/draft-ietf-quic-transport-20#ref-HTTP2
[9]. https://github.com/lucas-clemente/quic-go
[10]. https://www.zhihu.com/question/30519570/answer/1400925045
附录
实现QUIC客户端和服务端:Go语?
package main import ( "context" "crypto/rand" "crypto/rsa" "crypto/tls" "crypto/x509" "encoding/pem" "fmt" "github.com/lucas-clemente/quic-go" "math/big" "time" ) const addr = "localhost:4242" const clientMessage = "Hello" const serverMessage = "Hi" func main() { go func() { err := server() if err != nil { panic(err) } }() err := client() if err != nil { panic(err) } // 等待main和go程 执行完,防止server执行完自动结束 time.Sleep(time.Second * 5) } // 客户端 func client() error { tlsConf := &tls.Config{ InsecureSkipVerify: true, NextProtos: []string{"quic-echo-example"}, } session, err := quic.DialAddr(addr, tlsConf, nil) if err != nil { return err } stream, err := session.OpenStreamSync(context.Background()) if err != nil { return err } fmt.Printf("Client: Sending ‘%s‘\n", clientMessage) _, err = stream.Write([]byte(clientMessage)) if err != nil { return err } buf := make([]byte, 1024) _, err = stream.Read(buf) if err != nil { return err } fmt.Printf("Client: Got ‘%s‘\n", buf) return nil } // 服务端 func server() error { listener, err := quic.ListenAddr(addr, generateTLSConfig(), nil) if err != nil { return err } sess, err := listener.Accept(context.Background()) if err != nil { return err } stream, err := sess.AcceptStream(context.Background()) if err != nil { panic(err) } buf := make([]byte, 1024) _, err = stream.Read(buf) if err != nil { return err } fmt.Printf("Server: Got ‘%s‘\n", buf) fmt.Printf("Server: Sending ‘%s‘\n", serverMessage) _, err = stream.Write([]byte(serverMessage)) return err } func generateTLSConfig() *tls.Config { key, err := rsa.GenerateKey(rand.Reader, 1024) if err != nil { panic(err) } template := x509.Certificate{SerialNumber: big.NewInt(1)} certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) if err != nil { panic(err) } keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) tlsCert, err := tls.X509KeyPair(certPEM, keyPEM) if err != nil { panic(err) } return &tls.Config{ Certificates: []tls.Certificate{tlsCert}, NextProtos: []string{"quic-echo-example"}, } }