1. 程式人生 > >TCP連線過程中的記憶體使用問題

TCP連線過程中的記憶體使用問題

當伺服器的併發TCP連線數以十萬計時,我們就會對一個TCP連線在作業系統核心上消耗的記憶體多少感興趣。socket程式設計方法提供了SO_SNDBUF、SO_RCVBUF這樣的介面來設定連線的讀寫快取,linux上還提供了以下系統級的配置來整體設定伺服器上的TCP記憶體使用,但這些配置看名字卻有些互相沖突、概念模糊的感覺,如下(sysctl -a命令可以檢視這些配置):
  1. net.ipv4.tcp_rmem = 8192 87380 16777216  
  2. net.ipv4.tcp_wmem = 8192 65536 16777216  
  3. net.ipv4.tcp_mem = 8388608 12582912 16777216  
  4. net.core.rmem_default = 262144  
  5. net.core.wmem_default = 262144  
  6. net.core.rmem_max = 16777216  
  7. net.core.wmem_max = 16777216  

還有一些較少被提及的、也跟TCP記憶體相關的配置:
  1. net.ipv4.tcp_moderate_rcvbuf = 1  
  2. net.ipv4.tcp_adv_win_scale = 2  

(注:為方便下文講述,介紹以上系統配置時字首省略掉,配置值以空格分隔的多個數字以陣列來稱呼,例如tcp_rmem[2]表示上面第一行最後一列16777216。)
網上可以找到很多這些系統配置項的說明,然而往往還是讓人費解,例如,tcp_rmem[2]和rmem_max似乎都跟接收快取最大值有關,但它們卻可以不一致,究竟有什麼區別?或者tcp_wmem[1]和wmem_default似乎都表示傳送快取的預設值,衝突了怎麼辦?在用抓包軟體抓到的syn握手包裡,為什麼TCP接收視窗大小似乎與這些配置完全沒關係? TCP連線在程序中使用的記憶體大小千變萬化,通常程式較複雜時可能不是直接基於socket程式設計,這時平臺級的元件可能就封裝了TCP連線使用到的使用者態記憶體。不同的平臺、元件、中介軟體、網路庫都大不相同。而核心態為TCP連線分配記憶體的演算法則是基本不變的,這篇文章將試圖說明TCP連線在核心態中會使用多少記憶體,作業系統使用怎樣的策略來平衡巨集觀的吞吐量與微觀的某個連線傳輸速度。這篇文章也將一如既往的面向應用程式開發者,而不是系統級的核心開發者,所以,不會詳細的介紹為了一個TCP連線、一個TCP報文作業系統分配了多少位元組的記憶體,核心級的資料結構也不是本文的關注點,這些也不是應用級程式設計師的關注點。這篇文章主要描述linux核心為了TCP連線上傳輸的資料是怎樣管理讀寫快取的。
一、快取上限是什麼? (1)先從應用程式程式設計時可以設定的SO_SNDBUF、SO_RCVBUF說起。 無論何種語言,都對TCP連線提供基於setsockopt方法實現的SO_SNDBUF、SO_RCVBUF,怎麼理解這兩個屬性的意義呢? SO_SNDBUF、SO_RCVBUF都是個體化的設定,即,只會影響到設定過的連線,而不會對其他連線生效。SO_SNDBUF表示這個連線上的核心寫快取上限。實際上,程序設定的SO_SNDBUF也並不是真的上限,在核心中會把這個值翻一倍再作為寫快取上限使用,我們不需要糾結這種細節,只需要知道,當設定了SO_SNDBUF時,就相當於劃定了所操作的TCP連線上的寫快取能夠使用的最大記憶體。然而,這個值也不是可以由著程序隨意設定的,它會受制於系統級的上下限,當它大於上面的系統配置wmem_max(net.core.wmem_max)時,將會被wmem_max替代(同樣翻一倍);而當它特別小時,例如在2.6.18核心中設計的寫快取最小值為2K位元組,此時也會被直接替代為2K。 SO_RCVBUF表示連線上的讀快取上限,與SO_SNDBUF類似,它也受制於rmem_max配置項,實際在核心中也是2倍大小作為讀快取的使用上限。SO_RCVBUF設定時也有下限,同樣在2.6.18核心中若這個值小於256位元組就會被256所替代。 (2)那麼,可以設定的SO_SNDBUF、SO_RCVBUF快取使用上限與實際記憶體到底有怎樣的關係呢? TCP連線所用記憶體主要由讀寫快取決定,而讀寫快取的大小隻與實際使用場景有關,在實際使用未達到上限時,SO_SNDBUF、SO_RCVBUF是不起任何作用的。對讀快取來說,接收到一個來自連線對端的TCP報文時,會導致讀快取增加,當然,如果加上報文大小後讀快取已經超過了讀快取上限,那麼這個報文會被丟棄從而讀快取大小維持不變。什麼時候讀快取使用的記憶體會減少呢?當程序呼叫read、recv這樣的方法讀取TCP流時,讀快取就會減少。因此,讀快取是一個動態變化的、實際用到多少才分配多少的緩衝記憶體,當這個連線非常空閒時,且使用者程序已經把連線上接收到的資料都消費了,那麼讀快取使用記憶體就是0。 寫快取也是同樣道理。當用戶程序呼叫send或者write這樣的方法傳送TCP流時,就會造成寫快取增大。當然,如果寫快取已經到達上限,那麼寫快取維持不變,向用戶程序返回失敗。而每當接收到TCP連線對端發來的ACK確認了報文的成功傳送時,寫快取就會減少,這是因為TCP的可靠性決定的,發出去報文後由於擔心報文丟失而不會銷燬它,可能會由重發定時器來重發報文。因此,寫快取也是動態變化的,空閒的正常連線上,寫快取所用記憶體通常也為0。 因此,只有當接收網路報文的速度大於應用程式讀取報文的速度時,可能使讀快取達到了上限,這時這個快取使用上限才會起作用。所起作用為:丟棄掉新收到的報文,防止這個TCP連線消耗太多的伺服器資源。同樣,當應用程式傳送報文的速度大於接收對方確認ACK報文的速度時,寫快取可能達到上限,從而使send這樣的方法失敗,核心不為其分配記憶體。 二、快取的大小與TCP的滑動視窗到底有什麼關係? (1)滑動視窗的大小與快取大小肯定是有關的,但卻不是一一對應的關係,更不會與快取上限具有一一對應的關係。因此,網上很多資料介紹rmem_max等配置設定了滑動視窗的最大值,與我們tcpdump抓包時看到的win視窗值完全不一致,是講得通的。下面我們來細探其分別在哪裡。 讀快取的作用有2個:1、將無序的、落在接收滑動視窗內的TCP報文快取起來;2、當有序的、可以供應用程式讀取的報文出現時,由於應用程式的讀取是延時的,所以會把待應用程式讀取的報文也儲存在讀快取中。所以,讀快取一分為二,一部分快取無序報文,一部分快取待延時讀取的有序報文。這兩部分快取大小之和由於受制於同一個上限值,所以它們是會互相影響的,當應用程式讀取速率過慢時,這塊過大的應用快取將會影響到套接字快取,使接收滑動視窗縮小,從而通知連線的對端降低傳送速度,避免無謂的網路傳輸。當應用程式長時間不讀取資料,造成應用快取將套接字快取擠壓到沒空間,那麼連線對端會收到接收視窗為0的通知,告訴對方:我現在消化不了更多的報文了。 反之,接收滑動視窗也是一直在變化的,我們用tcpdump抓三次握手的報文:
  1. 14:49:52.421674 IP houyi-vm02.dev.sd.aliyun.com.6400 > r14a02001.dg.tbsite.net.54073: S 2736789705:2736789705(0) ack 1609024383 win 5792 <mss 1460,sackOK,timestamp 2925954240 2940689794,nop,wscale 9>  

可以看到初始的接收視窗是5792,當然也遠小於最大接收快取(稍後介紹的tcp_rmem[1])。 這當然是有原因的,TCP協議需要考慮複雜的網路環境,所以使用了慢啟動、擁塞視窗(參見高效能網路程式設計2----TCP訊息的傳送),建立連線時的初始視窗並不會按照接收快取的最大值來初始化。這是因為,過大的初始視窗從巨集觀角度,對整個網路可能造成過載引發惡性迴圈,也就是考慮到鏈路上各環節的諸多路由器、交換機可能扛不住壓力不斷的丟包(特別是廣域網),而微觀的TCP連線的雙方卻只按照自己的讀快取上限作為接收視窗,這樣雙方的傳送視窗(對方的接收視窗)越大就對網路產生越壞的影響。慢啟動就是使初始視窗儘量的小,隨著接收到對方的有效報文,確認了網路的有效傳輸能力後,才開始增大接收視窗。 不同的linux核心有著不同的初始視窗,我們以廣為使用的linux2.6.18核心為例,在以太網裡,MSS大小為1460,此時初始視窗大小為4倍的MSS,簡單列下程式碼(*rcv_wnd即初始接收視窗):
  1. int init_cwnd = 4;  
  2. if (mss > 1460*3)  
  3.  init_cwnd = 2;  
  4. elseif (mss > 1460)  
  5.  init_cwnd = 3;  
  6. if (*rcv_wnd > init_cwnd*mss)  
  7.  *rcv_wnd = init_cwnd*mss;  

大家可能要問,為何上面的抓包上顯示視窗其實是5792,並不是1460*4為5840呢?這是因為1460想表達的意義是:將1500位元組的MTU去除了20位元組的IP頭、20位元組的TCP頭以後,一個最大報文能夠承載的有效資料長度。但有些網路中,會在TCP的可選頭部裡,使用12位元組作為時間戳使用,這樣,有效資料就是MSS再減去12,初始視窗就是(1460-12)*4=5792,這與視窗想表達的含義是一致的,即:我能夠處理的有效資料長度。 在linux3以後的版本中,初始視窗調整到了10個MSS大小,這主要來自於GOOGLE的建議。原因是這樣的,接收視窗雖然常以指數方式來快速增加視窗大小(擁塞閥值以下是指數增長的,閥值以上進入擁塞避免階段則為線性增長,而且,擁塞閥值自身在收到128以上資料報文時也有機會快速增加),若是傳輸視訊這樣的大資料,那麼隨著視窗增加到(接近)最大讀快取後,就會“開足馬力”傳輸資料,但若是通常都是幾十KB的網頁,那麼過小的初始視窗還沒有增加到合適的視窗時,連線就結束了。這樣相比較大的初始視窗,就使得使用者需要更多的時間(RTT)才能傳輸完資料,體驗不好。 那麼這時大家可能有疑問,當視窗從初始視窗一路擴張到最大接收視窗時,最大接收視窗就是最大讀快取嗎? 不是,因為必須分一部分快取用於應用程式的延時報文讀取。到底會分多少出來呢?這是可配的系統選項,如下:
  1. net.ipv4.tcp_adv_win_scale = 2  

這裡的tcp_adv_win_scale意味著,將要拿出1/(2^tcp_adv_win_scale)快取出來做應用快取。即,預設tcp_adv_win_scale配置為2時,就是拿出至少1/4的記憶體用於應用讀快取,那麼,最大的接收滑動視窗的大小隻能到達讀快取的3/4。 (2)最大讀快取到底應該設定到多少為合適呢? 當應用快取所佔的份額通過tcp_adv_win_scale配置確定後,讀快取的上限應當由最大的TCP接收視窗決定。初始視窗可能只有4個或者10個MSS,但在無丟包情形下隨著報文的互動視窗就會增大,當視窗過大時,“過大”是什麼意思呢?即,對於通訊的兩臺機器的記憶體而言不算大,但是對於整個網路負載來說過大了,就會對網路裝置引發惡性迴圈,不斷的因為繁忙的網路裝置造成丟包。而視窗過小時,就無法充分的利用網路資源。所以,一般會以BDP來設定最大接收視窗(可計算出最大讀快取)。BDP叫做頻寬時延積,也就是頻寬與網路時延的乘積,例如若我們的頻寬為2Gbps,時延為10ms,那麼頻寬時延積BDP則為2G/8*0.01=2.5MB,所以這樣的網路中可以設最大接收視窗為2.5MB,這樣最大讀快取可以設為4/3*2.5MB=3.3MB。 為什麼呢?因為BDP就表示了網路承載能力,最大接收視窗就表示了網路承載能力內可以不經確認發出的報文。如下圖所示:
經常提及的所謂長肥網路,“長”就是是時延長,“肥”就是頻寬大,這兩者任何一個大時,BDP就大,都應導致最大視窗增大,進而導致讀快取上限增大。所以在長肥網路中的伺服器,快取上限都是比較大的。(當然,TCP原始的16位長度的數字表示視窗雖然有上限,但在RFC1323中定義的彈性滑動視窗使得滑動視窗可以擴充套件到足夠大。) 傳送視窗實際上就是TCP連線對方的接收視窗,所以大家可以按接收視窗來推斷,這裡不再囉嗦。 三、linux的TCP快取上限自動調整策略 那麼,設定好最大快取限制後就高枕無憂了嗎?對於一個TCP連線來說,可能已經充分利用網路資源,使用大視窗、大快取來保持高速傳輸了。比如在長肥網路中,快取上限可能會被設定為幾十兆位元組,但系統的總記憶體卻是有限的,當每一個連線都全速飛奔使用到最大視窗時,1萬個連線就會佔用記憶體到幾百G了,這就限制了高併發場景的使用,公平性也得不到保證。我們希望的場景是,在併發連線比較少時,把快取限制放大一些,讓每一個TCP連線開足馬力工作;當併發連線很多時,此時系統記憶體資源不足,那麼就把快取限制縮小一些,使每一個TCP連線的快取儘量的小一些,以容納更多的連線。 linux為了實現這種場景,引入了自動調整記憶體分配的功能,由tcp_moderate_rcvbuf配置決定,如下: net.ipv4.tcp_moderate_rcvbuf = 1 預設tcp_moderate_rcvbuf配置為1,表示打開了TCP記憶體自動調整功能。若配置為0,這個功能將不會生效(慎用)。 另外請注意:當我們在程式設計中對連線設定了SO_SNDBUF、SO_RCVBUF,將會使linux核心不再對這樣的連線執行自動調整功能! 那麼,這個功能到底是怎樣起作用的呢?看以下配置:
  1. net.ipv4.tcp_rmem = 8192 87380 16777216  
  2. net.ipv4.tcp_wmem = 8192 65536 16777216  
  3. net.ipv4.tcp_mem = 8388608 12582912 16777216  

tcp_rmem[3]陣列表示任何一個TCP連線上的讀快取上限,其中tcp_rmem[0]表示最小上限,tcp_rmem[1]表示初始上限(注意,它會覆蓋適用於所有協議的rmem_default配置),tcp_rmem[2]表示最大上限。 tcp_wmem[3]陣列表示寫快取,與tcp_rmem[3]類似,不再贅述。 tcp_mem[3]陣列就用來設定TCP記憶體的整體使用狀況,所以它的值很大(它的單位也不是位元組,而是頁--4K或者8K等這樣的單位!)。這3個值定義了TCP整體記憶體的無壓力值、壓力模式開啟閥值、最大使用值。以這3個值為標記點則記憶體共有4種情況: 1、當TCP整體記憶體小於tcp_mem[0]時,表示系統記憶體總體無壓力。若之前記憶體曾經超過了tcp_mem[1]使系統進入記憶體壓力模式,那麼此時也會把壓力模式關閉。 這種情況下,只要TCP連線使用的快取沒有達到上限(注意,雖然初始上限是tcp_rmem[1],但這個值是可變的,下文會詳述),那麼新記憶體的分配一定是成功的。 2、當TCP記憶體在tcp_mem[0]與tcp_mem[1]之間時,系統可能處於記憶體壓力模式,例如總記憶體剛從tcp_mem[1]之上下來;也可能是在非壓力模式下,例如總記憶體剛從tcp_mem[0]以下上來。 此時,無論是否在壓力模式下,只要TCP連線所用快取未超過tcp_rmem[0]或者tcp_wmem[0],那麼都一定都能成功分配新記憶體。否則,基本上就會面臨分配失敗的狀況。(注意:還有一些例外場景允許分配記憶體成功,由於對於我們理解這幾個配置項意義不大,故略過。) 3、當TCP記憶體在tcp_mem[1]與tcp_mem[2]之間時,系統一定處於系統壓力模式下。其他行為與上同。 4、當TCP記憶體在tcp_mem[2]之上時,毫無疑問,系統一定在壓力模式下,而且此時所有的新TCP快取分配都會失敗。 下圖為需要新快取時核心的簡化邏輯:
當系統在非壓力模式下,上面我所說的每個連線的讀寫快取上限,才有可能增加,當然最大也不會超過tcp_rmem[2]或者tcp_wmem[2]。相反,在壓力模式下,讀寫快取上限則有可能減少,雖然上限可能會小於tcp_rmem[0]或者tcp_wmem[0] 所以,粗略的總結下,對這3個數組可以這麼看: 1、只要系統TCP的總體記憶體超了 tcp_mem[2] ,新記憶體分配都會失敗。 2、tcp_rmem[0]或者tcp_wmem[0]優先順序也很高,只要條件1不超限,那麼只要連線記憶體小於這兩個值,就保證新記憶體分配一定成功。 3、只要總體記憶體不超過tcp_mem[0],那麼新記憶體在不超過連線快取的上限時也能保證分配成功。 4、tcp_mem[1]與tcp_mem[0]構成了開啟、關閉記憶體壓力模式的開關。在壓力模式下,連線快取上限可能會減少。在非壓力模式下,連線快取上限可能會增加,最多增加到tcp_rmem[2]或者tcp_wmem[2]。