1. 程式人生 > >關於Linux TCP接收快取以及接收視窗的一個細節解析

關於Linux TCP接收快取以及接收視窗的一個細節解析

TCP視窗 關於TCP的接收快取以及通告視窗,一般而言懂TCP的都能說出個大概,但是涉及到細節的話可能理解就不那麼深入了。由於我最近的工作與TCP有關,順便又想起了很久之前遇到的一個問題:
明明在接收端有8192位元組的接收快取,為什麼收了不到8000位元組的資料就ZeroWindow了呢?
當時我的解決方案是直接擴大接收快取完事,然後就沒有然後了。後來深挖了一下細節,發現了很多曾經不知道的東西,如今對TCP的理解想必又深入了一些,趁著國慶假期順便就把很多想法整理成一篇文章了。

0.network buffer & application buffer

深入接收快取管理機制的過程中,你可能會在程式碼的註釋中看到這樣的分割,將接收快取分割成了所謂的network buffer和application buffer,具體參見__tcp_grow_window的註釋:
/* 2. Tuning advertised window (window_clamp, rcv_ssthresh)
 *
 * All tcp_full_space() is split to two parts: "network" buffer, allocated
 * forward and advertised in receiver window (tp->rcv_wnd) and
 * "application buffer", required to isolate scheduling/application
 * latencies from network.
 * window_clamp is maximal advertised window. It can be less than
 * tcp_full_space(), in this case tcp_full_space() - window_clamp
 * is reserved for "application" buffer. The less window_clamp is
 * the smoother our behaviour from viewpoint of network, but the lower
 * throughput and the higher sensitivity of the connection to losses. 8)
 *
 * rcv_ssthresh is more strict window_clamp used at "slow start"
 * phase to predict further behaviour of this connection.
 * It is used for two goals:
 * - to enforce header prediction at sender, even when application
 *   requires some significant "application buffer". It is check #1.
 * - to prevent pruning of receive queue because of misprediction
 *   of receiver window. Check #2.
 *
 * The scheme does not work when sender sends good segments opening
 * window and then starts to feed us spagetti. But it should work
 * in common situations. Otherwise, we have to rely on queue collapsing.
 */
然後,幾乎所有的分析接收快取的文章都採用了這種說法,誠然,說法並不重要,關鍵是要便於人們去理解。因此我嘗試用一種不同的說法去解釋它,其實本質上是相同的,只是更加囉嗦一些。
        和我一向的觀點一樣,本文不會去大段大段分析原始碼,也就是說不會去做給原始碼加註釋的工作,而是希望能繪製一個關於這個話題的藍圖,就像之前分析OpenVPN以及Netfilter的時候那樣。

1.通告視窗與接收快取

在TCP的配置中,有一個接收快取的概念,另外在TCP滑動視窗機制中,還有一個接收視窗的概念,毋庸置疑,接收視窗所使用的記憶體必須分配自接收快取,因此二者是包容的關係。
        但這不是重點,重點是: 接收視窗無法完全佔完接收快取的記憶體,即接收快取的記憶體並不能完全用於接收視窗!Why?

        這是因為接收視窗是TCP層的概念,僅僅描述TCP載荷,然而這個載荷之所以可以收到,必須使用一個叫做資料包的載體,在Linux中就是skb,另外為了讓協議執行,必須為載荷封裝TCP頭,IP頭,以太頭...等等。
        我用下圖來解釋接收快取以及其和TCP資料包的關係:




【注意,當我說“TCP資料包”的時候,我的意思是這是一個帶有以太頭的完整資料包,當我說“TCP資料段”的時候,我想表達的則是我並不關係IP層及以下的東西。】
圖示的最後,我特意標紅了一個“極力要避免”的警示,確實,如果直接把可用的視窗都通告出去了,且傳送端並不按照滿MSS傳送的話,是存在溢位風險的,這要怎麼解決呢?

附:如何確定通告視窗可以使用的接收快取

在程式碼中,我們注意一個函式tcp_fixup_rcvbuf:

   
  1. static void tcp_fixup_rcvbuf( struct sock *sk)
  2. {
  3. u32 mss = tcp_sk(sk)->advmss;
  4. u32 icwnd = TCP_DEFAULT_INIT_RCVWND;
  5. int rcvmem;
  6. /* Limit to 10 segments if mss <= 1460,
  7. * or 14600/mss segments, with a minimum of two segments.
  8. */
  9. if (mss > 1460)
  10. icwnd = max_t(u32, ( 1460 * TCP_DEFAULT_INIT_RCVWND) / mss, 2);
  11. rcvmem = SKB_TRUESIZE(mss + MAX_TCP_HEADER);
  12. // 將rcvbuf按比例縮放到其(n-1)/n可以完全容納TCP純載荷的程度,n由系統引數net.ipv4.tcp_adv_win_scale來確定。
  13. while (tcp_win_from_space(rcvmem) < mss)
  14. rcvmem += 128;
  15. rcvmem *= icwnd;
  16. if (sk->sk_rcvbuf < rcvmem)
  17. sk->sk_rcvbuf = min(rcvmem, sysctl_tcp_rmem[ 2]);
  18. }

以上函式確定了接收快取,其中有3個要點:
1).初始通告視窗的大小
預設是10個MSS滿1460位元組的段,這個數值10來自google的測試,與擁塞視窗的初始值一致,然而由於MSS各不同,其會按照1460/mss的比例進行縮放來適配經驗值10。
2).TCP載體開銷的最小值128
展開巨集SKB_TRUESIZE會發現其最小值就是128,這對通告視窗慢啟動過程定義了一個安全下界,載荷小於128位元組的TCP資料段將不會增加通告的上限大小。
3).引數tcp_adv_win_scale的含義
對比我上面的圖示,上述程式碼的註釋,我們知道tcp_adv_win_scale就是控制“載荷/載體”比例的,我們看一下其Kernel DOC
tcp_adv_win_scale - INTEGER Count buffering overhead as bytes/2^tcp_adv_win_scale
    (if tcp_adv_win_scale > 0) or bytes-bytes/2^(-tcp_adv_win_scale),
    if it is <= 0.
    Possible values are [-31, 31], inclusive.
    Default: 1

這個引數曾經的default值是2而不是1,這意味著以往TCP的載荷佔比由3/4變成了1/2,好像是開銷更大了,這是為什麼呢?以下是該Change的patch描述:


From: Eric Dumazet <[email protected]>

[ Upstream commit b49960a05e32121d29316cfdf653894b88ac9190 ]

tcp_adv_win_scale default value is 2, meaning we expect a good citizen
skb to have skb->len / skb->truesize ratio of 75% (3/4)

In 2.6 kernels we (mis)accounted for typical MSS=1460 frame :
1536 + 64 + 256 = 1856 'estimated truesize', and 1856 * 3/4 = 1392.
So these skbs were considered as not bloated.

With recent truesize fixes, a typical MSS=1460 frame truesize is now the
more precise :
2048 + 256 = 2304. But 2304 * 3/4 = 1728.
So these skb are not good citizen anymore, because 1460 < 1728

(GRO can escape this problem because it build skbs with a too low
truesize.)

This also means tcp advertises a too optimistic window for a given
allocated rcvspace : When receiving frames, sk_rmem_alloc can hit
sk_rcvbuf limit and we call tcp_prune_queue()/tcp_collapse() too often,
especially when application is slow to drain its receive queue or in
case of losses (netperf is fast, scp is slow). This is a major latency
source.

We should adjust the len/truesize ratio to 50% instead of 75%

This patch :

1) changes tcp_adv_win_scale default to 1 instead of 2

2) increase tcp_rmem[2] limit from 4MB to 6MB to take into account
better truesize tracking and to allow autotuning tcp receive window to
reach same value than before. Note that same amount of kernel memory is

consumed compared to 2.6 kernels.



單純從TCP載荷比來講,開銷的增加意味著效率的降低,然而注意到這部分開銷的增加並非網路協議頭所為,而是skb_shared_info結構體被計入開銷以及skb結構體等系統載體的膨脹所導致:
我們分別來看一下2.6.32和3.10兩個版本的sk_buff的大小,怎麼看呢?不要想著寫一個模組然後列印sizeof,直接用slabtop去看即可,裡面資訊很足。
a).2.6.32版本的sk_buff大小
slabtop的結果是:
skbuff_head_cache    550    615    256   15    1 : tunables  120   60    8 : slabdata     41     41      0
我們看到其大小是256位元組。
b).3.10版本的sk_buff大小
slabtop的結果是:
skbuff_head_cache   3675   3675    320   25    2 : tunables    0    0    0 : slabdata    147    147      0
我們看到其大小是320位元組。
        差別並不是太大!這不是主要因素,但確實會有所影響。
        除了skb的膨脹之外,系統中還有別的膨脹,比如為了效率的“對齊開銷”,但更大的開銷增加是skb_shared_info結構體的計入(個人認為以前開銷中不計入skb_shared_info結構體是錯誤的)等,最終導致新版本(以3.10+為例)的核心計算TRUESIZE的方法改變:
packet_size = mss + MAX_TCP_HEADER + SKB_DATA_ALIGN(sizeof(struct sk_buff)) + SKB_DATA_ALIGN(sizeof(struct skb_shared_info)))
然而以往的老核心(以2.6.32為例),其開銷的計算是非常魯莽的,少了很多東西:
packet_size = mss + MAX_TCP_HEADER + 16 + sizeof(struct sk_buff);
雖然這種開銷的膨脹在TCP層面幾乎看不到什麼收益(反而付出了代價,你不得不配置更大的rcvbuf...),然而skb等並不單單服務於TCP,這種膨脹的收益可能被排程,中斷,IP路由,負載均衡等機制獲取了,記住兩點即可:首先,Linux核心各個子系統是一個整體,其次,記憶體越來越便宜而時間一去不復返,空間換時間,划得來!

2.如何規避接收快取溢位的風險

在談如何規避溢位風險之前,我必須先說一下這個風險並不是常在的,如果應用程式非常迅速的讀取TCP資料並釋放skb,那麼幾乎不會有什麼風險,問題在於應用程式並不受TCP層的控制,所以我說的“溢位風險”指的是一種合理但很極端的情況,那就是應用程式在TCP層收滿一窗資料前都不會去讀取資料,這雖然很極端,但是符合TCP滑動視窗的規範:通告給傳送端的視窗表示傳送端可以一次性發送這麼多的資料,至於應用程式什麼時候來讀取,滑動視窗機制並不控制。
        在闡明瞭風險的來源後,我們就可以暢談何以規避風險了。
        我們知道,TCP擁塞控制通過慢啟動來規避突發造成的網路快取溢位的的風險,事實上擁塞控制也是一種流量控制,作為標準的方案,慢啟動幾乎是規避溢位的標配方案!這很好理解,慢啟動的含義是“快速地從起點試探到穩態”,並非其字面含義所說的“慢慢地啟動”,之所以有“慢”字是因為與進入穩定狀態後相比,它的起點是低的。這和開車是一樣的道理,靜止的汽車從踩下油門開始一直到勻速,是一個快速加速的過程,達到100km/h的時間也是一個重要的指標,當然,很多情況下是越小越好!
        所以說,通告視窗也是採用慢啟動方式逐步張開的。

2.0.收到極小載荷的TCP資料包時的慢啟動

比如說收到了一個只包含1個位元組載荷的資料包時,此時僅僅skb,協議頭等開銷就會超過幾百位元組,通告視窗增加是非常危險的。Linux TCP實現中,將128位元組定為下限,凡是收到小於128位元組載荷的資料包,接收一大窗的資料非常有可能造成快取溢位,因此不執行慢啟動。

2.1.收到滿MSS的TCP資料包時的慢啟動

如果能保證傳送端一直髮送滿MSS長度的TCP資料包,那麼接收快取是不會溢位的,因為整個通告視窗可以使用的記憶體就是通過這個滿MSS長度和接收快取按照比例縮放而生成的,但是誰也不能保證傳送端會一直髮送滿MSS長度的TCP資料包,所以就不能允許傳送端一下子傳送所有可用的視窗快取那麼大的資料量,因此慢啟動是必須的。
        收到滿MSS長度的資料或者大於MSS長度的資料,視窗可以毫無壓力地增加2個MSS大小。

2.2.收到非滿MSS

這裡的情況比較複雜了。雖然收到資料長度比MSS小的TCP資料包有快取溢位的風險,但是受限於當前的通告視窗上限(由於慢啟動的功勞)小於整個可用的通告視窗記憶體,這種情況下即便是傳送一整窗的資料,也不會造成整個接收快取的溢位。這就是說某些時候,噹噹前的接收視窗上限未達到整個可用的視窗快取時,長度小於MSS的TCP資料包的額外高於 (n-1)/n比例的開銷可以暫時“借用”剩餘的視窗可用的快取,只要不會造成溢位,管它是不是借用,都是可以接受的。
        如此複雜的情況,我畫了一個稍微複雜點的圖來展示,以節省文字篇幅:




看懂了上圖之後,我來補充一個動態過程,如果持續收到小包的情況下,會怎樣?
        如果持續收到小於MSS的小包,假設長度都相等,那麼從慢啟動開始,通告視窗的最大值,即rcv_ssthresh將會在每收到一個數據包後從初始值開始按照2倍資料段長度的增量持續增長,直到其達到小於所有可用通告視窗記憶體的某個值停止再增長,增長到該值的位置時,一整窗的資料連同其開銷將會完全佔滿整個rcvbuf。

3.一個差異:通告視窗大小與通告視窗上限

為什麼擁塞視窗的慢啟動是直接增加的擁塞視窗的值,通告視窗的慢啟動並不直接增加通告視窗而是增加的通告視窗的上限呢?
        這是因為通告視窗的實際值並非單單由接收快取溢位檢測這麼一個因素控制,這個因素事實上反而不是主導因素,主導因素是應用程式是不是即時騰出了接收快取。我們從程式碼中如何確定通告視窗的邏輯中可以看出:

   
  1. u32 __tcp_select_window(struct sock *sk)
  2. {
  3. struct inet_connection_sock *icsk = inet_csk(sk);
  4. struct tcp_sock *tp = tcp_sk(sk);
  5. /* MSS for the peer's data. Previous verions used mss_clamp
  6. * here. I don't know if the value based on our guesses
  7. * of peer's MSS is better for the performance. It's more correct
  8. * but may be worse for the performance because of rcv_mss
  9. * fluctuations. --SAW 1998/11/1
  10. */
  11. int mss = icsk->icsk_ack.rcv_mss;
  12. // free_space就是應用程式和TCP層合力確定的通告視窗基準值,它簡單來講就是(rcvbuf - sk_rmem_alloc)中的純資料部分,縮放比例就是本文開始提到的(n-1)/n。
  13. int free_space = tcp_space(sk);
  14. int full_space = min_t( int, tp->window_clamp, tcp_full_space(sk));
  15. int window;
  16. if (mss > full_space)
  17. mss = full_space;
  18. // 這裡是為了防止接收快取溢位的最後防線,當free_space小於全部rcvbuf按純資料比例縮放後的大小的一半時,就要小心了!
  19. if (free_space < (full_space >> 1)) {
  20. icsk->icsk_ack.quick = 0;
  21. if (sk_under_memory_pressure(sk))
  22. tp->rcv_ssthresh = min(tp->rcv_ssthresh,
  23. 4U * tp->advmss);
  24. // 我們要多大程度上信任mss,取決於傳送端mss的波動情況,如註釋中所提到的“It's more correct but may be worse for the performance because of rcv_mss fluctuations.”
  25. if (free_space < mss)
  26. return 0;
  27. }
  28. // 這裡的核心是,雖然應用程式為TCP接收快取騰出了free_space這麼大小的空間,但是並不能全部通告給傳送端,需要一點點通告並增加通告的大小,這就是慢啟動了。
  29. // 注意這裡,free_space不能超過ssthresh,這便是通告視窗上限慢啟動的根本了。
  30. if (free_space > tp->rcv_ssthresh)
  31. free_space = tp->rcv_ssthresh;
  32. ... // 這裡的視窗計算詳細過程反而不是本文關注的,可以參見其它原始碼分析的文章和書籍
  33. ... // 最終free_space要落實到window,為了便於理解核心,可以認為free_space就是window。
  34. return window;
  35. }

在本文的最後,我來總結一幅圖,將上面談到的所有這些概念與Linux核心協議棧TCP實現關聯起來:




好了,這篇國慶假期期間沒有寫完的文章到此終於寫完了!
        溫州老闆墜馬落水。

轉載:https://blog.csdn.net/dog250/article/details/52792108