QUIC协议调研分析

编译QUIC

前置要求

  • git
  • gcc & g++
  1. 拉取depot_tools
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git

将其加入环境变量

export PATH="$PATH:YOUR_PATH/depot_tools"
  1. 拉取chromium
mkdir chromium
cd chromium
fetch --nohooks chromium --no-history chromium
  1. 安装依赖
cd src
./build/install-build-deps.sh
gclient runhooks
  1. 编译
gn gen out/Debug
ninja -C out/Debug quic_server quic_client

QUIC研究

介绍

QUIC(Quick UDP Internet Connections),即快速UDP网络连接,是被设计用在传输层的网络协议。
QUIC增加了面向连接的TCP网络应用程序的性能,它通过使用UDP在两个端点之间建立⼀系列多路复用的连接实现这个目的,它同时被用来代替(obsolesce)TCP在网络层的作用。
QUIC的另⼀个⽬标是减少连接和传输时候的延迟,以及评估每⼀个⽅向的带宽来避免阻塞。它还将拥塞控制算法移动到两个端点的⽤户空间,⽽不是内核空间,根据QUIC的实现,这将会提升算法的性能。

优势

QUIC的⽬标⼏乎等同于TCP连接,但是延迟却会更少。主要是由于以下的改变:

  • 减少连接期间的开销
  • 提高网络交换事件期间的性能。

另外QUIC相比较于TCP而言,在如下方面做的更好:

  • 拥塞控制
  • 前向纠错
  • 多路复用

建立连接

TCP为了建立连接需要三次握手,而QUIC只需要一次往返握手即可。

具体方法如下:

QUIC客户端第⼀次连接到服务器时,客户端必须执⾏1次往返握⼿,以获取完成握⼿所需的信息。客户端发送早期(empty)客户端Hello问候(CHLO) ,服务器发送拒绝(rejection) (REJ) ,其中包含客户端前进所需的信息,包括源地址令牌和服务器的证书。客户端下次发送CHLO时,可以使⽤以前连接中的缓存凭据来⽴即将加密的请求发送到服务器。

这导致,QUIC只需要在首次建立连接时需要一次RTT,往后的连接可以不再握手直接发送数据。

image

前⾯图⽚是首次连接需要1RTT,后⾯是之后的连接不需要RTT

拥塞控制

QUIC的拥塞控制是基于拥塞窗口的。和TCP相比拥塞算法没有多少不同,其主要多了如下的几个特性:

  • 可插拔
  • 包编号单调递增
  • 禁止Reneging
  • 更多ACK帧
  • 更精准的发送延迟

可插拔

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

  • 在应⽤层实现拥塞算法,⽽以前实现对应的拥塞算法,需要部署到操作系统内核中。现在可以更快
    的迭代升级
  • 不同的平台具有不同的底层和⽹络环境,现在我们能够灵活的选择拥塞控制,⽐如选择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允许更快地进行网络切换,例如jiangwifi切换为数据网络。

为了做到这一点,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普通帧头部

所有的QUIC 帧都有一个 2-21 字节的头部, 头部的格式如下

0        1        2        3        4        8
+--------+--------+--------+--------+--------+--- ---+
| Public | Connection ID (0, 8, 32, or 64) ... | ->
|Flags(8)| (variable length) |
+--------+--------+--------+--------+--------+--- ---+
9        10       11       12
+--------+--------+--------+--------+
| Quic Version (32) | ->
| (optional) |
+--------+--------+--------+--------+
13       14       15       16       17       18       19       20
+--------+--------+--------+--------+--------+--------+--------+--------+
| Sequence Number (8, 16, 32, or 48) |Private | FEC (8)|
| (variable length) |Flags(8)| (opt) |
+--------+--------+--------+--------+--------+--------+--------+--------+

从 Private Flags 开始的数据是加密过的

QUIC 代码分析

在开源实现的lsquic中自带了一个示例的echo_serverecho_client。这里将对改代码进行分析

static lsquic_conn_ctx_t *echo_server_on_new_conn (void *stream_if_ctx, lsquic_conn_t *conn)
{
    struct echo_server_ctx *server_ctx = stream_if_ctx;
    lsquic_conn_ctx_t *conn_h = calloc(1, sizeof(*conn_h));
    conn_h->conn = conn;
    conn_h->server_ctx = server_ctx;
    TAILQ_INSERT_TAIL(&server_ctx->conn_ctxs, conn_h, next_connh);
    LSQ_NOTICE("New connection!");
    print_conn_info(conn);
    return conn_h;
}

当连接建立的时候,会调用上述函数。该函数会构造一个新的conn_ctx,并调用TAILQ_INSERT_TAIL,将这个连接插入到队列结尾。

static void echo_server_on_conn_closed (lsquic_conn_t *conn)
{
    lsquic_conn_ctx_t *conn_h = lsquic_conn_get_ctx(conn);
    if (conn_h->server_ctx->n_conn)
    {
        --conn_h->server_ctx->n_conn;
        LSQ_NOTICE("Connection closed, remaining: %d", conn_h->server_ctx->n_conn);
        if (0 == conn_h->server_ctx->n_conn)
            prog_stop(conn_h->server_ctx->prog);
    }
    else
        LSQ_NOTICE("Connection closed");
    TAILQ_REMOVE(&conn_h->server_ctx->conn_ctxs, conn_h, next_connh);
    free(conn_h);
}

当链接断开的时候,上述函数会被调用,该函数会检测连接计数,并在合适的时候停止服务程序。最后调用TAILQ_REMOVE移除掉该链接。

static void echo_server_on_read (lsquic_stream_t *stream, lsquic_stream_ctx_t *st_h)
{
    struct lsquic_conn_ctx *conn_h;
    size_t nr;

    nr = lsquic_stream_read(stream, st_h->buf + st_h->buf_off++, 1);
    if (0 == nr)
    {
        LSQ_NOTICE("EOF: closing connection");
        lsquic_stream_shutdown(stream, 2);
        conn_h = find_conn_h(st_h->server_ctx, stream);
        lsquic_conn_close(conn_h->conn);
    }
    else if ('\n' == st_h->buf[ st_h->buf_off - 1 ])
    {
        /* Found end of line: echo it back */
        lsquic_stream_wantwrite(stream, 1);
        lsquic_stream_wantread(stream, 0);
    }
    else if (st_h->buf_off == sizeof(st_h->buf))
    {
        /* Out of buffer space: line too long */
        LSQ_NOTICE("run out of buffer space");
        lsquic_stream_shutdown(stream, 2);
    }
    else
    {
        /* Keep reading */;
    }
}

当有数据传输过来时,该函数会被调用,用于读取收到的数据。lsquic_stream_read函数会通过stream读取输入,返回读取到的字节数。

static void 
echo_server_on_write (lsquic_stream_t *stream, lsquic_stream_ctx_t *st_h)
{
    lsquic_stream_write(stream, st_h->buf, st_h->buf_off);
    st_h->buf_off = 0;
    lsquic_stream_flush(stream);
    lsquic_stream_wantwrite(stream, 0);
    lsquic_stream_wantread(stream, 1);
}

该函数用于服务器向客户端发送数据。其中lsquic_stream_write会将数据通过stream发送出去。

这里最重要的两个函数就是lsquic_stream_writelsquic_stream_read,这里分析这两个函数的调用过程。

ssize_t
lsquic_stream_write (lsquic_stream_t *stream, const void *buf, size_t len)
{
    struct iovec iov = { .iov_base = (void *) buf, .iov_len = len, };
    return lsquic_stream_writev(stream, &iov, 1);
}

lsquic_stream_write会调用lsquic_stream_writev,后者会控制传输的数据块数量。

ssize_t
lsquic_stream_writev (lsquic_stream_t *stream, const struct iovec *iov,
                                                                    int iovcnt)
{
    COMMON_WRITE_CHECKS();
    SM_HISTORY_APPEND(stream, SHE_USER_WRITE_DATA);

    struct inner_reader_iovec iro = {
        .iov = iov,
        .end = iov + iovcnt,
        .cur_iovec_off = 0,
    };
    struct lsquic_reader reader = {
        .lsqr_read = inner_reader_iovec_read,
        .lsqr_size = inner_reader_iovec_size,
        .lsqr_ctx  = &iro,
    };

    return stream_write(stream, &reader, SWO_BUFFER);
}

lsquic_stream_writev会检查写操作是否可以进行,然后初始化控制块,调用stream_write进行处理。

static ssize_t
stream_write (lsquic_stream_t *stream, struct lsquic_reader *reader,
                                                enum stream_write_options swo)
{
    const struct stream_hq_frame *shf;
    size_t thresh, len, frames, total_len, n_allowed, nwritten;
    ssize_t nw;

    len = reader->lsqr_size(reader->lsqr_ctx);
    if (len == 0)
        return 0;

    frames = 0;
    if ((stream->sm_bflags & (SMBF_IETF|SMBF_USE_HEADERS))
                                        == (SMBF_IETF|SMBF_USE_HEADERS))
        STAILQ_FOREACH(shf, &stream->sm_hq_frames, shf_next)
            if (shf->shf_off >= stream->sm_payload)
                frames += stream_hq_frame_size(shf);
    total_len = len + frames + stream->sm_n_buffered;
    thresh = lsquic_stream_flush_threshold(stream, total_len);
    n_allowed = stream_get_n_allowed(stream);
    if (total_len <= n_allowed && total_len < thresh)
    {
        if (!(swo & SWO_BUFFER))
            return 0;
        nwritten = 0;
        do
        {
            nw = save_to_buffer(stream, reader, len - nwritten);
            if (nw > 0)
                nwritten += (size_t) nw;
            else if (nw == 0)
                break;
            else
                return nw;
        }
        while (nwritten < len
                        && stream->sm_n_buffered < stream->sm_n_allocated);
        return nwritten;
    }
    else
        return stream_write_to_packets(stream, reader, thresh, swo);
}

该函数不会立刻传输数据,而是将数据缓存起来,知道达到上限之后才调用stream_write_to_packets进行装包发送。

对于lsquic_stream_read函数也是大致流程,但是他会调用lsquic_stream_readf处理实际的输入。

ssize_t
lsquic_stream_readf (struct lsquic_stream *stream,
        size_t (*readf)(void *, const unsigned char *, size_t, int), void *ctx)
{
    ssize_t nread;

    SM_HISTORY_APPEND(stream, SHE_USER_READ);

    if (stream_is_read_reset(stream))
    {
        if (stream->stream_flags & STREAM_RST_RECVD)
            stream->stream_flags |= STREAM_RST_READ;
        errno = ECONNRESET;
        return -1;
    }
    if (stream->stream_flags & STREAM_U_READ_DONE)
    {
        errno = EBADF;
        return -1;
    }
    if (stream->stream_flags & STREAM_FIN_REACHED)
    {
       if (stream->sm_bflags & SMBF_USE_HEADERS)
       {
            if ((stream->stream_flags & STREAM_HAVE_UH) && !stream->uh)
                return 0;
       }
       else
           return 0;
    }

    nread = stream_readf(stream, readf, ctx);
    if (nread >= 0)
        maybe_update_last_progress(stream);

    return nread;
}

该函数会检查流的状态,然后调用stream_readf进行实际的处理。并且根据需要,更新流的进度。

static ssize_t
stream_readf (struct lsquic_stream *stream,
        size_t (*readf)(void *, const unsigned char *, size_t, int), void *ctx)
{
    size_t total_nread;
    ssize_t nread;

    total_nread = 0;

    if ((stream->sm_bflags & (SMBF_USE_HEADERS|SMBF_IETF))
                                            == (SMBF_USE_HEADERS|SMBF_IETF)
            && !(stream->stream_flags & STREAM_HAVE_UH)
            && !stream->uh)
    {
        if (stream->sm_readable(stream))
        {
            if (stream->sm_hq_filter.hqfi_flags & HQFI_FLAG_ERROR)
            {
                LSQ_INFO("HQ filter hit an error: cannot read from stream");
                errno = EBADMSG;
                return -1;
            }
            assert(stream->uh);
        }
        else
        {
            errno = EWOULDBLOCK;
            return -1;
        }
    }

    if (stream->uh)
    {
        if (stream->uh->uh_flags & UH_H1H)
        {
            total_nread += read_uh(stream, readf, ctx);
            if (stream->uh)
                return total_nread;
        }
        else
        {
            LSQ_INFO("header set not claimed: cannot read from stream");
            return -1;
        }
    }
    else if ((stream->sm_bflags & SMBF_USE_HEADERS)
                                && !(stream->stream_flags & STREAM_HAVE_UH))
    {
        LSQ_DEBUG("cannot read: headers not available");
        errno = EWOULDBLOCK;
        return -1;
    }

    nread = read_data_frames(stream, 1, readf, ctx);
    if (nread < 0)
        return nread;
    total_nread += (size_t) nread;

    LSQ_DEBUG("%s: read %zd bytes, read offset %"PRIu64", reached fin: %d",
        __func__, total_nread, stream->read_offset,
        !!(stream->stream_flags & STREAM_FIN_REACHED));

    if (total_nread)
        return total_nread;
    else if (stream->stream_flags & STREAM_FIN_REACHED)
        return 0;
    else
    {
        errno = EWOULDBLOCK;
        return -1;
    }
}

该函数最终从数据帧里面将数据拆出来。

posted @ 2021-01-25 21:50  DevinMus  阅读(978)  评论(0编辑  收藏  举报