一次在小组内的关于Quic的分享

由于QUIC目前的应用还是和HTTP协议紧密结合的,因此我会先介绍HTTP的发展史,给读者一个更加壮阔的视野去分析为什么会有QUIC协议。

HTTP 发展简史

Ref : https://en.wikipedia.org/wiki/Hypertext_Transfer_Protocol (tips:维基中文和英文差距太大,建议看英文的)

相信大家都很熟悉HTTP协议了,这里只做一下简单的回顾HTTP的历史,

  • HTTP/0.9 (1989): 只有GET命令,并且只能传输HTML的字符串 ,不支持请求头

  • HTTP/1.0 (1996):

    • headers 添加了将结构化文本元数据(metadata)附加到请求和响应的能力,它可以修改客户端或服务端的行为。例如,encoding 和 content-type 类型 headers 允许 HTTP 不仅可以传输HTML,还可以传输任何类型的payload。增加了对不同内容的传输支持(mp3/mp4/pdf 等),

    • 引入了POST/HEAD 命令,允许client指明它想要执行的操作类型。

    • 引入状态码(status code),为客户端提供了一种方法,用于确认是否服务器已成功处理请求,如果没有,还可以了解发生了什么类型的错误。

    • 每一个resource request都是一个单独TCP连接。如下图

  • HTTP/1.1 (1997):

    • 默认采取长链接(Connection: keep-alive),复用TCP Connection。

    • HTTP pipelining :客户端可以pipeline的形式发送多个请求,提高通信效率,简单地说就是,客户端不再需要等待服务器响应请求之后,才能发送后续的HTTP 请求。HTTP pipeling要求服务器按照接收到的请求的顺序进行响应,因此,如果管道中的单个response缓慢(不是丢失),则对客户端的所有后续response都将相应地延迟。这种情况被称为HTTP层的队头阻塞

    • 域名分片,浏览器为每个域名建立多个连接,以实现并发请求。

    • 支持TLS


    短链接/长链接/管道化的区别

HTTP/1.x ,由于域名分片的原因, 浏览器打开每个站点都会需要 4 个到 8 个TCP连接。

  • HTTP/2 (2015)

    • HTTP/1.x 协议以换行符作为纯文本的分隔符,而 HTTP/2 将所有传输的信息分割为更小的消息和帧,并采用二进制格式编码。将一个TCP Connection分为若干个流(Stream),每个流中可以传输若干消息(Message),每个消息由若干最小的二进制帧(Frame)组成.用户的每个操作行为都被分配了一个流编号(Stream ID)

    TCP Connection : TCP 连接,包含 1 个或者多个 stream。所有通信都在一个 TCP 连接上完成,此连接可以承载任意数量的双向数据流。

    Stream 数据流:一个双向通信的数据流,包含 1 条或者多条 Message。每个数据流都有一个唯一的标识符和可选的优先级信息,用于承载双向消息。

    Message 消息:对应 HTTP/1.1 中的请求 request 或者响应 response,包含 1 个或者多个 Frame。

    Frame 数据帧:最小通信单位,以二进制压缩格式存放内容。来自不同数据流的帧可以交错发送,然后再根据每个帧头的数据流标识符重新组装。

    image-20221111222527287

    • 压缩HTTP的header,客户端和服务端都各自维护一个哈希表,不用每次请求和响应都发送重复的header字段。

    • 划分resource request的优先级

    • ​ 引入多路复用,解决HTTP/1.1的队头阻塞问题。 server不再需要以客户端request source的顺序来发送response。

      image-20221111222630431

    • server可以push 资源到客户端 (server push)

  • HTTP/3 (2022):选择UDP作为传输层协议,在UDP的基础上封了一层称为QUIC 默认使用QUIC进行传输

TCP & UDP 简介

 我们知道,TCP/IP四层模型提供了不同层次的抽象,这些抽象屏蔽了下层模型的差异化,为上层服务提供了统一的接口。举个例子

能够用来传输光信号的介质有很多,比如双绞线/同轴光缆/空气等等,但是我在网络冲浪的时候我不需要关心我的传输介质是啥,因为位于网络层以上的服务只需要知道网络接口层可以传输光信号就可以了,这就是网络接口层的抽象。假设你本来是用WIFI上网的,然后你突然接了条网线,只要你的DHCP服务不改你的IP地址,那么对上层服务(TCP/HTTP等)来说,是不感知这一变化的。

Why QUIC ?

在计算机科学的世界里, 新技术的出现都是为了解决某些存在的已有问题,我们只有在充分了解已有问题的前提下,才能更好地了解和把控新技术的发展。

所以我们可以先分析一下TCP和HTTPS 协作的过程中出现了哪些问题?

1. HTTP/2 多路复用 TCP 连接 会出现TCP层队头阻塞(Head Of Bottleneck)

 从HTTP简介中我们了解到,为了减少TCP连接的数量,从HTTP/1.1 开始 就支持在同一条TCP connection中 串行地传输不同的HTTP 资源。 这样做看起来并没有什么问题,减少了TCP connection对服务器资源的开销,也不用像HTTP/0.9,传一张几KB的gif图 也要单开一个TCP connection。

但是我们知道 本质上TCP connection 是类似于 FIFO 的串行队列,如果出现丢包的情况的话, 则会导致其他的HTTP request/response 被阻塞 ,因为TCP 的滑动窗口能够缓存乱序的数据包是有限的,并且TCP发送方感知到丢包,一般都需要一段时间(Fast Retransmit / TCP Segment TimeOut / SACK等)。下图很好的说明了HTTP/2的多路复用

导致这个问题真正的原因在于

  • 这是一条串行传输的TCP连接。

  • TCP不能无限制的缓存乱序的包。 如果TCP可以无限缓存的话 也会存在队头阻塞,只不过这个问题不会像现在这么显著。

2. TCP 3次握手 与TLS握手不同步

下面给出一个很形象的图,其中a 代表是双方首次建联 , b代表双方不是第一次建联。 这里就不展开TLS1.3 相对于TLS 1.2的优化,感兴趣的可以看看TLS1.3相对1.2的优化但是我们可以清楚了解下面

  • TLS/1.2 双方首次建联 需要 2个RTT ,再次建联需要 1个RTT
  • TLS/1.3 首次握手只需要 1 个RTT ,再次握手则需要0个RTT
TLS1.2 VS TLS1.3

但是无论如何 TCP 3次握手 都无法被忽略(1个 RTT)

 TCP握手和TLS 握手不同步是一个必然的结果,首先TCP协议在80年代就被提出,而TLS 协议则是在1999 年才被第一次以标准的形式公布。其次,并不是所有的上层应用都需要加密传输的,比如内网之间的HTTP连接,这也导致了TLS握手必须要在TCP握手成功之后 。这时候有个聪明的小李开始思考,我能不能在TCP握手的时候把TLS握手信息也带上,岂不美哉? 思路没有什么问题,但是RFC标准规定了TCP握手的时候不能携带数据, 这时候,聪明的小李又想,有没有一种可能 我在传输层实现里 带上TLS,这样就不需要多一次握手了。

3. TCP基于拥塞控制的假设 已经不再成立

 在古早时期的互联网,中心骨干网的带宽并没有那么大,如果互联网上的一半或者四分之一用户的TCP connection 都以相对高的速率传输数据,很容易就导致中心骨干网的路由阻塞,进而导致骨干网瘫痪的场景(骨干路由疯狂丢包),因此聪明的TCP 协议 使用了拥塞控制的方法,避免了这种情况的发生。通过拥塞控制,TCP发送方能感知到TCP connection 的网络情况,从而控制发送速率,来避免骨干网瘫痪的情况。但是这种假设在今天的互联网世界已经不再成立,对于绝大多数普通用户来说,就算同时往死里用,也不可能掀起太多的波澜。那么现如今导致TCP 拥塞控制发生的主要原因是什么呢? 没错,就是弱网环境下的丢包。弱网环境下的丢包 会让TCP 发送方误以为网络很堵塞,就会主动降低发送速率, 从而使我们弱网环境更加雪上加霜(答应我,一定要找一个信号好一点的厕所蹲坑好吗)。

4. TCP 面向连接思想的局限性

 学过计算机网络的我们都知道,一个TCP connection 是由一个四元组(source IP,source port, destination IP,destination port)唯一决定的,一旦四元组的任一个元素发生变化,TCP连接都会断掉, 如果想继续传输数据的话,只能重新建立新的 TCP connection。典型变化场景有NAT 重绑定 ,从WIFI 切换到4G/5G信号,高速行驶的动车上等。这时候,聪明的小李又想,有没有一种可能 四元组的变化 我不需要重新建立连接 就能继续传输数据呢?

5. TCP 位于操作系统内核的局限性

 我们都知道TCP/IP协议栈位于操作系统的内核,这也意味着如果我们对TCP进行修改的话,需要重新编译内核,然后重新启动。但是内核的更新换代并没有像应用层这么频繁,这也导致了如果想直接对TCP进行修订的话,十分地复杂,并且需要内核的更新。这对于绝大多数只追求稳定的设备来说是不可以接受的。这时候,聪明的小李又想, 有没有一种可能 在应用层实现类似TCP 的协议呢?

How QUIC solve these problem ?

那么我们来看一下上面我们总结的问题

  • HTTP/1.1 有队头阻塞,因为它需要完整地发送响应,并且不能多路复用它们

  • HTTP/2 通过引入“帧”(frames)标识每个资源块属于哪个“流”(stream)来解决HTTP/1.1的队头阻塞问题

  • 然而,TCP 不知道这些单独的“流”(streams),只是把所有的东西看作一个大流(1 big stream)

  • 如果一个 TCP 包丢失,所有后续的包都需要等待它的重传,即使它们包含来自不同流的无关联数据。TCP与HTTP/2协作时 可能会出现传输层队头阻塞。

因此聪明的你,肯定发现了如果我们想要解决TCP的队头阻塞问题, 那我们最关键的点是需要让传输层知道不同的、独立的流!这样,如果一个stream的某个frame丢失,传输层本身就知道,它的丢失只影响 相应的stream而不会阻塞其他stream。

那如果让我们在基于UDP的基础上实现QUIC的话 我们需要做什么呢?

  • 可靠传输

  • 感知到传输层不同的流

  • 感知TLS的握手

什么是可靠传输,如何在udp上实现

 其实所谓的可靠传输指的并不是传输层以下的可靠,因为传输层以下的绝对可靠是不可能实现的(涉及物理上的知识),因此所有谈可靠传输的实现 都是基于传输层而言的,而对于上层应用而言,只要应用层想要的数据能够1bit不差,都能按照正确的顺序投递(deliver)给应用层,那就认为下层抽象提供了可靠传输的服务。其实TCP和UDP 的最主要的区别在于segment header的区别,TCP为什么能够实现可靠传输的原因就是因为header 里面有着非常丰富的信息,可以让接收方根据这些信息来对这些可能存在的错误的segment

TCP header vs UDP header
进行排序/丢弃 等等操作。 因此 如果UDP 想要实现可靠传输的话,必须的也要像TCP header一样 携带segment的信息,那这一部分的数据放在哪呢? 毫无疑问,只能放在UDP datagram 的payload里面,然后发送方和接收方 按照一定的协议 去写和解析这一部分的数据。

举个例子,接收方和发送方可以约定 payload 的前20Bytes 是meta data,不是真正有效的负载,然后发送方在pack data的时候 先写这20Bytes的数据,接收方收到数据的时候先解析这20Bytes的数据 ,然后再决定如何处理这个segment

QUIC Frame Format
  1. QUIC实现可靠性

我们知道 TCP 通过序列号(seqNum)和ACK 响应 来实现可靠传输, 类似的QUIC也有packet_number,stream id,stream offset,以及ACK 来实现可靠性。

  • QUIC使用 Packet Number 代替了 TCP 的 sequence number,并且每个 Packet Number 都严格递增,也就是说就算 Packet N 丢失了,重传的 Packet N 的 Packet Number 已经不是 N,而是一个比 N 大的值。而且当数据包 Packet N 丢失后,只要有新的已接收数据包确认,当前窗口就会继续向右滑动。待发送端获知数据包 Packet N 丢失后,会将需要重传的数据包放到待发送队列,重新编号比如数据包 Packet N+M 后重新发送给接收端。

  • 同样的 QUIC 也采用了TCP 的SACK机制,来实现选择重传(selective retransmit),QUIC 接收方能够准确的告知发送方需要重传哪几个包。

而 TCP 呢,重传 segment 的 sequence number 和原始的 segment 的 Sequence Number 保持不变,也正是由于这个特性,引入了 Tcp 重传的歧义问题。(TCP重传歧义会导致RTT采样不准确)

1. QUIC 集成了TLS /1.3模块

QUIC 通过集成TLS 模块,来解决TLS握手不一致的问题,看下图,我们来看看 1个RTT的QUIC 握手,这种情况是建立在双方首次建立QUIC连接的情况下才需要的TLS握手,如果之前已经经历过TLS握手,则可以实现0-RTT握手。

0-RTT的过程

CH - Client Hello ,SH - Server Hello , CRYPTO 代表的是TLS frame 用来携带证书等数据。 1-RTT握手和0-RTT握手最大的差异就在于 Client 会额外发一个0-RTT的packet 告诉server 想要进行0-RTT的建立连接。

HTTP/2 VS HTTP/3

在HTTP/2 及以前的时代,连接的安全性和可靠性 是完全隔离开来的,TCP提供了连接的可靠性,TLS提供了连接的安全性。

2. QUIC协议 可以通过 stream id来区分不同的stream

其实在我看来QUIC的形式就是虚拟出多个TCP Connection,然后各不相干,来解决队头阻塞的问题。

3. QUIC 利用Connection ID 来唯一标识一个 QUIC Connection

我们来看一下QUIC的Frame 的类型

image-20221111224638051

就算我们知道了QUIC通信双方依赖于Connection ID来解决连接迁移的问题,我们依旧不知道这到底是怎么work起来的。 下面会简单分析一下链接迁移的过程。

connection migration的标志: Receiving a packet from a new peer address containing a non-probing frame indicates that the peer has migrated to that address.

  • 假设小明在刷Youtube视频,突然从WIFI 变更到移动网络,这个时候会出现两种情况
    • 小明的上层应用Youtube App 是不感知网络IP变化的,当他处理完Youtube 服务器的 UDP datagram的时候,依旧会给Youtube server 发ACK packet,这时候 Youtube server 能感知到小明的IP的地址变化了,会发一个 Probing Packet 来探测新链路的可达性。进行一些额外操作之后,Youtube Server 就能愉快的和小明的新IP继续通信了。
    • Youtube App ACK timeout了,然后触发了重发机制,然后会给Youtube server继续发ACK packet,然后就和上面的情况一致了。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
(Before connection migration)
       +--------------+   Non-probing Packet   +--------------+
       |    Client    |  ------------------->  |              |
       |(Source IP: 1)|  <-------------------  |              |
       +--------------+   Non-probing Packet   |              |
              |                                |              |
              |                                |              |
              |                                |              |
              v                                |              |
       +--------------+   Non-probing Packet   |              |
       |              |  ------------------->  |              |
       |              |  <-------------------  |              |
       |              |   Non-probing Packet   |              |
       |              |                        |              |
       |              |                        |    Server    |
       |              |     Probing Packet     |              |
       |              |    (PATH_CHALLENGE)    |              |
       |    Client    |  <-------------------  |              |
       |(Source IP: 2)|  ------------------->  |              |
       |              |     Probing Packet     |              |
       |              |     (PATH_RESPONSE)    |              |
       |              |                        |              |
       |              |     Probing Packet     |              |
       |              |    (PATH_CHALLENGE)    |              |
       |              |  ------------------->  |              |
       |              |  <-------------------  |              |
       |              |     Probing Packet     |              |
       |              |     (PATH_RESPONSE)    |              |
       +--------------+                        +--------------+
       (After connection migration)

根据RFC9000的解释,客户端在发起连接迁移的时候不希望网络中的观察者将新的路径和老的路径关联起来,会使用新的连接ID,如果对端没有本端提供的未使用的连接ID,将不能连接迁移成功;当然可以选择连接迁移的时候发送新的NEW_CONNECTION_ID帧。

4. QUIC 采取了前向纠错 (Forward Error Correction,FEC)的机制

啥是前向纠错呢? 简单来说就是类似汉明码,发送冗余的数据 然后接收方可如果发现丢包后,可以根据冗余的数据来恢复丢掉的数据。举个简单的例子,假设server 连续发了11个packet ,10个有效的数据 1个是冗余的校验包。当这10个packet 里面任意一个packet 丢失之后,client都可以利用这9个有效的数据和冗余校验包用异或计算出 丢失的数据包的内容。 缺点就是当丢失2个包的时候,没有办法正确的计算出丢失的包。当然这个算法也可以推广到 n+m个包 然后恢复m个丢失的数据,但是也有trade off 就是真实有效数据的负载。

前向纠错牺牲了每个数据包可以发送数据的上限,但是减少了因为丢包导致的数据重传,因为数据重传将会消耗更多的时间(包括确认数据包丢失、请求重传、等待新数据包等步骤的时间消耗)

真实世界的QUIC

Ref :RFC 9000 QUIC: A UDP-Based Multiplexed and Secure Transport

这是我在工位上访问wireshark.org时候的抓包(我的内网IP是10.95.15.149),我们可以清晰地看到这是一个0RTT的握手,仅在100ms以内 我和wireshark服务器就开始了正常的通信。我在第一次通信的时候我发了两个UDP datagram ,其中一个是Initial Packet ,一个是0-RTT Packet 告诉peer 我想要进行0-RTT 通信。同时也可以看到当我发起Intitial packet的时候,里面包含了很多不同类型的frame,有CRYPTO Frame(为了TLS1.3 握手),有PING Frame 有PADDING frame。

性能对比

Data From cloudflare,可以看到在建联的延迟方面,平均下降了30ms。

在加载不同大小页面的时间对比

对于 15KB 的小测试页面,HTTP/3 的平均加载时间为 443 毫秒,而 HTTP/2 的加载时间为 458 毫秒。然而,一旦将页面大小增加到 1MB,这种优势就消失了。

来看看一些真实的代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
typedef struct QuicContext {
    void* notifyparam;
    void(*notify)(int type, const char* log, void* user);
    void(*notify_net_status)(size_t connId, int bw, int rtt, float loss_rate, void* user);
    struct
    {
        int time_out;//default 10 * 1000ms
        int socket_recv_buffer_size;//default 65535
        int socket_send_buffer_size;//default 1024*1024
        int congestion_type;//0 Cubic Bytes, 1 BBR, 2 BBR v2
        int loss_detection_type;// 0 nack, 1 time based, 2 adaptive time based, 3 lazy fack
        int quic_version;//39, 43, 44
        QuicLogSeverity log_severity;
    } param;
    
    size_t connId;
    uint32_t streamId;
    bool stop;
    
    uint64_t dnsTime;

    UdpContext transport_ctx;
} QuicContext;

typedef struct UdpContext {
    int sock;
    
    socklen_t addrlen;
    struct sockaddr_storage addr;
    int timeout;
    // if udp_handle is not NULL, sockaddr_storage will be ignored
    void* udp_handle;
    
    int (*udp_read)(void *ctx, unsigned char* buf, size_t size);
    int (*udp_write)(void *ctx, unsigned char* buf, size_t size);
} UdpContext;

兴趣 Question 环节

1. Why QUIC implement over UDP ?

2. What is the best advantage of QUIC ?

  0-RTT / 弱网环境的优化 / 解决了队头阻塞的问题 / 拥塞控制放在了用户层

4. What is cons of QUIC ?

  • QUIC内置了TLS
  • 一个QUIC 可以携带多个stream的frame
  • QUIC无法保证跨stream的有序性

5. Would QUIC be TCP killer ?

从应用场景的考虑,Quic 解决了弱网环境的问题,提高了网络传输的下限。QUIC 提供了0-RTT 带来了更低的延迟。但是

6. How could i grasp a sense of QUIC ?

 用wireshark 抓包, 一个教程QUIC教程

当 Chrome 向之前从未发过请求的服务端发出请求时,它不知道对方是否支持 QUIC,因此先通过 TCP 发送第一个请求。服务器响应该请求以后,要发送 Alt-Svc HTTP 响应头告诉 chrome 它支持 QUIC。 (例如,响应头中 “alt-svc: quic=":443”; ma=2592000; v=“44,43,39,35” 告诉 Chrome 服务端支持端口443上的QUIC,且支持的版本号是 44,43,39,35,max-age 为 2592000 秒)。 现在 Chrome 知道服务端支持 QUIC,于是尝试使用 QUIC 来进行下一个请求。发出请求后,Chrome 将采取 QUIC 和 TCP 竞争的方式与服务端建立连接。(建立这些连接,但是不发送请求)如果第一个请求通过 TCP 发出,TCP 赢得竞争,第二个请求将通过 TCP 发出。 在随后的某个时刻,QUIC 如果一旦连接成功,将来所有请求都将通过 QUIC 连接发送。 所以 QUIC 的协议发现过程是通过识别响应头中的特殊字段实现的。

7. Can i design a transport protocol better than QUIC ?

 想想QUIC 有什么缺点,你可以解决 or 想想你可以用什么更优雅的方法来解决TCP的问题。

番外

 想搓一搓TCP协议吗? 来吧.

0%