一個 TCP 發送緩衝區問題的解析

最近遇到一個問題,簡化模型以下:app

image

Client 建立一個 TCP 的 socket,並經過 SO_SNDBUF 選項設置它的發送緩衝區大小爲 4096 字節,鏈接到 Server 後,每 1 秒發送一個 TCP 數據段長度爲 1024 的報文。Server 端不調用 recv()。預期的結果分爲如下幾個階段:socket

Phase 1 Server 端的 socket 接收緩衝區未滿,因此儘管 Server 不會 recv(),但依然能對 Client 發出的報文回覆 ACK;
Phase 2 Server 端的 socket 接收緩衝區被填滿了,向 Client 端通告零窗口(Zero Window)。Client 端待發送的數據開始累積在 socket 的發送緩衝區;
Phase 3 Client 端的 socket 的發送緩衝區滿了,用戶進程阻塞在 send() 上。tcp

實際執行時,表現出來的現象也"基本"符合預期。不過當咱們在 Client 端經過 ss -nt 不時監控 TCP 鏈接的發送隊列長度時,發現這個值居然從 0 最終增加到 14480,它輕鬆地超了以前設置的 SO_SNDBUF 值(4096)spa

# ss -nt
State   Recv-Q   Send-Q         Local Address:Port              Peer Address:Port
ESTAB   0        0              192.168.183.130:52454           192.168.183.130:14465
State   Recv-Q   Send-Q         Local Address:Port              Peer Address:Port
ESTAB   0        1024           192.168.183.130:52454           192.168.183.130:14465
State   Recv-Q   Send-Q         Local Address:Port              Peer Address:Port
ESTAB   0        2048           192.168.183.130:52454           192.168.183.130:14465
......
State   Recv-Q   Send-Q         Local Address:Port              Peer Address:Port
ESTAB   0        13312          192.168.183.130:52454           192.168.183.130:14465
State   Recv-Q   Send-Q         Local Address:Port              Peer Address:Port
ESTAB   0        14336          192.168.183.130:52454           192.168.183.130:14465
State   Recv-Q   Send-Q         Local Address:Port              Peer Address:Port
ESTAB   0        14480          192.168.183.130:52454           192.168.183.130:14465

有必要解釋一下這裏的 Send-Q 的含義。咱們知道,TCP 是的發送過程是受到滑動窗口限制。3d

image

這裏的 Send-Q 就是發送端滑動窗口的左邊沿到全部未發送的報文的總長度。code

那麼爲何這個值超過了 SO_SNDBUF 呢?blog

雙倍 SO_SNDBUF

當用戶經過 SO_SNDBUF 選項設置套接字發送緩衝區時,內核將其記錄在 sk->sk_sndbuf 中。隊列

@sock.c: sock_setsockopt
{
   case SO_SNDBUF:
       .....
       sk->sk_sndbuf = mat_x(u32, val * 2, SOCK_MIN_SNDBUF)
}

注意,內核在這裏玩了一個小 trick,它在 sk->sk_sndbuf 記錄的的不是用戶設置的 val, 而是 val 的兩倍進程

也就是說,當 Client 設置 4096 時,內核記錄的是 8192 !ip

那麼,爲何內核須要這麼作呢? 我認爲是由於內核用 sk_buff 保存用戶數據有額外的開銷,好比 sk_buff 結構自己、以及 skb_shared_info 結構,還有 L二、L三、L4 層的首部大小.這些額外開銷天然會佔據發送方的內存緩衝區,但卻不該該是用戶須要 care 的,因此內核在這裏將這個值翻個倍,保證即便有一半的內存用來存放額外開銷,也能保證用戶的數據有足夠內存存放。

可是,問題現象還不能解釋,由於即便是 8192 字節的發送緩衝區內存所有用來存放用戶數據(額外開銷爲 0,固然這是不可能的),也達不到 Send-Q 最後達到的 14480 。

sk_wmem_queued

既然設置了 sk->sk_sndbuf, 那麼內核就會在發包時檢查當前的發送緩衝區已使用內存值是否超過了這個限制,前者使用 sk->wmem_queued 保存。

須要注意的是,sk->wmem_queued = 待發送數據佔用的內存 + 額外開銷佔用的內存,因此它應該大於 Send-Q

@sock.h 
bool sk_stream_memory_free(const struct sock* sk)
{
    if (sk->sk_wmem_queued >= sk->sk_sndbuf)  // 若是當前 sk_wmem_queued 超過  sk_sndbuf,則返回 false,表示內存不夠了
        return false;
    .....
}

sk->wmem_queued 是不斷變化的,對 TCP socket 來講,當內核將 skb 塞入發送隊列後,這個值增長 skb->truesize (truesize 正如其名,是指包含了額外開銷後的報文總大小);而當該報文被 ACK 後,這個值減少 skb->truesize。

tcp_sendmsg

以上都是鋪墊,讓咱們來看看 tcp_sendmsg 是怎麼作的。總的來講內核會根據發送隊列(write queue)是否有待發送的報文,決定是 建立新的 sk_buff,或是將用戶數據追加(append)到 write queue 的最後一個 sk_buff

int tcp_sendmsg(struct sock *sk, struct msghdr *msg, size_t size)
{
    mss_now = tcp_send_mss(sk, &size_goal, flags);
    
    // code committed
    while (msg_data_left(msg)) {
        int copy = 0;
        int max = size_goal;

        skb = tcp_write_queue_tail(sk);
        if (tcp_send_head(sk)) {
            ......
            copy = max - skb->len;
        }

        if (copy <= 0) {
        /* case 1: alloc new skb */
new_segment:
            if (!sk_stream_memory_free(sk))
                goto wait_for_sndbuf;  // 若是發送緩衝區滿了 就阻塞進程 而後睡眠

            skb = sk_stream_alloc_skb(sk,
                          select_size(sk, sg),
                          sk->sk_allocation,
                          skb_queue_empty(&sk->sk_write_queue));
        }
        ......
        /* case 2: copy msg to last skb */
        ......
}

Case 1.建立新的 sk_buff

在咱們這個問題中,Client 在 Phase 1 是不會累積 sk_buff 的。也就是說,這時每一個用戶發送的報文都會經過 sk_stream_alloc_skb 建立新的 sk_buff。在這以前,內核會檢查發送緩衝區內存是否已經超過限制,而在Phase 1 ,內核也能經過這個檢查。

static inline bool sk_stream_memory_free(const struct sock* sk)
{
    if (sk-?sk_wmem_queued >= sk->sk_sndbuf)
        return false;
    ......    
}

Case 2.將用戶數據追加到最後一個 sk_buff

而在進入 Phase 2 後,Client 的發送緩衝區已經有了累積的 sk_buff,這時,內核就會嘗試將用戶數據(msg中的內容)追加到 write queue 的最後一個 sk_buff。須要注意的是,這種搭便車的數據也是有大小限制的,它用 copy 表示

@tcp_sendmsg

int max = size_goal;

copy = max - skb->len;

這裏的 size_goal 表示該 sk_buff 最多能容納的用戶數據,減去已經使用的 skb->len, 剩下的就是還能夠追加的數據長度。

那麼 size_goal 是如何計算的呢?

tcp_sendmsg
  |-- tcp_send_mss
       |-- tcp_xmit_size_goal
       
static unsigned  int tcp_xmit_size_goal(struct sock* sk, u32 mss_now, int large_allowed)
{
    if (!large_allowed || !sk_can_gso(sk))
        return mss_now;        
    .....
    size_goal = tp->gso_segs * mss_now;
    .....
    return max(size_goal, mss_now);
}

繼續追蹤下去,能夠看到,size_goal 跟使用的網卡是否使能了 GSO 功能有關。

  • GSO Enable: size_goal = tp->gso_segs * mss_now
  • GSO Disable: size_goal = mss_now

在個人實驗環境中,TCP 鏈接的有效 mss_now 是 1448 字節,用 systemtap 加了探測點後,發現 size_goal 爲 14480 字節!是 mss_now 的整整 10 倍

因此當 Clinet 進入 Phase 2 時,tcp_sendmsg 計算出 copy = 14480 - 1024 = 13456 字節。

但是最後一個 sk_buff 真的能裝這麼多嗎?

在實驗環境中,Phase 1 階段建立的 sk_buff ,其 skb->len = 1024, skb->truesize = 4372 (4096 + 256,這個值的詳細來源請看 sk_stream_alloc_skb)

這樣看上去,這個 sk_buff 也容納不下 14480 啊。

再繼續看內核的實現,再 skb_copy_to_page_nocache() 拷貝以前,會進行 sk_wmem_schedule()

tcp_sendmsg
{
    /* case 2: copy msg to last skb */
    ......
    if (!sk_wmem_schedule(sk, copy))
        goto wait_for_memory;
    
    err = skb_copy_to_page_nocache(sk, &msg->msg_iter, skb, 
                                   pfrag->page,
                                   pfrag->offset,
                                   copy);
}

而在 sk_wmem_schedule 內部,會進行 sk_buff 的擴容(增大能夠存放的用戶數據長度).

tcp_sendmsg
  |--sk_wmem_schedule
        |-- __sk_mem_schedule
__sk_mem_schedule(struct sock* sk, int size, int kind)
{
    sk->sk_forward_alloc += amt * SK_MEM_QUANTUM;
    allocated = sk_memory_allocated_add(sk, amt, &parent_status);
    ......
    // 後面有一堆檢查,好比若是系統內存足夠,就不去看他是否超過 sk_sndbuf
}

經過這種方式,內核可讓 sk->wmem_queued 在超過 sk->sndbuf 的限制。

我並不以爲這樣是優雅而合理的行爲,由於它讓用戶設置的 SO_SNDBUF 形同虛設!那麼我能夠增麼修改呢?

  • 關掉網卡 GSO 特性
  • 修改內核代碼, 將檢查發送緩衝區限制移動到 while 循環的開頭。
while (msg_data_left(msg)) {
        int copy = 0;
        int max = size_goal;

+       if (!sk_stream_memory_free(sk))
+            goto wait_for_sndbuf;

        skb = tcp_write_queue_tail(sk);
        if (tcp_send_head(sk)) {
            if (skb->ip_summed == CHECKSUM_NONE)
                max = mss_now;
            copy = max - skb->len;
        }

        if (copy <= 0) {
new_segment:
            /* Allocate new segment. If the interface is SG,
             * allocate skb fitting to single page.
             */
-            if (!sk_stream_memory_free(sk))
-                goto wait_for_sndbuf;

全文完

個人我的主頁
網址二維碼.png

相關文章
相關標籤/搜索