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;
    }
}

該函數最終從數據幀里面將數據拆出來。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM