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

剖析QUIC协议的工作原理与细节

最编程 2024-07-31 16:58:48
...

QUIC概述

Quic 全称 quick udp internet connection,“快速 UDP 互联网连接”,(和英文 quick 谐音,简称“快”)是由 google 提出的使用 udp 进行多路并发传输的协议。
Quic 相比现在广泛应用的 http2+tcp+tls 协议有如下优势:

  • 减少了 TCP 三次握手及 TLS 握手时间。
  • 改进的拥塞控制。
  • 避免队头阻塞的多路复用。
  • 连接迁移。
  • 前向冗余纠错。

QUIC核心特性连接建立延时低

0RTT 建连可以说是 QUIC 相比 HTTP2 最大的性能优势。那什么是 0RTT 建连呢?这里面有两层含义。

1、传输层 0RTT 就能建立连接。
2、加密层 0RTT 就能建立加密连接。

比如上图左边是 HTTPS 的一次完全握手的建连过程,需要 3 个 RTT。就算是 Session Resumption,也需要至少 2 个 RTT。

而 QUIC 呢?由于建立在 UDP 的基础上,同时又实现了 0RTT 的安全握手,所以在大部分情况下,只需要 0 个 RTT 就能实现数据发送,在实现前向加密的基础上,并且 0RTT 的成功率相比 TLS 的 Sesison Ticket 要高很多。

改进的拥塞控制

TCP 的拥塞控制实际上包含了四个算法:慢启动,拥塞避免,快速重传,快速恢复。
QUIC 协议当前默认使用了 TCP 协议的 Cubic 拥塞控制算法,同时也支持 CubicBytes, Reno, RenoBytes, BBR, PCC 等拥塞控制算法。

拥塞控制特点:

可插拔

指可以灵活的使⽤拥塞算法,⼀次选择⼀个或⼏个拥塞算法同时⼯作

  • 在应⽤层实现拥塞算法,⽽以前实现对应的拥塞算法,需要部署到操作系统内核中。现在可以更快
    的迭代升级
  • 不同的平台具有不同的底层和⽹络环境,现在我们能够灵活的选择拥塞控制,⽐如选择A选择
    Cubic,B则选择显示拥塞控制
  • 应⽤程序不需要停机和升级,我们在服务端进⾏的修改,现在只需要简单的reload⼀下就能实现不同拥塞控制切换

包编号单调递增

QUIC使⽤Packet Number,每个Packet Number严格递增,所以如果Packet N丢失了,重传Packet N的Packet Number已不是N,⽽是⼀个⼤于N的值。 这样可以确保不会出现TCP中的”重传歧义“问题。

禁止Reneging

QUIC不允许重新发送任何确认的数据包,也就禁止了接收方丢弃已经接受的内容。

更多ACK帧

TCP只能有3个ACK Block,但是Quic Ack Frame 可以同时提供 256 个 Ack Block,在丢包率⽐较⾼的⽹络下,更多的 Sack Block可以提升⽹络的恢复速度,减少重传量。

更精准的发送延迟

QUIC端点会测量接收到数据包与发送相应确认之间的延迟,使对等⽅可以保持更准确的往返时间估计

多路复用

HTTP2的最⼤特性就是多路复⽤,⽽HTTP2最⼤的问题就是队头阻塞。例如,HTTP2在⼀个TCP连接上同时发送3个stream,其中第2个stream丢了⼀个Packet,TCP为了保证数据可靠性,需要发送端重传丢失的数据包,虽然这时候第3个数据包已经到达接收端,但被阻塞了。

QUIC可以避免这个问题,因为QUIC的丢包、流控都是基于stream的,所有stream是相互独
⽴的,⼀条stream上的丢包,不会影响其他stream的数据传输。

前向纠错

为了从丢失的数据包中恢复⽽⽆需等待重新传输,QUIC可以⽤FEC数据包来补充⼀组数据包。与RAID-4相似,FEC数据包包含FEC组中数据包的奇偶校验。如果该组中的⼀个数据包丢失,则可以从FEC数据包和该组中的其余数据包中恢复该数据包的内容。发送者可以决定是否发送FEC分组以优化特定场景(例如,请求的开始和结束).
在这⾥需要注意的是:早期QUIC中使⽤的FEC算法是基于XOR的简单实现,不过IETF的QUIC协议标准中已经没有FEC的踪影,猜测是FEC在QUIC协议的应⽤场景中难以被⾼效的使⽤。

头部和负载的加密

由于使用了TLS 1.3,因此QUIC可以确保数据的可靠性,每次发送的数据都被加密。

更快的网络交换

QUIC允许更快地进行网络切换,例如将wifi切换为数据网络。
为了做到这一点,QUIC的连接标识发生了变化。
任何⼀条 QUIC 连接不再以 IP 及端⼝四元组标识,⽽是以⼀个 64 位的随机数作为 ID 来标识,这样就算 IP 或者端⼝发⽣变化时,只要 ID 不变,这条连接依然维持着,上层业务逻辑感知不到变化,不会中断,也就不需要重连。

启动切换

端点可以通过发送包含来⾃该地址的⾮探测帧的数据包,将连接迁移到新的本地地址。

响应切换

从包含⾮探测帧的新对等⽅地址接收到数据包表明对等⽅已迁移到该地址。

数据检测和拥塞控制

当响应后,中间可能会有数据损失和拥塞控制问题:新路径上的可⽤容量可能与旧路径上的容量不同。在旧路径上发送的数据包不应有助于新路径的拥塞控制或RTT估计。端点确认对等⽅对其新地址的所有权后,应⽴即为新路径重置拥塞控制器和往返时间估计器。

流量控制

QUIC同样可以针对接收方的缓冲进行设置,以防止发送方发送过快对接收方造成压力。

QUIC有两种控制方法:

  • 流控制:通过限制可以在任何流上发送的数据量来防⽌单个流占⽤整个连接的接收缓冲区。
  • 连接控制:通过限制所有流上以STREAM帧发送的流数据的总字节数,来防⽌发送⽅超出连接的接收⽅缓冲区容量。

QUIC 实现流量控制的原理⽐较简单:
通过 window_update 帧告诉对端⾃⼰可以接收的字节数,这样发送⽅就不会发送超过这个数量的数据。
通过 BlockFrame 告诉对端由于流量控制被阻塞了,⽆法发送数据。

Packet格式

QUIC 有四种 packet 类型:

  • Version Negotiation Packets
  • Frame Packets
  • FEC Packets
  • Public Reset Packets

所有的 QUIC packet 大小都应该低于路径的 MTU, 路径 MTU 的发现由进程负责实现, QUIC 在IPv6 最大支持 1350 的packet,IPv4最大支持 1370

QUIC编译与测试

实验环境:

  • Ubuntu 20.04
  • 磁盘空间300GB
  • 内存8G

获取源代码

安装编译依赖环境并添加至环境变量

git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
export PATH="$PATH:${HOME}/depot_tools"

获取chromium源代码

mkdir ~/chromium && cd ~/chromium
fetch --nohooks chromium

如果仅仅只是想编译最新版本源码(大约24gb)的话,可以添加--no-history能减少一些下载量,但是缺点就是无法切换到旧版本源码。

安装构建所需的依赖项

cd src
./build/install-build-deps.sh

如果在已经运行过install-build-deps.sh的机器重新检出chromium源码的话,可以不输入-nohooks这个选项,其会在fetch完毕后自动gclient runhooks。

gclient runhooks

设置并生成编译目录

 gn gen out/Default

开始编译 QUIC client and server

ninja -C out/Default quic_server quic_client

mkdir /tmp/quic-data
cd /tmp/quic-data
wget -p --save-headers https://www.example.org
cd www.example.org

修改index.html

vim index.html

删除(如果存在):"Transfer-Encoding: chunked"
删除(如果存在):"Alternate-Protocol: ..."
添加:X-Original-Url:https://www.example.org/

将后面内容全部删除,改成你想要测试的数据,例如改成json数据

生成CA证书并将其部署至系统

cd net/tools/quic/certs
./generate-certs.sh
cd 
certutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n quic -i chromium/src/net/tools/quic/certs/out/2048-sha256-root.pem

测试运行

运行quic_server,监听8888端口

cd chromium/src
./out/Default/quic_server \
    --quic_response_cache_dir=/tmp/quic-data/www.example.org \
    --certificate_file=net/tools/quic/certs/out/leaf_cert.pem \
    --key_file=net/tools/quic/certs/out/leaf_cert.pkcs8 \
    --host=127.0.0.1 \
    --port=8888&

运行quic-client客户端,向本地8888端口发送请求,顺便提一句如果没指定端口的话会默认采用80端口。

./out/Default/quic_client  \
    --host=127.0.0.1 \
    --port=8888 \
    https://www.example.org/ \
  	--allow_unknown_root_cert

未加flag --allow_unknown_root_cert 报错

测试成功,获取到了之前下载并修改后的www.example.org的index.html

数据重传逻辑分析

最新的quic代码里中的重传逻辑,实现了两种处理模式,一个是在connection层实现的重传,另一个是在session层实现的重传。在应用中只能启用一个,要有由connection负责重传,要么由session负责重传。session层的重传的启动开关是session_decides_what_to_write_。

QuicFrame的定义是:

struct QUIC_EXPORT_PRIVATE QuicFrame {
explicit QuicFrame(QuicStreamFrame* stream_frame);
 QuicFrameType type;
  union {
    // Frames smaller than a pointer are inline.
    QuicPaddingFrame padding_frame;
    QuicMtuDiscoveryFrame mtu_discovery_frame;
    QuicPingFrame ping_frame;

    // Frames larger than a pointer.
    QuicStreamFrame* stream_frame;
    QuicAckFrame* ack_frame;
    QuicStopWaitingFrame* stop_waiting_frame;
    QuicRstStreamFrame* rst_stream_frame;
    QuicConnectionCloseFrame* connection_close_frame;
    QuicGoAwayFrame* goaway_frame;
    QuicWindowUpdateFrame* window_update_frame;
    QuicBlockedFrame* blocked_frame;
  };
}

stream_frame是一个指针形式,但是在新的代码里,就是个结构体。这样的改变,就是为了实现这个session层面的重传逻辑。

struct QUIC_EXPORT_PRIVATE QuicFrame {
  explicit QuicFrame(QuicStreamFrame stream_frame);
      struct {
      QuicFrameType type;

      // TODO(wub): These frames can also be inlined without increasing the size
      // of QuicFrame: QuicStopWaitingFrame, QuicRstStreamFrame,
      // QuicWindowUpdateFrame, QuicBlockedFrame, QuicPathResponseFrame,
      // QuicPathChallengeFrame and QuicStopSendingFrame.
      union {
        QuicAckFrame* ack_frame;
        QuicStopWaitingFrame* stop_waiting_frame;
        QuicRstStreamFrame* rst_stream_frame;
        QuicConnectionCloseFrame* connection_close_frame;
        QuicGoAwayFrame* goaway_frame;
        QuicWindowUpdateFrame* window_update_frame;
        QuicBlockedFrame* blocked_frame;
        QuicApplicationCloseFrame* application_close_frame;
        QuicNewConnectionIdFrame* new_connection_id_frame;
        QuicRetireConnectionIdFrame* retire_connection_id_frame;
        QuicPathResponseFrame* path_response_frame;
        QuicPathChallengeFrame* path_challenge_frame;
        QuicStopSendingFrame* stop_sending_frame;
        QuicMessageFrame* message_frame;
        QuicCryptoFrame* crypto_frame;
        QuicNewTokenFrame* new_token_frame;
      };
    };
}

数据的重传,首先要判断数据的丢包。

bool QuicSentPacketManager::OnAckFrameEnd(QuicTime ack_receive_time){
  PostProcessAfterMarkingPacketHandled(last_ack_frame_, ack_receive_time,
                                       rtt_updated_, prior_bytes_in_flight);
}
void QuicSentPacketManager::PostProcessAfterMarkingPacketHandled(
    const QuicAckFrame& ack_frame,
    QuicTime ack_receive_time,
    bool rtt_updated,
    QuicByteCount prior_bytes_in_flight){
     InvokeLossDetection(ack_receive_time);
    }
void QuicSentPacketManager::InvokeLossDetection(QuicTime time) {
	MarkForRetransmission(packet.packet_number, LOSS_RETRANSMISSION);    
 }
 //在这里就进入分叉处理,
void QuicSentPacketManager::MarkForRetransmission(
    QuicPacketNumber packet_number,
    TransmissionType transmission_type) {
  //  记录要重传的数据包序号,后续connection层的重传会用到
  if (!session_decides_what_to_write()) {
    if (!unacked_packets_.HasRetransmittableFrames(*transmission_info)) {
      return;
    }
    if (!QuicContainsKey(pending_retransmissions_, packet_number)) {
      pending_retransmissions_[packet_number] = transmission_type;
    }
    return;
  }
//如果session_decides_what_to_write_开启,则由session负责重传。
  HandleRetransmission(transmission_type, transmission_info);    
}

connection负责的重传:

void QuicConnection::WritePendingRetransmissions()
{
  DCHECK(!session_decides_what_to_write());
  // Keep writing as long as there's a pending retransmission which can be
  // written.
  while (sent_packet_manager_.HasPendingRetransmissions() &&
         CanWrite(HAS_RETRANSMITTABLE_DATA)) {
    const QuicPendingRetransmission pending =
        sent_packet_manager_.NextPendingRetransmission();

    // Re-packetize the frames with a new packet number for retransmission.
    // Retransmitted packets use the same packet number length as the
    // original.
    // Flush the packet generator before making a new packet.
    // TODO(ianswett): Implement ReserializeAllFrames as a separate path that
    // does not require the creator to be flushed.
    // TODO(fayang): FlushAllQueuedFrames should only be called once, and should
    // be moved outside of the loop. Also, CanWrite is not checked after the
    // generator is flushed.
    {
      ScopedPacketFlusher flusher(this, NO_ACK);
      packet_generator_.FlushAllQueuedFrames();
    }
    DCHECK(!packet_generator_.HasQueuedFrames());
    char buffer[kMaxPacketSize];
    packet_generator_.ReserializeAllFrames(pending, buffer, kMaxPacketSize);
  }
}
void QuicPacketCreator::ReserializeAllFrames(
    const QuicPendingRetransmission& retransmission,
    char* buffer,
    size_t buffer_len) {
   SerializePacket(buffer, buffer_len);
  packet_.original_packet_number = retransmission.packet_number;   
   }

这里最终还是去stream中的send_buffer_获取数据。感兴趣的可以阅读packet_generator_.ReserializeAllFrames以下的处理的逻辑。packet_.original_packet_number记录数据包上次发送使用的序列号,下次发送的时候回调用QuicSentPacketManager::OnPacketSent将原来记录的丢失帧信息更新。

bool QuicSentPacketManager::OnPacketSent(
    SerializedPacket* serialized_packet,
    QuicPacketNumber original_packet_number,
    QuicTime sent_time,
    TransmissionType transmission_type,
    HasRetransmittableData has_retransmittable_data) {
    unacked_packets_.AddSentPacket(serialized_packet, original_packet_number,
                                 transmission_type, sent_time, in_flight);  
    }
    void QuicUnackedPacketMap::AddSentPacket(SerializedPacket* packet,
                                         QuicPacketNumber old_packet_number,
                                         TransmissionType transmission_type,
                                         QuicTime sent_time,
                                         bool set_in_flight){
  if (old_packet_number.IsInitialized()) {
    TransferRetransmissionInfo(old_packet_number, packet_number,
                               transmission_type, &info);
  }  
}

session负责的重传

在session层实现的重传,就不需要sent_packet_manager_.NextPendingRetransmission()获取pending中含有的可重传帧的原理的传输序号了(retransmission.packet_number)。也可以说QuicUnackedPacketMap中的记录的unacked_packets_信息就不太重要了。

void QuicSentPacketManager::HandleRetransmission(
    TransmissionType transmission_type,
    QuicTransmissionInfo* transmission_info) {
   unacked_packets_.NotifyFramesLost(*transmission_info, transmission_type); 
    }
    
void QuicUnackedPacketMap::NotifyFramesLost(const QuicTransmissionInfo& info,
                                            TransmissionType type) {
  DCHECK(session_decides_what_to_write_);
  for (const QuicFrame& frame : info.retransmittable_frames) {
    session_notifier_->OnFrameLost(frame);
  }
}

void QuicSession::OnFrameLost(const QuicFrame& frame){
  QuicStream* stream = GetStream(frame.stream_frame.stream_id);
  if (stream == nullptr) {
    return;
  }
  stream->OnStreamFrameLost(frame.stream_frame.offset,
                            frame.stream_frame.data_length,
                            frame.stream_frame.fin);
}
void QuicStream::OnStreamFrameLost(QuicStreamOffset offset,
                                   QuicByteCount data_length,
                                   bool fin_lost) {
  if (data_length > 0) {
    send_buffer_.OnStreamDataLost(offset, data_length);
  }
}
void QuicStreamSendBuffer::OnStreamDataLost(QuicStreamOffset offset,
                                            QuicByteCount data_length) {
  for (const auto& lost : bytes_lost) {
    pending_retransmissions_.Add(lost.min(), lost.max());
  }
}

再次将数据发送出去

void QuicStream::OnCanWrite() {
  if (HasPendingRetransmission()) {
    WritePendingRetransmission();
    // Exit early to allow other streams to write pending retransmissions if
    // any.
    return;
  }
}
void QuicStream::WritePendingRetransmission() {
      consumed = session()->WritevData(this, id_, pending.length, pending.offset,
                                can_bundle_fin ? FIN : NO_FIN);
}

参考:
· https://blog.****.net/u010643777/article/details/89178372
· https://cs.chromium.org/chromium/src/net/third_party/quiche/src/quic
· http://www.chromium.org/quic
· https://zhuanlan.zhihu.com/p/32553477