電子產(chǎn)業(yè)一站式賦能平臺

PCB聯(lián)盟網(wǎng)

搜索
查看: 78|回復(fù): 0
收起左側(cè)

一個(gè) TCP 發(fā)送緩沖區(qū)問題的解析

[復(fù)制鏈接]

475

主題

475

帖子

4237

積分

四級會員

Rank: 4

積分
4237
跳轉(zhuǎn)到指定樓層
樓主
發(fā)表于 2024-11-26 09:02:00 | 只看該作者 |只看大圖 回帖獎(jiǎng)勵(lì) |倒序?yàn)g覽 |閱讀模式
點(diǎn)擊上方“C語言與CPP編程”,選擇“關(guān)注/置頂/星標(biāo)公眾號
干貨福利,第一時(shí)間送達(dá)!
最近有小伙伴說沒有收到當(dāng)天的文章推送,這是因?yàn)槲⑿鸥牧送扑蜋C(jī)制,導(dǎo)致沒有星標(biāo)公眾號的小伙伴刷不到當(dāng)天推送的文章,無法接收到一些比較實(shí)用的知識和資訊。所以建議大家加個(gè)星標(biāo)??,以后就能第一時(shí)間收到推送了。

原文:https://segmentfault.com/a/1190000021488755
最近遇到一個(gè)問題,簡化模型如下:

Client 創(chuàng)建一個(gè) TCP 的 socket,并通過 SO_SNDBUF 選項(xiàng)設(shè)置它的發(fā)送緩沖區(qū)大小為 4096 字節(jié),連接到 Server 后,每 1 秒發(fā)送一個(gè) TCP 數(shù)據(jù)段長度為 1024 的報(bào)文。Server 端不調(diào)用 recv()。預(yù)期的結(jié)果分為以下幾個(gè)階段:
Phase 1 Server 端的 socket 接收緩沖區(qū)未滿,所以盡管 Server 不會 recv(),但依然能對 Client 發(fā)出的報(bào)文回復(fù) ACK;
Phase 2 Server 端的 socket 接收緩沖區(qū)被填滿了,向 Client 端通告零窗口(Zero Window)。Client 端待發(fā)送的數(shù)據(jù)開始累積在 socket 的發(fā)送緩沖區(qū);
Phase 3 Client 端的 socket 的發(fā)送緩沖區(qū)滿了,用戶進(jìn)程阻塞在 send() 上。
實(shí)際執(zhí)行時(shí),表現(xiàn)出來的現(xiàn)象也"基本"符合預(yù)期。
不過當(dāng)我們在 Client 端通過 ss -nt 不時(shí)監(jiān)控 TCP 連接的發(fā)送隊(duì)列長度時(shí),發(fā)現(xiàn)這個(gè)值竟然從 0 最終增長到 14480,它輕松地超了之前設(shè)置的 SO_SNDBUF 值(4096)
# 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 是的發(fā)送過程是受到滑動(dòng)窗口限制。

這里的 Send-Q 就是發(fā)送端滑動(dòng)窗口的左邊沿到所有未發(fā)送的報(bào)文的總長度。
那么為什么這個(gè)值超過了 SO_SNDBUF 呢?
雙倍 SO_SNDBUF當(dāng)用戶通過 SO_SNDBUF 選項(xiàng)設(shè)置套接字發(fā)送緩沖區(qū)時(shí),內(nèi)核將其記錄在 sk->sk_sndbuf 中。
@sock.c: sock_setsockopt
{
   case SO_SNDBUF:
       .....
       sk->sk_sndbuf = mat_x(u32, val * 2, SOCK_MIN_SNDBUF)
}注意,內(nèi)核在這里玩了一個(gè)小 trick,它在 sk->sk_sndbuf 記錄的的不是用戶設(shè)置的 val, 而是 val 的兩倍!
也就是說,當(dāng) Client 設(shè)置 4096 時(shí),內(nèi)核記錄的是 8192 !
那么,為什么內(nèi)核需要這么做呢?我認(rèn)為是因?yàn)閮?nèi)核用 sk_buff 保存用戶數(shù)據(jù)有額外的開銷,比如 sk_buff 結(jié)構(gòu)本身、以及 skb_shared_info 結(jié)構(gòu),還有 L2、L3、L4 層的首部大小.這些額外開銷自然會占據(jù)發(fā)送方的內(nèi)存緩沖區(qū),但卻不應(yīng)該是用戶需要 care 的,所以內(nèi)核在這里將這個(gè)值翻個(gè)倍,保證即使有一半的內(nèi)存用來存放額外開銷,也能保證用戶的數(shù)據(jù)有足夠內(nèi)存存放。
但是,問題現(xiàn)象還不能解釋,因?yàn)榧词故?8192 字節(jié)的發(fā)送緩沖區(qū)內(nèi)存全部用來存放用戶數(shù)據(jù)(額外開銷為 0,當(dāng)然這是不可能的),也達(dá)不到 Send-Q 最后達(dá)到的 14480 。
sk_wmem_queued既然設(shè)置了 sk->sk_sndbuf, 那么內(nèi)核就會在發(fā)包時(shí)檢查當(dāng)前的發(fā)送緩沖區(qū)已使用內(nèi)存值是否超過了這個(gè)限制,前者使用 sk->wmem_queued 保存。
需要注意的是,sk->wmem_queued = 待發(fā)送數(shù)據(jù)占用的內(nèi)存 + 額外開銷占用的內(nèi)存,所以它應(yīng)該大于 Send-Q
@sock.h
bool sk_stream_memory_free(const struct sock* sk)
{
    if (sk->sk_wmem_queued >= sk->sk_sndbuf)  // 如果當(dāng)前 sk_wmem_queued 超過  sk_sndbuf,則返回 false,表示內(nèi)存不夠了
        return false;
    .....
}sk->wmem_queued 是不斷變化的,對 TCP socket 來說,當(dāng)內(nèi)核將 skb 塞入發(fā)送隊(duì)列后,這個(gè)值增加 skb->truesize (truesize 正如其名,是指包含了額外開銷后的報(bào)文總大小);而當(dāng)該報(bào)文被 ACK 后,這個(gè)值減小 skb->truesize。
tcp_sendmsg以上都是鋪墊,讓我們來看看 tcp_sendmsg 是怎么做的?偟膩碚f內(nèi)核會根據(jù)發(fā)送隊(duì)列(write queue)是否有待發(fā)送的報(bào)文,決定是 創(chuàng)建新的 sk_buff,或是將用戶數(shù)據(jù)追加(append)到 write queue 的最后一個(gè) 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;  // 如果發(fā)送緩沖區(qū)滿了 就阻塞進(jìn)程 然后睡眠
            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.創(chuàng)建新的 sk_buff在我們這個(gè)問題中,Client 在 Phase 1 是不會累積 sk_buff 的。也就是說,這時(shí)每個(gè)用戶發(fā)送的報(bào)文都會通過 sk_stream_alloc_skb 創(chuàng)建新的 sk_buff。
在這之前,內(nèi)核會檢查發(fā)送緩沖區(qū)內(nèi)存是否已經(jīng)超過限制,而在Phase 1 ,內(nèi)核也能通過這個(gè)檢查。
static inline bool sk_stream_memory_free(const struct sock* sk)
{
    if (sk-?sk_wmem_queued >= sk->sk_sndbuf)
        return false;
    ......   
}Case 2.將用戶數(shù)據(jù)追加到最后一個(gè) sk_buff而在進(jìn)入 Phase 2 后,Client 的發(fā)送緩沖區(qū)已經(jīng)有了累積的 sk_buff,這時(shí),內(nèi)核就會嘗試將用戶數(shù)據(jù)(msg中的內(nèi)容)追加到 write queue 的最后一個(gè) sk_buff。
需要注意的是,這種搭便車的數(shù)據(jù)也是有大小限制的,它用 copy 表示
@tcp_sendmsg
int max = size_goal;
copy = max - skb->len;這里的 size_goal 表示該 sk_buff 最多能容納的用戶數(shù)據(jù),減去已經(jīng)使用的 skb->len, 剩下的就是還可以追加的數(shù)據(jù)長度。
那么 size_goal 是如何計(jì)算的呢?
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);
}繼續(xù)追蹤下去,可以看到,size_goal 跟使用的網(wǎng)卡是否使能了 GSO 功能有關(guān)。
GSO Enable:size_goal = tp->gso_segs * mss_nowGSO Disable: size_goal = mss_now
在我的實(shí)驗(yàn)環(huán)境中,TCP 連接的有效 mss_now 是 1448 字節(jié),用 systemtap 加了探測點(diǎn)后,發(fā)現(xiàn) size_goal 為 14480 字節(jié)!是 mss_now 的整整 10 倍。
所以當(dāng) Clinet 進(jìn)入 Phase 2 時(shí),tcp_sendmsg 計(jì)算出 copy = 14480 - 1024 = 13456 字節(jié)。
可是最后一個(gè) sk_buff 真的能裝這么多嗎?
在實(shí)驗(yàn)環(huán)境中,Phase 1 階段創(chuàng)建的 sk_buff ,其 skb->len = 1024, skb->truesize = 4372 (4096 + 256,這個(gè)值的詳細(xì)來源請看 sk_stream_alloc_skb)
這樣看上去,這個(gè) sk_buff 也容納不下 14480 啊。
再繼續(xù)看內(nèi)核的實(shí)現(xiàn),再 skb_copy_to_page_nocache() 拷貝之前,會進(jìn)行 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 內(nèi)部,會進(jìn)行 sk_buff 的擴(kuò)容(增大可以存放的用戶數(shù)據(jù)長度).
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);
    ......
    // 后面有一堆檢查,比如如果系統(tǒng)內(nèi)存足夠,就不去看他是否超過 sk_sndbuf
}通過這種方式,內(nèi)核可以讓 sk->wmem_queued 在超過 sk->sndbuf 的限制。
我并不覺得這樣是優(yōu)雅而合理的行為,因?yàn)樗層脩粼O(shè)置的 SO_SNDBUF 形同虛設(shè)!那么我可以增么修改呢?
關(guān)掉網(wǎng)卡 GSO 特性修改內(nèi)核代碼, 將檢查發(fā)送緩沖區(qū)限制移動(dòng)到 while 循環(huán)的開頭。
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 -            if (!sk_stream_memory_free(sk))
-                goto wait_for_sndbuf;——EOF——你好,我是飛宇。日常分享C/C++、計(jì)算機(jī)學(xué)習(xí)經(jīng)驗(yàn)、工作體會,歡迎點(diǎn)擊此處查看我以前的學(xué)習(xí)筆記&經(jīng)驗(yàn)&分享的資源。
我組建了一些社群一起交流,群里有大牛也有小白,如果你有意可以一起進(jìn)群交流。

歡迎你添加我的微信,我拉你進(jìn)技術(shù)交流群。此外,我也會經(jīng)常在微信上分享一些計(jì)算機(jī)學(xué)習(xí)經(jīng)驗(yàn)以及工作體驗(yàn),還有一些內(nèi)推機(jī)會。


加個(gè)微信,打開另一扇窗
經(jīng)常遇到有讀者后臺私信想要一些編程學(xué)習(xí)資源,這里分享 1T 的編程電子書、C/C++開發(fā)手冊、Github上182K+的架構(gòu)路線圖、LeetCode算法刷題筆記等精品學(xué)習(xí)資料,點(diǎn)擊下方公眾號會回復(fù)"編程"即可免費(fèi)領(lǐng)取~
感謝你的分享,點(diǎn)贊,在看三  

發(fā)表回復(fù)

您需要登錄后才可以回帖 登錄 | 立即注冊

本版積分規(guī)則


聯(lián)系客服 關(guān)注微信 下載APP 返回頂部 返回列表