性能網絡編程總結

 

高性能網絡編程總結及《TCP/IP Sockets編程(C語言實現) (第2版)》 代碼下載(鏈接以及文件打包)

2016-03-01 17:04  2322人閱讀  評論(1)  收藏  舉報
  分類:

目錄(?)[+]

http://blog.csdn.NET/column/details/high-perf-network.html

http://blog.csdn.net/russell_tao/article/details/9111769

高性能網絡編程(一)----accept建立連接

最近在部門內做了個高性能網絡編程的培訓,近日整理了下PPT,欲寫成一系列文章從應用角度談談它。
編寫服務器時,許多程序員習慣於使用高層次的組件、中間件(例如OO(面向對象)層層封裝過的開源組件),相比於服務器的運行效率而言,他們更關注程序開發的效率,追求更快的完成項目功能點、希望應用代碼完全不關心通訊細節。他們更喜歡在OO世界裏,去實現某個接口、實現這個組件預定義的各種模式、設置組件參數來達到目的。學習複雜的通訊框架、底層細節,在習慣於使用OO語言的程序員眼裏是絕對事倍功半的。以上做法無可厚非,但有一定的侷限性,本文講述的網絡編程頭前冠以「高性能」,它是指程序員設計編寫的服務器需要處理很大的吞吐量,這與簡單網絡應用就有了質的不同。因爲:1、高吞吐量下,容易觸發到一些設計上的邊界條件;2、偶然性的小概率事件,會在高吞吐量下變成必然性事件。3、IO是慢速的,高吞吐量通常意味着高併發,如同一時刻存在數以萬計、十萬計、百萬計的TCP活動連接。所以,做高性能網絡編程不能僅僅滿足於學會開源組件、中間件是如何幫我實現期望功能的,對於企業級產品來說,需要了解更多的知識。

掌握高性能網絡編程,涉及到對網絡、操作系統協議棧、進程與線程、常見的網絡組件等知識點,需要有豐富的項目開發經驗,能夠權衡服務器運行效率與項目開發效率。以下圖來談談我個人對高性能網絡編程的理解。

上面這張圖中,由上至下有以下特點:
•關注點,逐漸由特定業務向通用技術轉移
•使用場景上,由專業領域向通用領域轉移
•靈活性上要求越來越高
•性能要求越來越高
•對細節、原理的掌握,要求越來越高
•對各種異常情況的處理,要求越來越高
•穩定性越來越高,bug率越來越少
在做應用層的網絡編程時,若服務器吞吐量大,則應該適度瞭解以上各層的關注點。

如上圖紅色文字所示,我認爲編寫高性能服務器的關注點有3個:
1、如果基於通用組件編程,關注點多是在組件如何封裝套接字編程細節。爲了使應用程序不感知套接字層,這些組件往往是通過各種回調機制來嚮應用層代碼提供網絡服務,通常,出於爲應用層提供更高的開發效率,組件都大量使用了線程(Nginx等是個例外),當然,使用了線程後往往可以降低代碼複雜度。但多線程引入的併發解決機制還是需要重點關注的,特別是鎖的使用。另外,使用多線程意味着把應用層的代碼複雜度扔給了操作系統,大吞吐量時,需要關注多線程給操作系統內核帶來的性能損耗。
基於通用組件編程,爲了程序的高性能運行,需要清楚的瞭解組件的以下特性:怎麼使用IO多路複用或者異步IO的?怎麼實現併發性的? 怎麼組織線程模型的? 怎麼處理高吞吐量引發的異常情況的?

2、通用組件只是在封裝套接字,操作系統是通過提供套接字來爲進程提供網絡通訊能力的。所以,不瞭解套接字編程,往往對組件的性能就沒有原理上的認識。學習套接字層的編程是有必要的,或許很少會自己從頭去寫,但操作系統的API提供方式經久不變,一經學會,受用終身,同時在項目的架構設計時,選用何種網絡組件就非常準確了。
學習套接字編程,關注點主要在:套接字的編程方法有哪些?阻塞套接字的各方法是如何阻塞住當前代碼段的?非阻塞套接字上的方法如何不阻塞當前代碼段的?IO多路複用機制是怎樣與套接字結合的?異步IO是如何實現的?網絡協議的各種異常情況、操作系統的各種異常情況是怎麼通過套接字傳遞給應用性程序的?

3、網絡的複雜性會影響到服務器的吞吐量,而且,高吞吐量場景下,多種臨界條件會導致應用程序的不正常,特別是組件中有bug或考慮不周或沒有配置正確時。瞭解網絡分組可以定位出這些問題,可以正確的配置系統、組件,可以正確的理解系統的瓶頸。
這裏的關注點主要在:TCP、UDP、IP協議的特點?linux等操作系統如何處理這些協議的?使用tcpdump等抓包工具分析各網絡分組。

一般掌握以上3點,就可以揮灑自如的實現高性能網絡服務器了。

下面具體談談如何做到高性能網絡編程。
衆所周知,IO是計算機上最慢的部分,先不看磁盤IO,針對網絡編程,自然是針對網絡IO。網絡協議對網絡IO影響很大,當下, TCP/IP協議是毫無疑問的主流協議,本文就主要以TCP協議爲例來說明網絡IO。
網絡IO中應用服務器往往聚焦於以下幾個由網絡IO組成的功能中:A)與客戶端建立起TCP連接。B)讀取客戶端的請求流。C)向客戶端發送響應流。D)關閉TCP連接。E)向其他服務器發起TCP連接。
要掌握住這5個功能,不僅僅需要熟悉一些API的使用,更要理解底層網絡如何與上層API之間互相發生影響。同時,還需要對不同的場景下,如何權衡開發效率、進程、線程與這些API的組合使用。下面依次來說說這些網絡IO。


1、 與客戶端建立起TCP連接
談這個功能前,先來看看網絡、協議、應用服務器間的關係

上圖中可知:
爲簡化不同場景下的編程,TCP/IP協議族劃分了應用層、TCP傳輸層、IP網絡層、鏈路層等,每一層只專注於少量功能。
例如,IP層只專注於每一個網絡分組如何到達目的主機,而不管目的主機如何處理。
傳輸層最基本的功能是專注於端到端,也就是一臺主機上的進程發出的包,如何到達目的主機上的某個進程。當然,TCP層爲了可靠性,還額外需要解決3個大問題:丟包(網絡分組在傳輸中存在的丟失)、重複(協議層異常引發的多個相同網絡分組)、延遲(很久後網絡分組纔到達目的地)。
鏈路層則只關心以太網或其他二層網絡內網絡包的傳輸。

回到應用層,往往只需要調用類似於accept的API就可以建立TCP連接。建立連接的流程大家都瞭解--三次握手,它如何與accept交互呢?下面以一個不太精確卻通俗易懂的圖來說明之:

研究過backlog含義的朋友都很容易理解上圖。這兩個隊列是內核實現的,當服務器綁定、監聽了某個端口後,這個端口的SYN隊列和ACCEPT隊列就建立好了。客戶端使用connect向服務器發起TCP連接,當圖中1.1步驟客戶端的SYN包到達了服務器後,內核會把這一信息放到SYN隊列(即未完成握手隊列)中,同時回一個SYN+ACK包給客戶端。一段時間後,在較中2.1步驟中客戶端再次發來了針對服務器SYN包的ACK網絡分組時,內核會把連接從SYN隊列中取出,再把這個連接放到ACCEPT隊列(即已完成握手隊列)中。而服務器在第3步調用accept時,其實就是直接從ACCEPT隊列中取出已經建立成功的連接套接字而已。

現有我們可以來討論應用層組件:爲何有的應用服務器進程中,會單獨使用1個線程,只調用accept方法來建立連接,例如tomcat;有的應用服務器進程中,卻用1個線程做所有的事,包括accept獲取新連接。

原因在於:首先,SYN隊列和ACCEPT隊列都不是無限長度的,它們的長度限制與調用listen監聽某個地址端口時傳遞的backlog參數有關。既然隊列長度是一個值,那麼,隊列會滿嗎?當然會,如果上圖中第1步執行的速度大於第2步執行的速度,SYN隊列就會不斷增大直到隊列滿;如果第2步執行的速度遠大於第3步執行的速度,ACCEPT隊列同樣會達到上限。第1、2步不是應用程序可控的,但第3步卻是應用程序的行爲,假設進程中調用accept獲取新連接的代碼段長期得不到執行,例如獲取不到鎖、IO阻塞等。

那麼,這兩個隊列滿了後,新的請求到達了又將發生什麼?
若SYN隊列滿,則會直接丟棄請求,即新的SYN網絡分組會被丟棄;如果ACCEPT隊列滿,則不會導致放棄連接,也不會把連接從SYN列隊中移出,這會加劇SYN隊列的增長。所以,對應用服務器來說,如果ACCEPT隊列中有已經建立好的TCP連接,卻沒有及時的把它取出來,這樣,一旦導致兩個隊列滿了後,就會使客戶端不能再建立新連接,引發嚴重問題。
所以,如TOMCAT等服務器會使用獨立的線程,只做accept獲取連接這一件事,以防止不能及時的去accept獲取連接。

那麼,爲什麼如Nginx等一些服務器,在一個線程內做accept的同時,還會做其他IO等操作呢?
這裏就帶出阻塞和非阻塞的概念。應用程序可以把listen時設置的套接字設爲非阻塞模式(默認爲阻塞模式),這兩種模式會導致accept方法有不同的行爲。對阻塞套接字,accept行爲如下圖:

這幅圖中可以看到,阻塞套接字上使用accept,第一個階段是等待ACCEPT隊列不爲空的階段,它耗時不定,由客戶端是否向自己發起了TCP請求而定,可能會耗時很長。
對非阻塞套接字,accept會有兩種返回,如下圖:

非阻塞套接字上的accept,不存在等待ACCEPT隊列不爲空的階段,它要麼返回成功並拿到建立好的連接,要麼返回失敗。

所以,企業級的服務器進程中,若某一線程既使用accept獲取新連接,又繼續在這個連接上讀、寫字符流,那麼,這個連接對應的套接字通常要設爲非阻塞。原因如上圖,調用accept時不會長期佔用所屬線程的CPU時間片,使得線程能夠及時的做其他工作。


  

高性能網絡編程2----TCP消息的發送

標籤: tcpkernelnaglesocketlinux
2013-07-18 16:37  17001人閱讀  評論(33)  收藏  舉報
  分類:
 
   
上一篇中,我們已經建立好的TCP連接,對應着操作系統分配的1個套接字。操作TCP協議發送數據時,面對的是數據流。通常調用諸如send或者write方法來發送數據到另一臺主機,那麼,調用這樣的方法時,在操作系統內核中發生了什麼事情呢?我們帶着以下3個問題來細細分析:發送方法成功返回時,能保證TCP另一端的主機接收到嗎?能保證數據已經發送到網絡上了嗎?套接字爲阻塞或者非阻塞時,發送方法做的事情有何不同?

要回答上面3個問題涉及了不少知識點,我們先在TCP層面上看看,發送方法調用時內核做了哪些事。我不想去羅列內核中的數據結構、方法等,畢竟大部分應用程序開發者不需要了解這些,僅以一幅示意圖粗略表示,如下:

圖1 一種典型場景下發送TCP消息的流程
再詳述上圖10個步驟前,先要澄清幾個概念:MTU、MSS、 tcp_write_queue發送隊列、阻塞與非阻塞套接字、擁塞窗口、滑動窗口、 Nagle算法
當我們調用發送方法時,會把我們代碼中構造好的消息流作爲參數傳遞。這個消息流可大可小,例如幾個字節,或者幾兆字節。當消息流較大時,將有可能出現分片。我們先來討論分片問題。

1、MSS與TCP的分片
由上一篇文中可知,TCP層是第4層傳輸層,第3層IP網絡層、第2層數據鏈路層具備的約束條件同樣對TCP層生效。下面來看看數據鏈路層中的一個概念:最大傳輸單元MTU。
無論何種類型的數據鏈路層,都會對網絡分組的長度有一個限制。例如以太網限制爲1500字節,802.3限制爲1492字節。當內核的IP網絡層試圖發送報文時,若一個報文的長度大於MTU限制,就會被分成若干個小於MTU的報文,每個報文都會有獨立的IP頭部。

看看IP頭部的格式:

圖2 IP頭部格式
可以看到,其指定IP包總長度的是一個16位(2字節)的字段,這意味一個IP包最大可以是65535字節。
若TCP層在以太網中試圖發送一個大於1500字節的消息,調用IP網絡層方法發送消息時,IP層會自動的獲取所在局域網的MTU值,並按照所在網絡的MTU大小來分片。IP層同時希望這個分片對於傳輸層來說是透明的,接收方的IP層會根據收到的多個IP包頭部,將發送方IP層分片出的IP包重組爲一個消息。
這種IP層的分片效率是很差的,因爲必須所有分片都到達才能重組成一個包,其中任何一個分片丟失了,都必須重發所有分片。所以,TCP層會試圖避免IP層執行數據報分片。

爲了避免IP層的分片,TCP協議定義了一個新的概念:最大報文段長度MSS。它定義了一個TCP連接上,一個主機期望對端主機發送單個報文的最大長度。TCP3次握手建立連接時,連接雙方都要互相告知自己期望接收到的MSS大小。例如(使用tcpdump抓包):
15:05:08.230782 IP 10.7.80.57.64569 > houyi-vm02.dev.sd.aliyun.com.tproxy: S 3027092051:3027092051(0) win 8192 <mss 1460,nop,wscale 8,nop,nop,sackOK>
15:05:08.234267 IP houyi-vm02.dev.sd.aliyun.com.tproxy > 10.7.80.57.64569: S 26006838:26006838(0) ack 3027092052 win 5840 <mss 1460,nop,nop,sackOK,nop,wscale 9>
15:05:08.233320 IP 10.7.80.57.64543 > houyi-vm02.dev.sd.aliyun.com.tproxy: P 78972532:78972923(391) ack 12915963 win 255
由於例子中兩臺主機都在以太網內,以太網的MTU爲1500,減去IP和TCP頭部的長度,MSS就是1460,三次握手中,SYN包都會攜帶期望的MSS大小。

當應用層調用TCP層提供的發送方法時,內核的TCP模塊在tcp_sendmsg方法裏,會按照對方告知的MSS來分片,把消息流分爲多個網絡分組(如圖1中的3個網絡分組),再調用IP層的方法發送數據。

這個MSS就不會改變了嗎?
會的。上文說過,MSS就是爲了避免IP層分片,在建立握手時告知對方期望接收的MSS值並不一定靠得住。因爲這個值是預估的,TCP連接上的兩臺主機若處於不同的網絡中,那麼,連接上可能有許多中間網絡,這些網絡分別具有不同的數據鏈路層,這樣,TCP連接上有許多個MTU。特別是,若中間途徑的MTU小於兩臺主機所在的網絡MTU時,選定的MSS仍然太大了,會導致中間路由器出現IP層的分片。
怎樣避免中間網絡可能出現的分片呢?
通過IP頭部的DF標誌位,這個標誌位是告訴IP報文所途經的所有IP層代碼:不要對這個報文分片。如果一個IP報文太大必須要分片,則直接返回一個ICMP錯誤,說明必須要分片了,且待分片路由器網絡接受的MTU值。這樣,連接上的發送方主機就可以重新確定MSS。


2、發送方法返回成功後,數據一定發送到了TCP的另一端嗎?
答案當然是否定的。解釋這個問題前,先來看看TCP是如何保證可靠傳輸的。
TCP把自己要發送的數據流裏的每一個字節都看成一個序號,可靠性是要求連接對端在接收到數據後,要發送ACK確認,告訴它已經接收到了多少字節的數據。也就是說,怎樣確保數據一定發送成功了呢?必須等待發送數據對應序號的ACK到達,才能確保數據一定發送成功。 TCP層提供的send或者write這樣的方法是不會做這件事的,看看圖1,它究竟做了哪些事。

圖1中分爲10步。
(1)應用程序試圖調用send方法來發送一段較長的數據。
(2)內核主要通過tcp_sendmsg方法來完成。
(3)(4)內核真正執行報文的發送,與send方法的調用並不是同步的。即,send方法返回成功了,也不一定把IP報文都發送到網絡中了。因此,需要把用戶需要發送的用戶態內存中的數據,拷貝到內核態內存中,不依賴於用戶態內存,也使得進程可以快速釋放發送數據佔用的用戶態內存。但這個拷貝操作並不是簡單的複製,而是把待發送數據,按照MSS來劃分成多個儘量達到MSS大小的分片報文段,複製到內核中的sk_buff結構來存放,同時把這些分片組成隊列,放到這個TCP連接對應的 tcp_write_queue發送隊列中
(5)內核中爲這個TCP連接分配的內核緩存是有限的( /proc/sys/net/core/wmem_default )。當沒有多餘的內核態緩存來複制用戶態的待發送數據時,就需要調用一個方法 sk_stream_wait_memory來等待滑動窗口移動,釋放出一些緩存出來(收到ACK後,不需要再緩存原來已經發送出的報文,因爲既然已經確認對方收到,就不需要定時重發,自然就釋放緩存了)。例如:
[cpp]  view plain  copy
  1. wait_for_memory:  
  2.             if (copied)  
  3.                 tcp_push(sk, tp, flags & ~MSG_MORE, mss_now, TCP_NAGLE_PUSH);  
  4.   
  5.             if ((err = sk_stream_wait_memory(sk, &timeo)) != 0)  
  6.                 goto do_error;  

這裏的 sk_stream_wait_memory方法接受一個參數timeo,就是等待超時的時間。這個時間是tcp_sendmsg方法剛開始就拿到的,如下:
[cpp]  view plain  copy
  1. timeo = sock_sndtimeo(sk, flags & MSG_DONTWAIT);  

看看其實現:
[cpp]  view plain  copy
  1. static inline long sock_sndtimeo(const struct sock *sk, int noblock)  
  2. {  
  3.     return noblock ? 0 : sk->sk_sndtimeo;  
  4. }  

也就是說,當這個套接字是阻塞套接字時,timeo就是SO_SNDTIMEO選項指定的發送超時時間。如果這個套接字是非阻塞套接字, timeo變量就會是0。
實際上,sk_stream_wait_memory對於非阻塞套接字會直接返回,並將 errno錯誤碼置爲EAGAIN。
(6)在圖1的例子中,我們假定使用了阻塞套接字,且等待了足夠久的時間,收到了對方的ACK,滑動窗口釋放出了緩存。
(7)將剩下的用戶態數據都組成MSS報文拷貝到內核態的sk_buff中。
(8)最後,調用tcp_push等方法,它最終會調用IP層的方法來發送tcp_write_queue隊列中的報文。
注意,IP層返回時,並不一定是把報文發送了出去。
(9)(10)發送方法返回。

從圖1的10個步驟中可知,無論是使用阻塞還是非阻塞套接字,發送方法成功返回時(無論全部成功或者部分成功),既不代表TCP連接的另一端主機接收到了消息,也不代表本機把消息發送到了網絡上,只是說明,內核將會試圖保證把消息送達對方。


3、 Nagle算法、 滑動窗口、擁塞窗口對發送方法的影響
圖1第8步tcp_push方法做了些什麼呢?先來看看主要的流程:

圖3 發送TCP消息的簡易流程

下面簡單看看這幾個概念:
(1)滑動窗口
滑動窗口大家都比較熟悉,就不詳細介紹了。TCP連接上的雙方都會通知對方自己的接收窗口大小。而對方的接收窗口大小就是自己的發送窗口大小。tcp_push在發送數據時當然需要與發送窗口打交道。發送窗口是一個時刻變化的值,隨着ACK的到達會變大,隨着發出新的數據包會變小。當然,最大也只能到三次握手時對方通告的窗口大小。tcp_push在發送數據時,最終會使用tcp_snd_wnd_test方法來判斷當前待發送的數據,其序號是否超出了發送滑動窗口的大小,例如:
[cpp]  view plain  copy
  1. //檢查這一次要發送的報文最大序號是否超出了發送滑動窗口大小  
  2. static inline int tcp_snd_wnd_test(struct tcp_sock *tp, struct sk_buff *skb, unsigned int cur_mss)  
  3. {  
  4.         //end_seq待發送的最大序號  
  5.     u32 end_seq = TCP_SKB_CB(skb)->end_seq;  
  6.   
  7.     if (skb->len > cur_mss)  
  8.         end_seq = TCP_SKB_CB(skb)->seq + cur_mss;  
  9.   
  10.         //snd_una是已經發送過的數據中,最小的沒被確認的序號;而snd_wnd就是發送窗口的大小  
  11.     return !after(end_seq, tp->snd_una + tp->snd_wnd);  
  12. }  


(2)慢啓動和擁塞窗口
由於兩臺主機間的網絡可能很複雜,通過廣域網時,中間的路由器轉發能力可能是瓶頸。也就是說,如果一方簡單的按照另一方主機三次握手時通告的滑動窗口大小來發送數據的話,可能會使得網絡上的轉發路由器性能雪上加霜,最終丟失更多的分組。這時,各個操作系統內核都會對TCP的發送階段加入慢啓動和擁塞避免算法。慢啓動算法說白了,就是對方通告的窗口大小隻表示對方接收TCP分組的能力,不表示中間網絡能夠處理分組的能力。所以,發送方請悠着點發,確保網絡非常通暢了後,再按照對方通告窗口來敞開了發。
擁塞窗口就是下面的cwnd,它用來幫助慢啓動的實現。連接剛建立時,擁塞窗口的大小遠小於發送窗口,它實際上是一個MSS。每收到一個ACK,擁塞窗口擴大一個MSS大小,當然,擁塞窗口最大隻能到對方通告的接收窗口大小。當然,爲了避免指數式增長,擁塞窗口大小的增長會更慢一些,是線性的平滑的增長過程。
所以,在tcp_push發送消息時,還會檢查擁塞窗口,飛行中的報文數要小於擁塞窗口個數,而發送數據的長度也要小於擁塞窗口的長度。
如下所示,首先用 unsigned int tcp_cwnd_test方法檢查飛行的報文數是否小於擁塞窗口個數(多少個MSS的個數)
[cpp]  view plain  copy
  1. static inline unsigned int tcp_cwnd_test(struct tcp_sock *tp, struct sk_buff *skb)  
  2. {  
  3.     u32 in_flight, cwnd;  
  4.   
  5.     /* Don't be strict about the congestion window for the final FIN.  */  
  6.     if (TCP_SKB_CB(skb)->flags & TCPCB_FLAG_FIN)  
  7.         return 1;  
  8.   
  9.         //飛行中的數據,也就是沒有ACK的字節總數  
  10.     in_flight = tcp_packets_in_flight(tp);  
  11.     cwnd = tp->snd_cwnd;  
  12.         //如果擁塞窗口允許,需要返回依據擁塞窗口的大小,還能發送多少字節的數據  
  13.     if (in_flight < cwnd)  
  14.         return (cwnd - in_flight);  
  15.   
  16.     return 0;  
  17. }  

再通過tcp_window_allows方法獲取擁塞窗口與滑動窗口的最小長度,檢查待發送的數據是否超出:
[cpp]  view plain  copy
  1. static unsigned int tcp_window_allows(struct tcp_sock *tp, struct sk_buff *skb, unsigned int mss_now, unsigned int cwnd)  
  2. {  
  3.     u32 window, cwnd_len;  
  4.   
  5.     window = (tp->snd_una + tp->snd_wnd - TCP_SKB_CB(skb)->seq);  
  6.     cwnd_len = mss_now * cwnd;  
  7.     return min(window, cwnd_len);  
  8. }  


(3)是否符合NAGLE算法?
Nagle算法的初衷是這樣的:應用進程調用發送方法時,可能每次只發送小塊數據,造成這臺機器發送了許多小的TCP報文。對於整個網絡的執行效率來說,小的TCP報文會增加網絡擁塞的可能,因此,如果有可能,應該將相臨的TCP報文合併成一個較大的TCP報文(當然還是小於MSS的)發送。
Nagle算法要求一個TCP連接上最多隻能有一個發送出去還沒被確認的小分組,在該分組的確認到達之前不能發送其他的小分組。
內核中是通過  tcp_nagle_test 方法實現該算法的。我們簡單的看下:
[cpp]  view plain  copy
  1.   
[cpp]  view plain  copy
  1. static inline int tcp_nagle_test(struct tcp_sock *tp, struct sk_buff *skb,  
  2.                  unsigned int cur_mss, int nonagle)  
  3. {  
  4.     //nonagle標誌位設置了,返回1表示允許這個分組發送出去  
  5.     if (nonagle & TCP_NAGLE_PUSH)  
  6.         return 1;  
  7.   
  8.     //如果這個分組包含了四次握手關閉連接的FIN包,也可以發送出去  
  9.     if (tp->urg_mode ||  
  10.         (TCP_SKB_CB(skb)->flags & TCPCB_FLAG_FIN))  
  11.         return 1;  
  12.   
  13.         //檢查Nagle算法  
  14.     if (!tcp_nagle_check(tp, skb, cur_mss, nonagle))  
  15.         return 1;  
  16.   
  17.     return 0;  
  18. }  

再來看看 tcp_nagle_check方法,它與上一個方法不同,返回0表示可以發送,返回非0則不可以,正好相反。
[cpp]  view plain  copy
  1. static inline int tcp_nagle_check(const struct tcp_sock *tp,  
  2.                   const struct sk_buff *skb,   
  3.                   unsigned mss_now, int nonagle)  
  4. {  
  5.         //先檢查是否爲小分組,即報文長度是否小於MSS  
  6.     return (skb->len < mss_now &&  
  7.         ((nonagle&TCP_NAGLE_CORK) ||  
  8.         //如果開啓了Nagle算法  
  9.          (!nonagle &&  
  10.         //若已經有小分組發出(packets_out表示「飛行」中的分組)還沒有確認  
  11.           tp->packets_out &&  
  12.           tcp_minshall_check(tp))));  
  13. }  

最後看看tcp_minshall_check做了些什麼:
[cpp]  view plain  copy
  1. static inline int tcp_minshall_check(const struct tcp_sock *tp)  
  2. {  
  3.         //最後一次發送的小分組還沒有被確認  
  4.     return after(tp->snd_sml,tp->snd_una) &&  
  5.                 //將要發送的序號是要大於等於上次發送分組對應的序號  
  6.         !after(tp->snd_sml, tp->snd_nxt);  
  7. }  

想象一種場景,當對請求的時延非常在意且網絡環境非常好的時候(例如同一個機房內),Nagle算法可以關閉,這實在也沒必要。使用TCP_NODELAY套接字選項就可以關閉Nagle算法。看看setsockopt是怎麼與上述方法配合工作的:
[cpp]  view plain  copy
  1. static int do_tcp_setsockopt(struct sock *sk, int level,  
  2.         int optname, char __user *optval, int optlen)  
  3.         ...  
  4.     switch (optname) {  
  5.         ...  
  6.     case TCP_NODELAY:  
  7.         if (val) {  
  8.                         //如果設置了TCP_NODELAY,則更新nonagle標誌  
  9.             tp->nonagle |= TCP_NAGLE_OFF|TCP_NAGLE_PUSH;  
  10.             tcp_push_pending_frames(sk, tp);  
  11.         } else {  
  12.             tp->nonagle &= ~TCP_NAGLE_OFF;  
  13.         }  
  14.         break;  
  15.         }  
  16. }  

可以看到,nonagle標誌位就是這麼更改的。


當然,調用了IP層的方法返回後,也未必就保證此時數據一定發送到網絡中去了。
下一篇我們探討如何接收TCP消息,以及接收到ack後內核做了些什麼。

  

高性能網絡編程3----TCP消息的接收

標籤: linuxtcp高性能服務器網絡編程
2013-08-26 18:55  17975人閱讀  評論(29)  收藏  舉報
  分類:
 
   
這篇文章將試圖說明應用程序如何接收網絡上發送過來的TCP消息流,由於篇幅所限,暫時忽略ACK報文的回覆和接收窗口的滑動。
爲了快速掌握本文所要表達的思想,我們可以帶着以下問題閱讀:
1、應用程序調用read、recv等方法時,socket套接字可以設置爲阻塞或者非阻塞,這兩種方式是如何工作的?
2、若socket爲默認的阻塞套接字,此時recv方法傳入的len參數,是表示必須超時(SO_RCVTIMEO)或者接收到len長度的消息,recv方法纔會返回嗎?而且,socket上可以設置一個屬性叫做SO_RCVLOWAT,它會與len產生什麼樣的交集,又是決定recv等接收方法什麼時候返回?
3、應用程序開始收取TCP消息,與程序所在的機器網卡上接收到網絡裏發來的TCP消息,這是兩個獨立的流程。它們之間是如何互相影響的?例如,應用程序正在收取消息時,內核通過網卡又在這條TCP連接上收到消息時,究竟是如何處理的?若應用程序沒有調用read或者recv時,內核收到TCP連接上的消息後又是怎樣處理的?
4、recv這樣的接收方法還可以傳入各種flags,例如MSG_WAITALL、MSG_PEEK、MSG_TRUNK等等。它們是如何工作的?
5、1個socket套接字可能被多個進程在使用,出現併發訪問時,內核是怎麼處理這種狀況的?
6、linux的sysctl系統參數中,有類似tcp_low_latency這樣的開關,默認爲0或者配置爲1時是如何影響TCP消息處理流程的?


書接上文。本文將通過三幅圖講述三種典型的接收TCP消息場景,理清內核爲實現TCP消息的接收所實現的4個隊列容器。當然,瞭解內核的實現並不是目的,而是如何使用socket接口、如何配置操作系統內核參數,才能使TCP傳輸消息更高效,這纔是最終目的。

很多同學不希望被內核代碼擾亂了思維,如何閱讀本文呢?
我會在圖1的步驟都介紹完了纔來從代碼上說明tcp_v4_rcv等主要方法。像flags參數、非阻塞套接字會產生怎樣的效果我是在代碼介紹中說的。然後我會介紹圖2、圖3,介紹它們的步驟時我會穿插一些上文沒有涉及的少量代碼。不喜歡瞭解內核代碼的同學請直接看完圖1的步驟後,請跳到圖2、圖3中,我認爲這3幅圖覆蓋了主要的TCP接收場景,能夠幫助你理清其流程。

接收消息時調用的系統方法要比上一篇發送TCP消息複雜許多。接收TCP消息的過程可以一分爲二:首先是PC上的網卡接收到網線傳來的報文,通過軟中斷內核拿到並且解析其爲TCP報文,然後TCP模塊決定如何處理這個TCP報文。其次,用戶進程調用read、recv等方法獲取TCP消息,則是將內核已經從網卡上收到的消息流拷貝到用戶進程裏的內存中。

第一幅圖描述的場景是,TCP連接上將要收到的消息序號是S1(TCP上的每個報文都有序號,詳見《TCP/IP協議詳解》),此時操作系統內核依次收到了序號S1-S2的報文、S3-S4、S2-S3的報文,注意後兩個包亂序了。之後,用戶進程分配了一段len大小的內存用於接收TCP消息,此時,len是大於S4-S1的。另外,用戶進程始終沒有對這個socket設置過SO_RCVLOWAT參數,因此,接收閥值SO_RCVLOWAT使用默認值1。另外,系統參數tcp_low_latency設置爲0,即從操作系統的總體效率出發,使用prequeue隊列提升吞吐量。當然,由於用戶進程收消息時,並沒有新包來臨,所以此圖中prequeue隊列始終爲空。先不細表。
圖1如下:

上圖中有13個步驟,應用進程使用了阻塞套接字,調用recv等方法時flag標誌位爲0,用戶進程讀取套接字時沒有發生進程睡眠。內核在處理接收到的TCP報文時使用了4個隊列容器(當鏈表理解也可),分別爲receive、out_of_order、prequeue、backlog隊列,本文會說明它們存在的意義。下面詳細說明這13個步驟。
1、當網卡接收到報文並判斷爲TCP協議後,將會調用到內核的tcp_v4_rcv方法。此時,這個TCP連接上需要接收的下一個報文序號恰好就是S1,而這一步裏,網卡上收到了S1-S2的報文,所以,tcp_v4_rcv方法會把這個報文直接插入到receive隊列中。
注意:receive隊列是允許用戶進程直接讀取的,它是將已經接收到的TCP報文,去除了TCP頭部、排好序放入的、用戶進程可以直接按序讀取的隊列。由於socket不在進程上下文中(也就是沒有進程在讀socket),由於我們需要S1序號的報文,而恰好收到了S1-S2報文,因此,它進入了receive隊列。

2、接着,我們收到了S3-S4報文。在第1步結束後,這時我們需要收到的是S2序號,但到來的報文卻是S3打頭的,怎麼辦呢?進入out_of_order隊列!從這個隊列名稱就可以看出來,所有亂序的報文都會暫時放在這。

3、仍然沒有進入來讀取socket,但又過來了我們期望的S2-S3報文,它會像第1步一樣,直接進入receive隊列。不同的時,由於此時out_of_order隊列不像第1步是空的,所以,引發了接來的第4步。

4、每次向receive隊列插入報文時都會檢查out_of_order隊列。由於收到S2-S3報文後,期待的序號成爲了S3,這樣,out_of_order隊列裏的唯一報文S3-S4報文將會移出本隊列而插入到receive隊列中(這件事由tcp_ofo_queue方法完成)。

5、終於有用戶進程開始讀取socket了。做過應用端編程的同學都知道,先要在進程裏分配一塊內存,接着調用read或者recv等方法,把內存的首地址和內存長度傳入,再把建立好連接的socket也傳入。當然,對這個socket還可以配置其屬性。這裏,假定沒有設置任何屬性,都使用默認值,因此,此時socket是阻塞式,它的SO_RCVLOWAT是默認的1。當然,recv這樣的方法還會接收一個flag參數,它可以設置爲MSG_WAITALL、MSG_PEEK、MSG_TRUNK等等,這裏我們假定爲最常用的0。進程調用了recv方法。

6、無論是何種接口,C庫和內核經過層層封裝,接收TCP消息最終一定會走到tcp_recvmsg方法。下面介紹代碼細節時,它會是重點。

7、在tcp_recvmsg方法裏,會首先鎖住socket。爲什麼呢?因此socket是可以被多進程同時使用的,同時,內核中斷也會操作它,而下面的代碼都是核心的、操作數據的、有狀態的代碼,不可以被重入的,鎖住後,再有用戶進程進來時拿不到鎖就要休眠在這了。內核中斷看到被鎖住後也會做不同的處理,參見圖2、圖3。

8、此時,第1-4步已經爲receive隊列裏準備好了3個報文。最上面的報文是S1-S2,將它拷貝到用戶態內存中。由於第5步flag參數並沒有攜帶MSG_PEEK這樣的標誌位,因此,再將S1-S2報文從receive隊列的頭部移除,從內核態釋放掉。反之,MSG_PEEK標誌位會導致receive隊列不會刪除報文。所以,MSG_PEEK主要用於多進程讀取同一套接字的情形。

9、如第8步,拷貝S2-S3報文到用戶態內存中。當然,執行拷貝前都會檢查用戶態內存的剩餘空間是否足以放下當前這個報文,不足以時會直接返回已經拷貝的字節數。
10、同上。

11、receive隊列爲空了,此時會先來檢查SO_RCVLOWAT這個閥值。如果已經拷貝的字節數到現在還小於它,那麼可能導致進程會休眠,等待拷貝更多的數據。第5步已經說明過了,socket套接字使用的默認的SO_RCVLOWAT,也就是1,這表明,只要讀取到報文了,就認爲可以返回了。
做完這個檢查了,再檢查backlog隊列。backlog隊列是進程正在拷貝數據時,網卡收到的報文會進這個隊列。此時若backlog隊列有數據,就順帶處理下。圖3會覆蓋這種場景。

12、在本圖對應的場景中,backlog隊列是沒有數據的,已經拷貝的字節數爲S4-S1,它是大於1的,因此,釋放第7步里加的鎖,準備返回用戶態了。

13、用戶進程代碼開始執行,此時recv等方法返回的就是S4-S1,即從內核拷貝的字節數。


圖1描述的場景是最簡單的1種場景,下面我們來看看上述步驟是怎樣通過內核代碼實現的(以下代碼爲2.6.18內核代碼)。


我們知道,linux對中斷的處理是分爲上半部和下半部的,這是處於系統整體效率的考慮。我們將要介紹的都是在網絡軟中斷的下半部裏,例如這個tcp_v4_rcv方法。圖1中的第1-4步都是在這個方法裏完成的。
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. int tcp_v4_rcv(struct sk_buff *skb)  
  2. {  
  3.         ... ...  
  4.     //是否有進程正在使用這個套接字,將會對處理流程產生影響  
  5.         //或者從代碼層面上,只要在tcp_recvmsg裏,執行lock_sock後只能進入else,而release_sock後會進入if  
  6.     if (!sock_owned_by_user(sk)) {  
  7.         {  
  8.             //當 tcp_prequeue 返回0時,表示這個函數沒有處理該報文  
  9.             if (!tcp_prequeue(sk, skb))//如果報文放在prequeue隊列,即表示延後處理,不佔用軟中斷過長時間  
  10.                 ret = tcp_v4_do_rcv(sk, skb);//不使用prequeue或者沒有用戶進程讀socket時(圖3進入此分支),立刻開始處理這個報文  
  11.         }  
  12.     } else  
  13.         sk_add_backlog(sk, skb);//如果進程正在操作套接字,就把skb指向的TCP報文插入到backlog隊列(圖3涉及此分支)  
  14.         ... ...  
  15. }  

圖1第1步裏,我們從網絡上收到了序號爲S1-S2的包。此時,沒有用戶進程在讀取套接字,因此,sock_owned_by_user(sk)會返回0。所以,tcp_prequeue方法將得到執行。簡單看看它:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. static inline int tcp_prequeue(struct sock *sk, struct sk_buff *skb)  
  2. {  
  3.     struct tcp_sock *tp = tcp_sk(sk);  
  4.   
  5.     //檢查tcp_low_latency,默認其爲0,表示使用prequeue隊列。tp->ucopy.task不爲0,表示有進程啓動了拷貝TCP消息的流程  
  6.     if (!sysctl_tcp_low_latency && tp->ucopy.task) {  
  7.         //到這裏,通常是用戶進程讀數據時沒讀到指定大小的數據,休眠了。直接將報文插入prequeue隊列的末尾,延後處理  
  8.         __skb_queue_tail(&tp->ucopy.prequeue, skb);  
  9.         tp->ucopy.memory += skb->truesize;  
  10.         //當然,雖然通常是延後處理,但如果TCP的接收緩衝區不夠用了,就會立刻處理prequeue隊列裏的所有報文  
  11.         if (tp->ucopy.memory > sk->sk_rcvbuf) {  
  12.             while ((skb1 = __skb_dequeue(&tp->ucopy.prequeue)) != NULL) {  
  13.                                 //sk_backlog_rcv就是下文將要介紹的tcp_v4_do_rcv方法  
  14.                 sk->sk_backlog_rcv(sk, skb1);  
  15.             }  
  16.         } else if (skb_queue_len(&tp->ucopy.prequeue) == 1) {  
  17.                         //prequeue裏有報文了,喚醒正在休眠等待數據的進程,讓進程在它的上下文中處理這個prequeue隊列的報文  
  18.             wake_up_interruptible(sk->sk_sleep);  
  19.         }  
  20.   
  21.         return 1;  
  22.     }  
  23.     //prequeue沒有處理  
  24.     return 0;  
  25. }  

由於tp->ucopy.task此時是NULL,所以我們收到的第1個報文在tcp_prequeue函數裏直接返回了0,因此,將由 tcp_v4_do_rcv方法處理。
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)  
  2. {  
  3.     if (sk->sk_state == TCP_ESTABLISHED) { /* Fast path */  
  4.         //當TCP連接已經建立好時,是由tcp_rcv_established方法處理接收報文的  
  5.         if (tcp_rcv_established(sk, skb, skb->h.th, skb->len))  
  6.             goto reset;  
  7.   
  8.         return 0;  
  9.     }  
  10.         ... ...  
  11. }  

tcp_rcv_established方法在圖1裏,主要調用tcp_data_queue方法將報文放入隊列中,繼續看看它又幹了些什麼事:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. static void tcp_data_queue(struct sock *sk, struct sk_buff *skb)  
  2. {  
  3.     struct tcp_sock *tp = tcp_sk(sk);  
  4.   
  5.     //如果這個報文是待接收的報文(看seq),它有兩個出路:進入receive隊列,正如圖1;直接拷貝到用戶內存中,如圖3  
  6.     if (TCP_SKB_CB(skb)->seq == tp->rcv_nxt) {  
  7.                 //滑動窗口外的包暫不考慮,篇幅有限,下次再細談  
  8.         if (tcp_receive_window(tp) == 0)  
  9.             goto out_of_window;  
  10.   
  11.         //如果有一個進程正在讀取socket,且正準備要拷貝的序號就是當前報文的seq序號  
  12.         if (tp->ucopy.task == current &&  
  13.             tp->copied_seq == tp->rcv_nxt && tp->ucopy.len &&  
  14.             sock_owned_by_user(sk) && !tp->urg_data) {  
  15.             //直接將報文內容拷貝到用戶態內存中,參見圖3  
  16.             if (!skb_copy_datagram_iovec(skb, 0, tp->ucopy.iov, chunk)) {  
  17.                 tp->ucopy.len -= chunk;  
  18.                 tp->copied_seq += chunk;  
  19.             }  
  20.         }  
  21.   
  22.         if (eaten <= 0) {  
  23. queue_and_out:  
  24.                         //如果沒有能夠直接拷貝到用戶內存中,那麼,插入receive隊列吧,正如圖1中的第1、3步  
  25.             __skb_queue_tail(&sk->sk_receive_queue, skb);  
  26.         }  
  27.                 //更新待接收的序號,例如圖1第1步中,更新爲S2  
  28.         tp->rcv_nxt = TCP_SKB_CB(skb)->end_seq;  
  29.   
  30.                 //正如圖1第4步,這時會檢查out_of_order隊列,若它不爲空,需要處理它  
  31.         if (!skb_queue_empty(&tp->out_of_order_queue)) {  
  32.                         //tcp_ofo_queue方法會檢查out_of_order隊列中的所有報文  
  33.             tcp_ofo_queue(sk);  
  34.         }  
  35.     }  
  36.         ... ...  
  37.   
  38.     //這個包是無序的,又在接收滑動窗口內,那麼就如圖1第2步,把報文插入到out_of_order隊列吧  
  39.     if (!skb_peek(&tp->out_of_order_queue)) {  
  40.         __skb_queue_head(&tp->out_of_order_queue,skb);  
  41.     } else {  
  42.                     ... ...  
  43.             __skb_append(skb1, skb, &tp->out_of_order_queue);  
  44.     }  
  45. }  

圖1第4步時,正是通過tcp_ofo_queue方法把之前亂序的S3-S4報文插入receive隊列的。
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. static void tcp_ofo_queue(struct sock *sk)  
  2. {  
  3.     struct tcp_sock *tp = tcp_sk(sk);  
  4.     __u32 dsack_high = tp->rcv_nxt;  
  5.     struct sk_buff *skb;  
  6.         //遍歷out_of_order隊列  
  7.     while ((skb = skb_peek(&tp->out_of_order_queue)) != NULL) {  
  8.         ... ...  
  9.                 //若這個報文可以按seq插入有序的receive隊列中,則將其移出out_of_order隊列  
  10.         __skb_unlink(skb, &tp->out_of_order_queue);  
  11.                 //插入receive隊列  
  12.         __skb_queue_tail(&sk->sk_receive_queue, skb);  
  13.                 //更新socket上待接收的下一個有序seq  
  14.         tp->rcv_nxt = TCP_SKB_CB(skb)->end_seq;  
  15.     }  
  16. }  


下面再介紹圖1第6步提到的tcp_recvmsg方法。
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. //參數裏的len就是read、recv方法裏的內存長度,flags正是方法的flags參數,nonblock則是阻塞、非阻塞標誌位  
  2. int tcp_recvmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg,  
  3.         size_t len, int nonblock, int flags, int *addr_len)  
  4. {  
  5.     //鎖住socket,防止多進程併發訪問TCP連接,告知軟中斷目前socket在進程上下文中  
  6.     lock_sock(sk);  
  7.   
  8.         //初始化errno這個錯誤碼  
  9.     err = -ENOTCONN;  
  10.   
  11.     //如果socket是阻塞套接字,則取出SO_RCVTIMEO作爲讀超時時間;若爲非阻塞,則timeo爲0。下面會看到timeo是如何生效的  
  12.     timeo = sock_rcvtimeo(sk, nonblock);  
  13.   
  14.     //獲取下一個要拷貝的字節序號  
  15.     //注意:seq的定義爲u32 *seq;,它是32位指針。爲何?因爲下面每向用戶態內存拷貝後,會更新seq的值,這時就會直接更改套接字上的copied_seq  
  16.     seq = &tp->copied_seq;  
  17.     //當flags參數有MSG_PEEK標誌位時,意味着這次拷貝的內容,當再次讀取socket時(比如另一個進程)還能再次讀到  
  18.     if (flags & MSG_PEEK) {  
  19.         //所以不會更新copied_seq,當然,下面會看到也不會刪除報文,不會從receive隊列中移除報文  
  20.         peek_seq = tp->copied_seq;  
  21.         seq = &peek_seq;  
  22.     }  
  23.   
  24.     //獲取SO_RCVLOWAT最低接收閥值,當然,target實際上是用戶態內存大小len和SO_RCVLOWAT的最小值  
  25.     //注意:flags參數中若攜帶MSG_WAITALL標誌位,則意味着必須等到讀取到len長度的消息才能返回,此時target只能是len  
  26.     target = sock_rcvlowat(sk, flags & MSG_WAITALL, len);  
  27.   
  28.         //以下開始讀取消息  
  29.     do {  
  30.         //從receive隊列取出1個報文  
  31.         skb = skb_peek(&sk->sk_receive_queue);  
  32.         do {  
  33.             //沒取到退出當前循環  
  34.             if (!skb)  
  35.                 break;  
  36.   
  37.   
  38.             //offset是待拷貝序號在當前這個報文中的偏移量,在圖1、2、3中它都是0,只有因爲用戶內存不足以接收完1個報文時才爲非0  
  39.             offset = *seq - TCP_SKB_CB(skb)->seq;  
  40.             //有些時候,三次握手的SYN包也會攜帶消息內容的,此時seq是多出1的(SYN佔1個序號),所以offset減1  
  41.             if (skb->h.th->syn)  
  42.                 offset--;  
  43.             //若偏移量還有這個報文之內,則認爲它需要處理  
  44.             if (offset < skb->len)  
  45.                 goto found_ok_skb;  
  46.   
  47.             skb = skb->next;  
  48.         } while (skb != (struct sk_buff *)&sk->sk_receive_queue);  
  49.   
  50.         //如果receive隊列爲空,則檢查已經拷貝的字節數,是否達到了SO_RCVLOWAT或者長度len。滿足了,且backlog隊列也爲空,則可以返回用戶態了,正如圖1的第11步  
  51.         if (copied >= target && !sk->sk_backlog.tail)  
  52.             break;  
  53.   
  54.                 //在tcp_recvmsg裏,copied就是已經拷貝的字節數  
  55.         if (copied) {  
  56.             ... ...  
  57.         } else {  
  58.                         //一個字節都沒拷貝到,但如果shutdown關閉了socket,一樣直接返回。當然,本文不涉及關閉連接  
  59.             if (sk->sk_shutdown & RCV_SHUTDOWN)  
  60.                 break;  
  61.   
  62.             //如果使用了非阻塞套接字,此時timeo爲0  
  63.             if (!timeo) {  
  64.                                 //非阻塞套接字讀取不到數據時也會返回,錯誤碼正是EAGAIN  
  65.                 copied = -EAGAIN;  
  66.                 break;  
  67.             }  
  68.                         ... ...  
  69.         }  
  70.   
  71.         //tcp_low_latency默認是關閉的,圖1、圖2都是如此,圖3則例外,即圖3不會走進這個if  
  72.         if (!sysctl_tcp_low_latency && tp->ucopy.task == user_recv) {  
  73.             //prequeue隊列就是爲了提高系統整體效率的,即prequeue隊列有可能不爲空,這是因爲進程休眠等待時可能有新報文到達prequeue隊列  
  74.             if (!skb_queue_empty(&tp->ucopy.prequeue))  
  75.                 goto do_prequeue;  
  76.         }  
  77.   
  78.         //如果已經拷貝了的字節數超過了最低閥值  
  79.         if (copied >= target) {  
  80.             //release_sock這個方法會遍歷、處理backlog隊列中的報文  
  81.             release_sock(sk);  
  82.             lock_sock(sk);  
  83.         } else  
  84.             sk_wait_data(sk, &timeo);//沒有讀取到足夠長度的消息,因此會進程休眠,如果沒有被喚醒,最長睡眠timeo時間  
  85.   
  86.         if (user_recv) {  
  87.             if (tp->rcv_nxt == tp->copied_seq &&  
  88.                 !skb_queue_empty(&tp->ucopy.prequeue)) {  
  89. do_prequeue:  
  90.                                 //接上面代碼段,開始處理prequeue隊列裏的報文  
  91.                 tcp_prequeue_process(sk);  
  92.             }  
  93.         }  
  94.   
  95.         //繼續處理receive隊列的下一個報文  
  96.         continue;  
  97.   
  98.     found_ok_skb:  
  99.         /* Ok so how much can we use? */  
  100.         //receive隊列的這個報文從其可以使用的偏移量offset,到總長度len之間,可以拷貝的長度爲used  
  101.         used = skb->len - offset;  
  102.         //len是用戶態空閒內存,len更小時,當然只能拷貝len長度消息,總不能導致內存溢出吧  
  103.         if (len < used)  
  104.             used = len;  
  105.   
  106.         //MSG_TRUNC標誌位表示不要管len這個用戶態內存有多大,只管拷貝數據吧  
  107.         if (!(flags & MSG_TRUNC)) {  
  108.             {  
  109.                 //向用戶態拷貝數據  
  110.                 err = skb_copy_datagram_iovec(skb, offset,  
  111.                         msg->msg_iov, used);  
  112.             }  
  113.         }  
  114.   
  115.         //因爲是指針,所以同時更新copied_seq--下一個待接收的序號  
  116.         *seq += used;  
  117.         //更新已經拷貝的長度  
  118.         copied += used;  
  119.         //更新用戶態內存的剩餘空閒空間長度  
  120.         len -= used;  
  121.   
  122.                 ... ...  
  123.     } while (len > 0);  
  124.   
  125.     //已經裝載了接收器  
  126.     if (user_recv) {  
  127.         //prequeue隊列不爲空則處理之  
  128.         if (!skb_queue_empty(&tp->ucopy.prequeue)) {  
  129.             tcp_prequeue_process(sk);  
  130.         }  
  131.   
  132.         //準備返回用戶態,socket上不再裝載接收任務  
  133.         tp->ucopy.task = NULL;  
  134.         tp->ucopy.len = 0;  
  135.     }  
  136.   
  137.     //釋放socket時,還會檢查、處理backlog隊列中的報文  
  138.     release_sock(sk);  
  139.     //向用戶返回已經拷貝的字節數  
  140.     return copied;  
  141. }  


圖2給出了第2種場景,這裏涉及到prequeue隊列。用戶進程調用recv方法時,連接上沒有任何接收並緩存到內核的報文,而socket是阻塞的,所以進程睡眠了。然後網卡中收到了TCP連接上的報文,此時prequeue隊列開始產生作用。圖2中tcp_low_latency爲默認的0,套接字socket的SO_RCVLOWAT是默認的1,仍然是阻塞socket,如下圖:

簡單描述上述11個步驟:
1、用戶進程分配了一塊len大小的內存,將其傳入recv這樣的函數,同時socket參數皆爲默認,即阻塞的、SO_RCVLOWAT爲1。調用接收方法,其中flags參數爲0。

2、C庫和內核最終調用到tcp_recvmsg方法來處理。

3、鎖住socket。

4、由於此時receive、prequeue、backlog隊列都是空的,即沒有拷貝1個字節的消息到用戶內存中,而我們的最低要求是拷貝至少SO_RCVLOWAT爲1長度的消息。此時,開始進入阻塞式套接字的等待流程。最長等待時間爲SO_RCVTIMEO指定的時間。
這個等待函數叫做sk_wait_data,有必要看下其實現:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. int sk_wait_data(struct sock *sk, long *timeo)  
  2. {  
  3.         //注意,它的自動喚醒條件有兩個,要麼timeo時間到達,要麼receive隊列不爲空  
  4.     rc = sk_wait_event(sk, timeo, !skb_queue_empty(&sk->sk_receive_queue));  
  5. }  

sk_wait_event也值得我們簡單看下:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. #define sk_wait_event(__sk, __timeo, __condition)       \  
  2. ({  int rc;                         \  
  3.     release_sock(__sk);                 \  
  4.     rc = __condition;                   \  
  5.     if (!rc) {                      \  
  6.         *(__timeo) = schedule_timeout(*(__timeo));  \  
  7.     }                           \  
  8.     lock_sock(__sk);                    \  
  9.     rc = __condition;                   \  
  10.     rc;                         \  
  11. })  

注意,它在睡眠前會調用release_sock,這個方法會釋放socket鎖,使得下面的第5步中,新到的報文不再只能進入backlog隊列。

5、這個套接字上期望接收的序號也是S1,此時網卡恰好收到了S1-S2的報文,在tcp_v4_rcv方法中,通過調用tcp_prequeue方法把報文插入到prequeue隊列中。

6、插入prequeue隊列後,此時會接着調用wake_up_interruptible方法,喚醒在socket上睡眠的進程。參見tcp_prequque方法。

7、用戶進程被喚醒後,重新調用lock_sock接管了這個socket,此後再進來的報文都只能進入backlog隊列了。

8、進程醒來後,先去檢查receive隊列,當然仍然是空的;再去檢查prequeue隊列,發現有一個報文S1-S2,正好是socket連接待拷貝的起始序號S1,於是,從prequeue隊列中取出這個報文並把內容複製到用戶內存中,再釋放內核中的這個報文。

9、目前已經拷貝了S2-S1個字節到用戶態,檢查這個長度是否超過了最低閥值(即len和SO_RCVLOWAT的最小值)。

10、由於SO_RCVLOWAT使用了默認的1,所以準備返回用戶。此時會順帶再看看backlog隊列中有沒有數據,若有,則檢查這個無序的隊列中是否有可以直接拷貝給用戶的報文。當然,此時是沒有的。所以準備返回,釋放socket鎖。

11、返回用戶已經拷貝的字節數。

圖3給出了第3種場景。這個場景中,我們把系統參數tcp_low_latency設爲1,socket上設置了SO_RCVLOWAT屬性的值。服務器先是收到了S1-S2這個報文,但S2-S1的長度是小於SO_RCVLOWAT的,用戶進程調用recv方法讀套接字時,雖然讀到了一些,但沒有達到最小閥值,所以進程睡眠了,與此同時,在睡眠前收到的亂序的S3-S4包直接進入backlog隊列。此時先到達了S2-S3包,由於沒有使用prequeue隊列,而它起始序號正是下一個待拷貝的值,所以直接拷貝到用戶內存中,總共拷貝字節數已滿足SO_RCVLOWAT的要求!最後在返回用戶前把backlog隊列中S3-S4報文也拷貝給用戶了。如下圖:

簡明描述上述15個步驟:
1、內核收到報文S1-S2,S1正是這個socket連接上待接收的序號,因此,直接將它插入有序的receive隊列中。

2、用戶進程所處的linux操作系統上,將sysctl中的tcp_low_latency設置爲1。這意味着,這臺服務器希望TCP進程能夠更及時的接收到TCP消息。用戶調用了recv方法接收socket上的消息,這個socket上設置了SO_RCVLOWAT屬性爲某個值n,這個n是大於S2-S1,也就是第1步收到的報文大小。這裏,仍然是阻塞socket,用戶依然是分配了足夠大的len長度內存以接收TCP消息。

3、通過tcp_recvmsg方法來完成接收工作。先鎖住socket,避免併發進程讀取同一socket的同時,也在告訴內核網絡軟中斷處理到這一socket時要有不同行爲,如第6步。

4、準備處理內核各個接收隊列中的報文。

5、receive隊列中的有序報文可直接拷貝,在檢查到S2-S1是小於len之後,將報文內容拷貝到用戶態內存中。

6、在第5步進行的同時,socket是被鎖住的,這時內核又收到了一個S3-S4報文,因此報文直接進入backlog隊列。注意,這個報文不是有序的,因爲此時連接上期待接收序號爲S2。

7、在第5步,拷貝了S2-S1個字節到用戶內存,它是小於SO_RCVLOWAT的,因此,由於socket是阻塞型套接字(超時時間在本文中忽略),進程將不得不轉入睡眠。轉入睡眠之前,還會幹一件事,就是處理backlog隊列裏的報文,圖2的第4步介紹過休眠方法sk_wait_data,它在睡眠前會執行release_sock方法,看看是如何實現的:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. void fastcall release_sock(struct sock *sk)  
  2. {  
  3.     mutex_release(&sk->sk_lock.dep_map, 1, _RET_IP_);  
  4.   
  5.     spin_lock_bh(&sk->sk_lock.slock);  
  6.         //這裏會遍歷backlog隊列中的每一個報文  
  7.     if (sk->sk_backlog.tail)  
  8.         __release_sock(sk);  
  9.         //這裏是網絡中斷執行時,告訴內核,現在socket並不在進程上下文中  
  10.     sk->sk_lock.owner = NULL;  
  11.     if (waitqueue_active(&sk->sk_lock.wq))  
  12.         wake_up(&sk->sk_lock.wq);  
  13.     spin_unlock_bh(&sk->sk_lock.slock);  
  14. }  

再看看__release_sock方法是如何遍歷backlog隊列的:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. static void __release_sock(struct sock *sk)  
  2. {  
  3.     struct sk_buff *skb = sk->sk_backlog.head;  
  4.   
  5.         //遍歷backlog隊列  
  6.     do {  
  7.         sk->sk_backlog.head = sk->sk_backlog.tail = NULL;  
  8.         bh_unlock_sock(sk);  
  9.   
  10.         do {  
  11.             struct sk_buff *next = skb->next;  
  12.   
  13.             skb->next = NULL;  
  14.                         //處理報文,其實就是tcp_v4_do_rcv方法,上文介紹過,不再贅述  
  15.             sk->sk_backlog_rcv(sk, skb);  
  16.   
  17.             cond_resched_softirq();  
  18.   
  19.             skb = next;  
  20.         } while (skb != NULL);  
  21.   
  22.         bh_lock_sock(sk);  
  23.     } while((skb = sk->sk_backlog.head) != NULL);  
  24. }  

此時遍歷到S3-S4報文,但因爲它是失序的,所以從backlog隊列中移入out_of_order隊列中(參見上文說過的tcp_ofo_queue方法)。

8、進程休眠,直到超時或者receive隊列不爲空。

9、內核接收到了S2-S3報文。注意,這裏由於打開了tcp_low_latency標誌位,這個報文是不會進入prequeue隊列以待進程上下文處理的。

10、此時,由於S2是連接上正要接收的序號,同時,有一個用戶進程正在休眠等待接收數據中,且它要等待的數據起始序號正是S2,於是,這種種條件下,使得這一步同時也是網絡軟中斷執行上下文中,把S2-S3報文直接拷貝進用戶內存。

11、上文介紹tcp_data_queue方法時大家可以看到,每處理完1個有序報文(無論是拷貝到receive隊列還是直接複製到用戶內存)後都會檢查out_of_order隊列,看看是否有報文可以處理。那麼,S3-S4報文恰好是待處理的,於是拷貝進用戶內存。然後喚醒用戶進程。

12、用戶進程被喚醒了,當然喚醒後會先來拿到socket鎖。以下執行又在進程上下文中了。

13、此時會檢查已拷貝的字節數是否大於SO_RCVLOWAT,以及backlog隊列是否爲空。兩者皆滿足,準備返回。

14、釋放socket鎖,退出tcp_recvmsg方法。

15、返回用戶已經複製的字節數S4-S1。


好了,這3個場景讀完,想必大家對於TCP的接收流程是怎樣的已經非常清楚了,本文起始的6個問題也在這一大篇中都涉及到了。下一篇我們來討論TCP連接的關閉。

高性能網絡編程4--TCP連接的關閉

標籤: tcpcloseshutdown四次握手網絡編程
2013-10-26 12:24  9907人閱讀  評論(2)  收藏  舉報
  分類:
 
   
TCP連接的關閉有兩個方法close和shutdown,這篇文章將盡量精簡的說明它們分別做了些什麼。
爲方便閱讀,我們可以帶着以下5個問題來閱讀本文:
1、當socket被多進程或者多線程共享時,關閉連接時有何區別?
2、關連接時,若連接上有來自對端的還未處理的消息,會怎麼處理?
3、關連接時,若連接上有本進程待發送卻未來得及發送出的消息,又會怎麼處理?
4、so_linger這個功能的用處在哪?
5、對於監聽socket執行關閉,和對處於ESTABLISH這種通訊的socket執行關閉,有何區別?

下面分三部分進行:首先說說多線程多進程關閉連接的區別;再用一幅流程圖談談close;最後用一幅流程圖說說shutdown。

先不提其原理和實現,從多進程、多線程下 close和shutdown方法調用時的區別說起。
看看close與shutdown這兩個系統調用對應的內核函數:(參見unistd.h文件)
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. #define __NR_close                               3  
  2. __SYSCALL(__NR_close, sys_close)  
  3. #define __NR_shutdown                           48  
  4. __SYSCALL(__NR_shutdown, sys_shutdown)  

但sys_close和sys_shutdown這兩個系統調用最終是由tcp_close和tcp_shutdown方法來實現的,調用過程如下圖所示:

sys_shutdown與多線程和多進程都沒有任何關係,而sys_close則不然,上圖中可以看到,層層封裝調用中有一個方法叫fput,它有一個引用計數,記錄這個socket被引用了多少次。在說明多線程或者多進程調用close的區別前,先在代碼上簡單看下close是怎麼調用的,對內核代碼沒興趣的同學可以僅看fput方法:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. void fastcall fput(struct file *file)  
  2. {  
  3.     if (atomic_dec_and_test(&file->f_count))//檢查引用計數,直到爲0纔會真正去關閉socket  
  4.         __fput(file);  
  5. }  

當這個socket的引用計數f_count不爲0時,是不會觸發到真正關閉TCP連接的tcp_close方法的。
那麼,這個引用計數的意義何在呢?爲了說明它,先要說道下進程與線程的區別。

大家知道,所謂線程其實就是「輕量級」的進程。創建進程只能是一個進程(父進程)創建另一個進程(子進程),子進程會複製父進程的資源,這裏的」複製「針對不同的資源其意義是不同的,例如對內存、文件、TCP連接等。創建進程是由clone系統調用實現的,而創建線程時同樣也是clone實現的,只不過clone的參數不同,其行爲也很不同。這個話題是很大的,這裏我們僅討論下TCP連接。
在clone系統調用中,會調用方法copy_files來拷貝文件描述符(包括socket)。創建線程時,傳入的flag參數中包含標誌位CLONE_FILES,此時,線程將會共享父進程中的文件描述符。而創建進程時沒有這個標誌位,這時,會把進程打開的所有文件描述符的引用計數加1,即把file數據結構的f_count成員加1,如下:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. static int copy_files(unsigned long clone_flags, struct task_struct * tsk)  
  2. {  
  3.     if (clone_flags & CLONE_FILES) {  
  4.         goto out;//創建線程  
  5.     }  
  6.     newf = dup_fd(oldf, &error);  
  7. out:  
  8.     return error;  
  9. }  

再看看dup_fd方法:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. static struct files_struct *dup_fd(struct files_struct *oldf, int *errorp)  
  2. {  
  3.     for (i = open_files; i != 0; i--) {  
  4.         struct file *f = *old_fds++;  
  5.         if (f) {  
  6.             get_file(f);//創建進程  
  7.         }  
  8.     }  
  9. }  

get_file宏就會加引用計數。
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. #define get_file(x) atomic_inc(&(x)->f_count)  

所以,子進程會將父進程中已經建立的socket加上引用計數。當進程中close一個socket時,只會減少引用計數,僅當引用計數爲0時纔會觸發tcp_close。

到這裏,對於第一個問題的close調用自然有了結論:單線程(進程)中使用close與多線程中是一致的,但這兩者與多進程的行爲並不一致,多進程中共享的同一個socket必須都調用了close纔會真正的關閉連接。

而shutdown則不然,這裏是沒有引用計數什麼事的,只要調用了就會去試圖按需關閉連接。所以,調用shutdown與多線程、多進程無關。


下面我們首先深入探討下close的行爲,因爲close比較shutdown來說要複雜許多。順便回答其餘四個問題。
TCP連接是一種雙工的連接,何謂雙工?即連接雙方可以並行的發送或者接收消息,而無須顧及對方此時到底在發還是收消息。這樣,關閉連接時,就存在3種情形:完全關閉連接;關閉發送消息的功能;關閉接收消息的功能。其中,後兩者就叫做半關閉,由shutdown實現(所以 shutdown多出一個參數正是控制關閉發送或者關閉接收),前者由close實現。

TCP連接是一種可靠的連接,在這裏可以這麼理解:既要確認本機發出的包得到確認,又要確認收到的任何消息都已告知連接的對端。
以下主要從雙工、可靠性這兩點上理解連接的關閉。

TCP雙工的這個特性使得連接的正常關閉需要四次握手,其含義爲:主動端關閉了發送的功能;被動端認可;被動端也關閉了發送的功能;主動端認可。
但還存在程序異常的情形,此時,則通過異常的那端發送RST復位報文通知另一端關閉連接。
下圖是close的主要流程:

這個圖稍複雜,這是因爲它覆蓋了關閉監聽句柄、關閉普通連接、關閉設置了SO_LINGER的連接這三種主要場景。

1)關閉監聽句柄
先從最右邊的分支說說關閉監聽socket的那些事。用於listen的監聽句柄也是使用close關閉,關閉這樣的句柄含義當然很不同,它本身並不對應着某個TCP連接,但是,附着在它之上的卻可能有半成品連接。什麼意思呢?之前說過TCP是雙工的,它的打開需要三次握手,三次握手也就是3個步驟,其含義爲:客戶端打開接收、發送的功能;服務器端認可並也打開接收、發送的功能;客戶端認可。當第1、2步驟完成、第3步步驟未完成時,就會在服務器上有許多半連接,close這個操作主要是清理這些連接。
參照上圖,close首先會移除keepalive定時器。keepalive功能常用於服務器上,防止僵死、異常退出的客戶端佔用服務器連接資源。移除此定時器後,若ESTABLISH狀態的TCP連接在tcp_keepalive_time時間(如服務器上常配置爲2小時)內沒有通訊,服務器就會主動關閉連接。
接下來,關閉每一個半連接。如何關閉半連接?這時當然不能發FIN包,即正常的四次握手關閉連接,而是會發送RST復位標誌去關閉請求。處理完所有半打開的連接close的任務就基本完成了。

2)關閉普通ESTABLISH狀態的連接(未設置so_linger)
首先檢查是否有接收到卻未處理的消息。
如果close調用時存在收到遠端的、沒有處理的消息,這時根據close這一行爲的意義,是要丟棄這些消息的。但丟棄消息後,意味着連接遠端誤以爲發出的消息已經被本機收到處理了(因爲ACK包確認過了),但實際上確是收到未處理,此時也不能使用正常的四次握手關閉,而是會向遠端發送一個RST非正常復位關閉連接。這個做法的依據請參考draft-ietf-tcpimpl-prob-03.txt文檔3.10節, Failure to RST on close with data pending。所以,這也要求我們程序員在關閉連接時,要確保已經接收、處理了連接上的消息。

如果此時沒有未處理的消息,那麼進入發送FIN來關閉連接的階段。
這時,先看看是否有待發送的消息。前一篇已經說過,發消息時要計算滑動窗口、擁塞窗口、angle算法等,這些因素可能導致消息會延遲發送的。如果有待發送的消息,那麼要盡力保證這些消息都發出去的。所以,會在最後一個報文中加入FIN標誌,同時,關閉用於減少網絡中小報文的angle算法,向連接對端發送消息。如果沒有待發送的消息,則構造一個報文,僅含有FIN標誌位,發送出去關閉連接。

3)使用了so_linger的連接
首先要澄清,爲何要有so_linger這個功能?因爲我們可能有強可靠性的需求,也就是說,必須確保發出的消息、FIN都被對方收到。例如,有些響應發出後調用close關閉連接,接下來就會關閉進程。如果close時發出的消息其實丟失在網絡中了,那麼,進程突然退出時連接上發出的RST就可能被對方收到,而且,之前丟失的消息不會有重發來保障可靠性了。
so_linger用來保證對方收到了close時發出的消息,即,至少需要對方通過發送ACK且到達本機。
怎麼保證呢?等待!close會阻塞住進程,直到確認對方收到了消息再返回。然而,網絡環境又得複雜的,如果對方總是不響應怎麼辦?所以還需要l_linger這個超時時間,控制close阻塞進程的最長時間。注意,務必慎用so_linger,它會在不經意間降低你程序中代碼的執行速度(close的阻塞)。

所以,當這個進程設置了so_linger後,前半段依然沒變化。檢查是否有未讀消息,若有則發RST關連接,不會觸發等待。接下來檢查是否有未發送的消息時與第2種情形一致,設好FIN後關閉angle算法發出。接下來,則會設置最大等待時間l_linger,然後開始將進程睡眠,直到確認對方收到後纔會醒來,將控制權交還給用戶進程。

這裏需要注意,so_linger不是確保連接被四次握手關閉再使close返回,而只是保證我方發出的消息都已被對方收到。例如,若對方程序寫的有問題,當它收到FIN進入CLOSE_WAIT狀態,卻一直不調用close發出FIN,此時,對方仍然會通過ACK確認,我方收到了ACK進入FIN_WAIT2狀態,但沒收到對方的FIN,我方的close調用卻不會再阻塞,close直接返回,控制權交還用戶進程。

從上圖可知,so_linger還有個偏門的用法,若l_linger超時時間竟被設爲0,則不會觸發FIN包的發送,而是直接RST復位關閉連接。我個人認爲,這種玩法確沒多大用處。


最後做個總結。調用close時,可能導致發送RST復位關閉連接,例如有未讀消息、打開so_linger但l_linger卻爲0、關閉監聽句柄時半打開的連接。更多時會導致發FIN來四次握手關閉連接,但打開so_linger可能導致close阻塞住等待着對方的ACK表明收到了消息。

最後來看看較爲簡單的shutdown。

解釋下上圖:
1)shutdown可攜帶一個參數,取值有3個,分別意味着:只關閉讀、只關閉寫、同時關閉讀寫。
對於監聽句柄,如果參數爲關閉寫,顯然沒有任何意義。但關閉讀從某方面來說是有意義的,例如不再接受新的連接。看看最右邊藍色分支,針對監聽句柄,若參數爲關閉寫,則不做任何事;若爲關閉讀,則把端口上的半打開連接使用RST關閉,與close如出一轍。
2)若shutdown的是半打開的連接,則發出RST來關閉連接。
3)若shutdown的是正常連接,那麼關閉讀其實與對端是沒有關係的。只要本機把接收掉的消息丟掉,其實就等價於關閉讀了,並不一定非要對端關閉寫的。實際上,shutdown正是這麼幹的。若參數中的標誌位含有關閉讀,只是標識下,當我們調用read等方法時這個標識就起作用了,會使進程讀不到任何數據。
4)若參數中有標誌位爲關閉寫,那麼下面做的事與close是一致的:發出FIN包,告訴對方,本機不會再發消息了。


以上,就是close與shutdown的主要行爲,同時也回答了本文最初的5個問題。下一篇,我們開始討論多路複用中常見的epoll。

高性能網絡編程5--IO複用與併發編程

標籤: epoll網絡編程高性能邊緣觸發ET
2013-12-04 15:57  13784人閱讀  評論(4)  收藏  舉報
  分類:
 
   
對於服務器的併發處理能力,我們需要的是:每一毫秒服務器都能及時處理這一毫秒內收到的數百個不同TCP連接上的報文,與此同時,可能服務器上還有數以十萬計的最近幾秒沒有收發任何報文的相對不活躍連接。同時處理多個並行發生事件的連接,簡稱爲併發;同時處理萬計、十萬計的連接,則是高併發。服務器的併發編程所追求的就是處理的併發連接數目無限大,同時維持着高效率使用CPU等資源,直至物理資源首先耗盡。

併發編程有很多種實現模型,最簡單的就是與「線程」捆綁,1個線程處理1個連接的全部生命週期。優點:這個模型足夠簡單,它可以實現複雜的業務場景,同時,線程個數是可以遠大於CPU個數的。然而,線程個數又不是可以無限增大的,爲什麼呢?因爲線程什麼時候執行是由操作系統內核調度算法決定的,調度算法並不會考慮某個線程可能只是爲了一個連接服務的,它會做大一統的玩法:時間片到了就執行一下,哪怕這個線程一執行就會不得不繼續睡眠。這樣來回的喚醒、睡眠線程在次數不多的情況下,是廉價的,但如果操作系統的線程總數很多時,它就是昂貴的(被放大了),因爲這種技術性的調度損耗會影響到線程上執行的業務代碼的時間。舉個例子,這時大部分擁有不活躍連接的線程就像我們的國企,它們執行效率太低了,它總是喚醒就睡眠在做無用功,而它喚醒爭到CPU資源的同時,就意味着處理活躍連接的民企線程減少獲得了CPU的機會,CPU是核心競爭力,它的無效率進而影響了GDP總吞吐量。我們所追求的是併發處理數十萬連接,當幾千個線程出現時,系統的執行效率就已經無法滿足高併發了。

對高併發編程,目前只有一種模型,也是本質上唯一有效的玩法。
從這個系列的前4篇文章可知,連接上的消息處理,可以分爲兩個階段:等待消息準備好、消息處理。當使用默認的阻塞套接字時(例如上面提到的1個線程捆綁處理1個連接),往往是把這兩個階段合而爲一,這樣操作套接字的代碼所在的線程就得睡眠來等待消息準備好,這導致了高併發下線程會頻繁的睡眠、喚醒,從而影響了CPU的使用效率。

高併發編程方法當然就是把兩個階段分開處理。即,等待消息準備好的代碼段,與處理消息的代碼段是分離的。當然,這也要求套接字必須是非阻塞的,否則,處理消息的代碼段很容易導致條件不滿足時,所在線程又進入了睡眠等待階段。那麼問題來了,等待消息準備好這個階段怎麼實現?它畢竟還是等待,這意味着線程還是要睡眠的!解決辦法就是,線程主動查詢,或者讓1個線程爲所有連接而等待!
這就是IO多路複用了。多路複用就是處理等待消息準備好這件事的,但它可以同時處理多個連接!它也可能「等待」,所以它也會導致線程睡眠,然而這不要緊,因爲它一對多、它可以監控所有連接。這樣,當我們的線程被喚醒執行時,就一定是有一些連接準備好被我們的代碼執行了,這是有效率的!沒有那麼多個線程都在爭搶處理「等待消息準備好」階段,整個世界終於清淨了!

多路複用有很多種實現,在linux上,2.4內核前主要是select和poll,現在主流是epoll,它們的使用方法似乎很不同,但本質是一樣的。
效率卻也不同,這也是epoll完全替代了select的原因。

簡單的談下epoll爲何會替代select。
前面提到過,高併發的核心解決方案是1個線程處理所有連接的「等待消息準備好」,這一點上epoll和select是無爭議的。但select預估錯誤了一件事,就像我們開篇所說,當數十萬併發連接存在時,可能每一毫秒只有數百個活躍的連接,同時其餘數十萬連接在這一毫秒是非活躍的。select的使用方法是這樣的:
返回的活躍連接 ==select(全部待監控的連接)
什麼時候會調用select方法呢?在你認爲需要找出有報文到達的活躍連接時,就應該調用。所以,調用select在高併發時是會被頻繁調用的。這樣,這個頻繁調用的方法就很有必要看看它是否有效率,因爲,它的輕微效率損失都會被「頻繁」二字所放大。它有效率損失嗎?顯而易見,全部待監控連接是數以十萬計的,返回的只是數百個活躍連接,這本身就是無效率的表現。被放大後就會發現,處理併發上萬個連接時,select就完全力不從心了。

看幾個圖。當併發連接爲一千以下,select的執行次數不算頻繁,與epoll似乎並無多少差距:

然而,併發數一旦上去,select的缺點被「執行頻繁」無限放大了,且併發數越多越明顯:

再來說說epoll是如何解決的。它很聰明的用了3個方法來實現select方法要做的事:
新建的epoll描述符==epoll_create()
epoll_ctrl(epoll描述符,添加或者刪除所有待監控的連接)
返回的活躍連接 ==epoll_wait( epoll描述符 )
這麼做的好處主要是:分清了頻繁調用和不頻繁調用的操作。例如,epoll_ctrl是不太頻繁調用的,而epoll_wait是非常頻繁調用的。這時,epoll_wait卻幾乎沒有入參,這比select的效率高出一大截,而且,它也不會隨着併發連接的增加使得入參越發多起來,導致內核執行效率下降。

epoll是怎麼實現的呢?其實很簡單,從這3個方法就可以看出,它比select聰明的避免了每次頻繁調用「哪些連接已經處在消息準備好階段」的 epoll_wait時,是不需要把所有待監控連接傳入的。這意味着,它在內核態維護了一個數據結構保存着所有待監控的連接。這個數據結構就是一棵紅黑樹,它的結點的增加、減少是通過epoll_ctrl來完成的。用我在《深入理解Nginx》第8章中所畫的圖來看,它是非常簡單的:

圖中左下方的紅黑樹由所有待監控的連接構成。左上方的鏈表,同是目前所有活躍的連接。於是,epoll_wait執行時只是檢查左上方的鏈表,並返回左上方鏈表中的連接給用戶。這樣,epoll_wait的執行效率能不高嗎?

最後,再看看epoll提供的2種玩法ET和LT,即翻譯過來的邊緣觸發和水平觸發。其實這兩個中文名字倒也有些貼切。這2種使用方式針對的仍然是效率問題,只不過變成了epoll_wait返回的連接如何能夠更準確些。
例如,我們需要監控一個連接的寫緩衝區是否空閒,滿足「可寫」時我們就可以從用戶態將響應調用write發送給客戶端 。但是,或者連接可寫時,我們的「響應」內容還在磁盤上呢,此時若是磁盤讀取還未完成呢?肯定不能使線程阻塞的,那麼就不發送響應了。但是,下一次epoll_wait時可能又把這個連接返回給你了,你還得檢查下是否要處理。可能,我們的程序有另一個模塊專門處理磁盤IO,它會在磁盤IO完成時再發送響應。那麼,每次epoll_wait都返回這個「可寫」的、卻無法立刻處理的連接,是否符合用戶預期呢?

於是,ET和LT模式就應運而生了。LT是每次滿足期待狀態的連接,都得在epoll_wait中返回,所以它一視同仁,都在一條水平線上。ET則不然,它傾向更精確的返回連接。在上面的例子中,連接第一次變爲可寫後,若是程序未向連接上寫入任何數據,那麼下一次epoll_wait是不會返回這個連接的。ET叫做 邊緣觸發,就是指,只有連接從一個狀態轉到另一個狀態時,纔會觸發epoll_wait返回它。可見,ET的編程要複雜不少,至少應用程序要小心的防止epoll_wait的返回的連接出現:可寫時未寫數據後卻期待下一次「可寫」、可讀時未讀盡數據卻期待下一次「可讀」。

當然,從一般應用場景上它們性能是不會有什麼大的差距的,ET可能的優點是,epoll_wait的調用次數會減少一些,某些場景下連接在不必要喚醒時不會被喚醒(此喚醒指epoll_wait返回)。但如果像我上面舉例所說的,有時它不單純是一個網絡問題,跟應用場景相關。當然,大部分開源框架都是基於ET寫的,框架嘛,它追求的是純技術問題,當然力求盡善盡美。

高性能網絡編程6--reactor反應堆與定時器管理

標籤: 網絡編程反應堆定時器redislibevent
2013-12-20 19:37  10841人閱讀  評論(7)  收藏  舉報
  分類:
 
反應堆開發模型被絕大多數高性能服務器所選擇,上一篇所介紹的IO多路複用是它的實現基礎。定時觸發功能通常是服務器必備組件,反應堆模型往往還不得不將定時器的管理囊括在內。本篇將介紹反應堆模型的特點和用法。

首先我們要談談,網絡編程界爲什麼需要反應堆?有了IO複用,有了epoll,我們已經可以使服務器併發幾十萬連接的同時,維持高TPS了,難道這還不夠嗎?
我的答案是,技術層面足夠了,但在軟件工程層面卻是不夠的。
程序使用IO複用的難點在哪裏呢?1個請求雖然由多次IO處理完成,但相比傳統的單線程完整處理請求生命期的方法,IO複用在人的大腦思維中並不自然,因爲,程序員編程中,處理請求A的時候,假定A請求必須經過多個IO操作A1-An(兩次IO間可能間隔很長時間),每經過一次IO操作,再調用IO複用時,IO複用的調用返回裏,非常可能不再有A,而是返回了請求B。即請求A會經常被請求B打斷,處理請求B時,又被C打斷。這種思維下,編程容易出錯。
形象的說,傳統編程方法就好像是到了銀行營業廳裏,每個窗口前排了長隊,業務員們在窗口後一個個的解決客戶們的請求。一個業務員可以盡情思考着客戶A依次提出的問題,例如:
「我要買2萬XX理財產品。「
「看清楚了,5萬起售。」
「等等,查下我活期餘額。」
「餘額5萬。」
「那就買 5萬吧。」
業務員開始錄入信息。
」對了,XX理財產品年利率8%?」
「是預期8%,最低無利息保本。「
」早不說,拜拜,我去買餘額寶。「
業務員無表情的刪着已經錄入的信息進行事務回滾。
」下一個!「

用了IO複用則是大師業務員開始挑戰極限,在超大營業廳裏給客戶們人手一個牌子,黑壓壓的客戶們都在大廳中,有問題時舉牌申請提問,大師目光敏銳點名指定某人提問,該客戶迅速得到大師的答覆後,要經過一段時間思考,查查自己的銀袋子,諮詢下LD,才能再次進行下一個提問,直到得到完整的滿意答覆退出大廳。例如:大師剛指導A填寫轉帳單的某一項,B又來申請兌換泰銖,給了B兌換單後,C又來辦理定轉活,然後D與F在爭搶有限的圓珠筆時出現了不和諧現象,被大師叫停業務,暫時等待。
這就是基於事件驅動的IO複用編程比起傳統1線程1請求的方式來,有難度的設計點了,客戶們都是上帝,既不能出錯,還不能厚此薄彼。

當沒有反應堆時,我們可能的設計方法是這樣的:大師把每個客戶的提問都記錄下來,當客戶A提問時,首先查閱A之前問過什麼做過什麼,這叫聯繫上下文,然後再根據上下文和當前提問查閱有關的銀行規章制度,有針對性的回答A,並把回答也記錄下來。當圓滿回答了A的所有問題後,刪除A的所有記錄。

回到碼農生涯,即,某一瞬間,服務器共有10萬個併發連接,此時,一次IO複用接口的調用返回了100個活躍的連接等待處理。先根據這100個連接找出其對應的對象,這並不難,epoll的返回連接數據結構裏就有這樣的指針可以用。接着,循環的處理每一個連接,找出這個對象此刻的上下文狀態,再使用read、write這樣的網絡IO獲取此次的操作內容,結合上下文狀態查詢此時應當選擇哪個業務方法處理,調用相應方法完成操作後,若請求結束,則刪除對象及其上下文。

這樣,我們就陷入了面向過程編程方法之中了,在面向應用、快速響應爲王的移動互聯網時代,這樣做早晚得把自己玩死。我們的主程序需要關注各種不同類型的請求,在不同狀態下,對於不同的請求命令選擇不同的業務處理方法。這會導致隨着請求類型的增加,請求狀態的增加,請求命令的增加,主程序複雜度快速膨脹,導致維護越來越困難,苦逼的程序員再也不敢輕易接新需求、重構。

反應堆是解決上述軟件工程問題的一種途徑,它也許並不優雅,開發效率上也不是最高的,但其執行效率與面向過程的使用IO複用卻幾乎是等價的,所以,無論是nginx、memcached、redis等等這些高性能組件的代名詞,都義無反顧的一頭扎進了反應堆的懷抱中。
反應堆模式可以在軟件工程層面,將事件驅動框架分離出具體業務,將不同類型請求之間用OO的思想分離。通常,反應堆不僅使用IO複用處理網絡事件驅動,還會實現定時器來處理時間事件的驅動(請求的超時處理或者定時任務的處理),就像下面的示意圖:

這幅圖有5點意思:
(1)處理應用時基於OO思想,不同的類型的請求處理間是分離的。例如,A類型請求是用戶註冊請求,B類型請求是查詢用戶頭像,那麼當我們把用戶頭像新增多種分辨率圖片時,更改B類型請求的代碼處理邏輯時,完全不涉及A類型請求代碼的修改。

(2)應用處理請求的邏輯,與事件分發框架完全分離。什麼意思呢?即寫應用處理時,不用去管何時調用IO複用,不用去管什麼調用epoll_wait,去處理它返回的多個socket連接。應用代碼中,只關心如何讀取、發送socket上的數據,如何處理業務邏輯。事件分發框架有一個抽象的事件接口,所有的應用必須實現抽象的事件接口,通過這種抽象才把應用與框架進行分離。

(3)反應堆上提供註冊、移除事件方法,供應用代碼使用,而分發事件方法,通常是循環的調用而已,是否提供給應用代碼調用,還是由框架簡單粗暴的直接循環使用,這是框架的自由。

(4)IO多路複用也是一個抽象,它可以是具體的select,也可以是epoll,它們只必須提供採集到某一瞬間所有待監控連接中活躍的連接。

(5)定時器也是由反應堆對象使用,它必須至少提供4個方法,包括添加、刪除定時器事件,這該由應用代碼調用。最近超時時間是需要的,這會被反應堆對象使用,用於確認select或者epoll_wait執行時的阻塞超時時間,防止IO的等待影響了定時事件的處理。遍歷也是由反應堆框架使用,用於處理定時事件。

下面用極簡流程來形象說明下反應堆是如何處理一個請求的,下圖中桔色部分皆爲反應堆的分發事件流程:

可以看到,分發IO、定時器事件都由反應堆框架來完成,應用代碼只會關注於如何處理可讀、可寫事件。
當然,上圖是極度簡化的流程,實際上要處理的異常情況都沒有列入。

這裏可以看到,爲什麼定時器集合需要提供最近超時事件距離現在的時間?因爲,調用epoll_wait或者select時,並不能夠始終傳入-1作爲timeout參數。因爲,我們的服務器主營業務往往是網絡請求處理,如果網絡請求很少時,那麼CPU的所有時間都會被頻繁卻又不必要的epoll_wait調用所佔用。在服務器閒時使進程的CPU利用率降低是很有意義的,它可以使服務器上其他進程得到更多的執行機會,也可以延長服務器的壽命,還可以省電。這樣,就需要傳入準確的timeout最大阻塞時間給epoll_wait了。

什麼樣的timeout時間纔是準確的呢?這等價於,我們需要準確的分析,什麼樣的時段進程可以真正休息,進入sleep狀態?
一個沒有意義的答案是:不需要進程執行任務的時間段內是可以休息的。
這就要求我們仔細想想,進程做了哪幾類任務,例如:
1、所有網絡包的處理,例如TCP連接的建立、讀寫、關閉,基本上所有的正常請求都由網絡包來驅動的。對這類任務而言,沒有新的網絡分組到達本機時,就是可以使進程休息的時段。
2、定時器的管理,它與網絡、IO複用無關,雖然它們在業務上可能有相關性。定時器裏的事件需要及時的觸發執行,不能因爲其他原因,例如阻塞在epoll_wait上時耽誤了定時事件的處理。當一段時間內,可以預判沒有定時事件達到觸發條件時(這也是提供接口查詢最近一個定時事件距當下的時間的意義所在),對定時任務的管理而言,進程就可以休息了。
3、其他類型的任務,例如磁盤IO執行完成,或者收到其他進程的signal信號,等等,這些任務明顯不需要執行的時間段內,進程可以休息。

於是,使用反應堆模型的進程代碼中,通常除了epoll_wait這樣的IO複用外,其他調用都會基於無阻塞的方式使用。所以,epoll_wait的timeout超時時間,就是除網絡外,其他任務所能允許的進程睡眠時間。而只考慮常見的定時器任務時,就像上圖中那樣,只需要定時器集合能夠提供最近超時事件到現在的時間即可。

從這裏也可以推導出,定時器集合通常會採用有序容器這樣的數據結構,好處是:
1、容易取到最近超時事件的時間。
2、可以從最近超時事件開始,向後依次遍歷已經超時的事件,直到第一個沒有超時的事件爲止即可停止遍歷,不用全部遍歷到。

因此,粗暴的採用無序的數據結構,例如普通的鏈表,通常是不足取的。但事無絕對,redis就是用了個毫無順序的鏈表,原因何在?因爲redis的客戶端連接沒有超時概念,所以對於併發的成千上萬個連上,都不會因爲超時被斷開。redis的定時器唯一的用途在於定時的將內存數據刷到磁盤上,這樣的定時事件通常只有個位數,其性能無關緊要。

如果定時事件非常多,綜合插入、遍歷、刪除的使用頻率,使用樹的機會最多,例如小根堆(libevent)、二叉平衡樹(nginx紅黑樹)。當然,場景特殊時,儘可以用有序數組、跳躍表等等實現。


綜上所述,反應堆模型開發效率上比起直接使用IO複用要高,它通常是單線程的,設計目標是希望單線程使用一顆CPU的全部資源,但也有附帶優點,即每個事件處理中很多時候可以不考慮共享資源的互斥訪問。可是缺點也是明顯的,現在的硬件發展,已經不再遵循摩爾定律,CPU的頻率受制於材料的限制不再有大的提升,而改爲是從核數的增加上提升能力,當程序需要使用多核資源時,反應堆模型就會悲劇,爲何呢?
如果程序業務很簡單,例如只是簡單的訪問一些提供了併發訪問的服務,就可以直接開啓多個反應堆,每個反應堆對應一顆CPU核心,這些反應堆上跑的請求互不相關,這是完全可以利用多核的。例如Nginx這樣的http靜態服務器。
如果程序比較複雜,例如一塊內存數據的處理希望由多核共同完成,這樣反應堆模型就很難做到了,需要昂貴的代價,引入許多複雜的機制。所以,大家就可以理解像redis、nodejs這樣的服務,爲什麼只能是單線程,爲什麼memcached簡單些的服務確可以是多線程。

  

高性能網絡編程7--tcp連接的內存使用

標籤: tcpmemorykernel緩存linux
2014-01-23 17:47  15540人閱讀  評論(19)  收藏  舉報
  分類:
 
 
當服務器的併發TCP連接數以十萬計時,我們就會對一個TCP連接在操作系統內核上消耗的內存多少感興趣。socket編程方法提供了SO_SNDBUF、SO_RCVBUF這樣的接口來設置連接的讀寫緩存,linux上還提供了以下系統級的配置來整體設置服務器上的TCP內存使用,但這些配置看名字卻有些互相沖突、概念模糊的感覺,如下(sysctl -a命令可以查看這些配置):
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  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內存相關的配置:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  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抓三次握手的報文:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  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即初始接收窗口 ):
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. int init_cwnd = 4;  
  2. if (mss > 1460*3)  
  3.  init_cwnd = 2;  
  4. else if (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)才能傳輸完數據,體驗不好。

那麼這時大家可能有疑問,當窗口從初始窗口一路擴張到最大接收窗口時,最大接收窗口就是最大讀緩存嗎?
不是,因爲必須分一部分緩存用於應用程序的延時報文讀取。到底會分多少出來呢?這是可配的系統選項,如下:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  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內核不再對這樣的連接執行自動調整功能!

那麼,這個功能到底是怎樣起作用的呢?看以下配置:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  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]。

作者所屬大學Baylor的網站,在相關的頁面上提供了下載。

http://cs.ecs.baylor.edu/~donahoo/practical/CSockets2/textcode.html

後來發現這篇日誌訪問挺高的,乾脆自己打個包放上來了。

http://files.cnblogs.com/wuyuegb2312/Sockets%E7%BC%96%E7%A8%8B%E6%BA%90%E7%A0%81.rar




初學Sockets編程(一) 基本的TCP套接字

  前段時間剛開始學習《TCP/IP Sockets編程(C語言實現) (第2版)》一書,又被告知建議先去看一下《UNIX網絡編程(第一卷)》的部分內容會對理解其理論知識有所幫助,於是稍微停滯了一下。幾天前所練習的實例已經有所生疏,因此爲了複習這部分內容(前面所提起的第一本書第2章),此文便作爲複習筆記(書本關鍵內容摘錄),而以後各章節的學習也希望能做到及時總結和複習。

  本章示例的工作方式:客戶連接服務器併發送它的數據;服務器簡單地把它接收到的任何內容發送回客戶並斷開連接。


1.典型的TCP客戶的通信步驟

⑴使用socket()創建TCP套接字

  參數涉及地址族、使用的協議,正確創建返回一個句柄。

⑵使用connect()建立到達服務器的連接

  參數涉及套接字句柄、服務器的地址結構中的地址和端口標識,其中地址結構需要強制轉換爲泛型類型。

⑶使用send()和recv()通信

  send()參數涉及套接字句柄、發送內容、發送長度;返回值爲發送的字節數,錯誤返回爲-1。

  recv()參數涉及套接字句柄、接收緩衝區、緩衝區大小、調用方式;返回值爲接受的字節數,返回0表示另一端應用程序關閉了TCP連接,返回-1表示失敗。

⑷使用close()關閉連接


示例爲之前提供的下載頁面的TCPEchoClient4.c。


2.基本的TCP服務器通信的常規步驟

⑴使用socket()創建TCP套接字

⑵利用bind()給套接字分配端口號

參數涉及套接字句柄、服務器的本機地址結構中的地址和端口標識,其中地址結構需要強制轉換爲泛型類型,如果不是非常關心所在地址可以用inaddr_any。

⑶使用listen()告訴系統允許對該端口建立連接

  參數涉及套接字句柄、最大允許連接數。

⑷反覆執行以下操作

  • 調用accept()爲每個客戶連接獲取新的套接字

  參數涉及之前的套接字句柄,創建成功後即將填寫的本機地址結構,該地址結構的長度,其中地址結構需要強制轉換爲泛型類型,返回一個新套接字的句柄。

  • 使用send()和recv()通過新的套接字與客戶通信
  • 使用close()關閉客戶連接


示例爲之前提供的下載頁面的TCPEchoServer4.c以及TCPServerUtility.c中的HandleTCPClient()。


3.指定地址

 

⑴通用地址

struct sockaddr {   sa_family_t sa_family; //Address family (e.g.,AF_INET)   char sa_data[14]; };  

⑵IPv4地址

 

複製代碼
struct in_addr {
  uint32_t s_addr;
};

  struct sockaddr_in {
  sa_family_t sin_family;  //Internet protocol  (AF_INET)
  in_port_t sin_port;    //Address port   (16bits)
  struct in_addr sin_addr; //IPv4 address    (32bits)
  char sin_zero[8];    //Not used
};
複製代碼

  ⑶IPv6地址(已按照上一篇文章更改)

複製代碼
struct in_addr {
  uint8_t s_addr[16];//Internet address(128bits)
};
struct sockaddr_in6 {
  sa_family_t sin6_family;  //Internet protocol(AF_INET6)
  in_port_t sin6_port;    //Address port(16bits)
  uint32_t sin6_flowinfo;  //Flow information
  struct in6_addr sin6_addr;//IPv6 address (128bits)
  uint32_t sin6_scope_id;  //Scope identifier
};
複製代碼

  ⑷地址轉換

int inet_pton(int addressFamily, constchar*src , void*dst)
//把地址從可打印的字符串(*src)轉換爲數字(dst引用的地址)

constchar*inet_ntop(int addressFamily, constvoid*src, char*dst,socklen_t dstBytes)
//把地址從數字轉化爲可打印的形式

  ⑸獲取套接字的關聯地址

int getpeername(int socket, struct sockaddr *remoteAddress, socklen_t (addressLength)
int getsockname(int socket, struct sockaddr (localAddress, socklen_t (addressLength)

  4.其他

  爲了便於在同一臺計算機的終端上進行調試,需要先在後臺執行服務器程序(參數等所有內容的最後面加一個&),再執行客戶機程序,發送到127.0.0.1即可。相關調試時用到的進程操作還有ps(查看當前運行進程)、kill(關閉進程)。

初學Sockets編程(二) 關於名稱和地址族

  這一章的核心內容是getaddrinfo()函數。

 

int getaddrinfo(constchar*hostStr, constchar* serviceStr, conststruct addrinfo *hints, 
struct addrinfo **results)
//需要配合下面函數進行使用
void freeaddrinfo(struct addrinfo *addrList) //釋放創建的結果鏈表
const char *gai_strerror(int errorCode) //如果getaddrinfo返回非0值,可以描述出錯的是什麼

 

  含義:

  hostStr    主機名稱或地址,以NULL結尾的字符串

  serviceStr    服務名稱或端口號,以NULL結尾的字符串

  hints     要返回信息的種類,可以實現選擇

  results   存儲一個指向包含結果的鏈表的指針

 對於addrinfo結構,如下所示:

複製代碼
struct addrinfo {
  int ai_flags;//Flags to control info resolution
  int ai_family;//Family:AF_INET,AF_INET6,AF_UNSPEC
  int ai_socktype;//Socket type:SOCK_STREAM,SOCK_DGRAM
  int ai_protocol;//Protocol: 0(default) or IPPROTO_XXX
  socklen_t ai_addrlen;//Length of socket address ai_addr
  struct sockaddr *ai_addr;//Socket address for socket
  char*ai_canonname;//Canonical name
  struct addrinfo *ai_next;//Next addrinfo in linked list
};
複製代碼

 

  利用getaddrinfo()函數編寫出的SetupTCPClientSocket()和SetupTCPServerSocket()可以很方便地隱藏IPv4和IPv6地址的差異,根據它重寫的TCPEchoClient.c和TCPEchoServer.c就可以同時處理兩種類型的地址了。


  本章最後還提到了從Internet地址獲取主機名稱的getnameinfo()和獲取自己主機名稱的gethostname(),不再詳述。

初學Sockets編程(三) UDP套接字

  UDP的過程通信看上去比TCP簡單一些,但也有許多細節需要注意。比如,UDP套接字使用前不必連接,TCP類似於電話通信,UDP類似於郵件通信,UDP套接字就像一個郵箱,可以把許多不同來源的信件或包裹放入其中。因此,在示例UDPEchoClient.c中,是需要用SockAddrsEqual()來檢測回送的數據包是否是之前所送往的服務器回送的,儘管在示例中不太可能出現這種情況。

  程序使用的地址結構、套接字的創建還是與TCP相差無幾。由於沒有建立連接的步驟,不需要調用listen(),一旦套接字具有地址就準備好接受消息。同時UDP也不需要使用accept()爲每個客戶獲取一個新的套接字,而是利用綁定到想要端口號的相同套接字立即調用recvfrom()。這樣,在接收數據報的同時需要獲知起來源。以下是發送和接收用到的函數。

ssize_t sendto(int socket, constvoid*msg, size_t msgLength, int flags, conststruct sockaddr *destAddr, socklen_t addrLen) //前4個參數與send()相同,另外兩個指向消息的目的地 ssize_t recvfrom(int socket, void*msg, size_t msgLength, int flags, struct sockaddr *srcAddr,socklen_t *addrLen) //前4個參數與recv()相同,另外兩個告知調用者所接受的數據報的來源 //addrLen是一個輸入/輸出型的參數,需要傳遞一個指針

  TCP 調用send()時,數據已經複製進緩衝區中以進行傳輸,可能不會實際的傳輸;UDP不會重傳,這意味着當其調用sendto()時,就已經把消息傳遞給底層,並且已經(或者很快將要)發送出去。

  UDP對不同消息的字節保留邊界,recvfrom()不會返回多個數據塊。當參數設定小於第一個數據塊大小時,剩餘字節將會被丟棄而無指示。因此緩衝區應該大於協議允許的最大消息,其最大負載是65507字節。


   UDP 套接字上調用connect()可以用於固定通過套接字發送的將來數據報的目的地址。一旦連接,可以用send()代替sendto()、recv()代替recvfrom(),但這不改變UDP的行爲方式。


初學Sockets編程(四) 發送和接收數據

    放假歸來,半個月沒看書了,稍微有些生疏。被安排了新的工作,老的自學任務還需繼續完成。

 

  這一章內容比較多,按小節整理了一下。

 

一、編碼整數

1.整數型的大小

  由通信過程雙方交換信息的協議標準引申出了編碼的整數,進而探討了各個整數類型的大小(char、int、long、int8_t、uint8_t等)、獲取它們的長度的方法——sizeof()、並且有一個簡單的程序示例TestSizes.c來展示。

2.傳輸順序

  多個字節編碼的整數,是從最高有效位(大端、左端)還是從最低有效位(小端、右端)發送,也是傳輸雙方需要協調的。大多數協議使用大端順序,因此它也被稱爲網絡字節順序。

3.符號擴展

  利用補碼進行符號擴展;不同長度的數據類型複製時的補位。

 

  這一小節使用了一個例子BruteForceCoding.c來展示如何進行移位和掩碼操作,相當繁瑣。

4.在流中包裝套接字

  使用fdopen、fclose、fflush。

5.結構填充

  優化結構成員的排列順序可以避免一些不必要的填充。或者,安排額外的結構成員使得其成爲可控制的填充部分。

6.字符串和文本
  利用wcstombs()和mbstowcs()進行寬字符和適於傳輸的字符序列之間的轉換。
7.位操作
二、構造、成幀和解析消息

  這一部分是一個示例。爲了便於複習,把各個組件功能註釋一下。與前幾章不同的是,這裏沒有用類似receive()這樣的函數,而是採用流的方式進行處理。

  VoteClientTCP.c 客戶端,用於發送請求。請求有兩種,投票和質詢。

  VoteServerTCP.c 服務器端,接收請求,並根據不同請求,修改或僅查詢服務器端數據,並回送。

  DelimFramer.c 基於界定符成幀,包含了從流複製字節到緩衝區直到遇到界定符的GetNextMsg( )和根據界定符把緩衝區字節複製到流中的PutMsg( )。

  LengthFramer.c 基於長度成幀,包含的兩個函數與DelimFramer.c提供的兩個函數同名,不同的是它們基於長度成幀。此時消息格式有所不同,按照前面的約定,兩個字節的前綴中保存了這個消息的長度。

  VoteEncodingText.c 基於文本進行消息編碼,包含把序列轉化爲消息結構的Encode( )和把消息結構轉化爲字節序列的Decode( )。其中用到的strtok( )第一次分割後,每次分割都要利用NULL作爲第一個參數;strtoll( )的用法如下:

 

longlongint strtoll(constchar*nptr, char**endptr, intbase);
//把nptr按照以base爲進制進行轉換。endptr非空時把第一個無效字符存放至endptr。參考資料

  VoteEncodingBin.c 基於二進制消息編碼,包含的兩個函數與VoteEncodingText.c提供的兩個函數同名,不同的是使用固定大小的消息。

  這樣,把VoteServerTCP.c、兩個成幀模塊之一、兩個編碼模塊之一以及輔助模塊DieWithMessage.c、TCPClientUtility.c、TCPServerUtility.c和AddressUtility.c一起編譯即可獲得服務器程序。客戶端同理,兩者需要使用相同的組合。


p.s.第五章程序尚未測試,由於有其它項目需要進行,暫時擱置TCP/IP Socket編程的學習。

 

高性能網絡編程總結及《TCP/IP Sockets編程(C語言實現) (第2版)》 代碼下載(鏈接以及文件打包)

2016-03-01 17:04  2322人閱讀  評論(1)  收藏  舉報
  分類:

目錄(?)[+]

http://blog.csdn.NET/column/details/high-perf-network.html

http://blog.csdn.net/russell_tao/article/details/9111769

高性能網絡編程(一)----accept建立連接

最近在部門內做了個高性能網絡編程的培訓,近日整理了下PPT,欲寫成一系列文章從應用角度談談它。
編寫服務器時,許多程序員習慣於使用高層次的組件、中間件(例如OO(面向對象)層層封裝過的開源組件),相比於服務器的運行效率而言,他們更關注程序開發的效率,追求更快的完成項目功能點、希望應用代碼完全不關心通訊細節。他們更喜歡在OO世界裏,去實現某個接口、實現這個組件預定義的各種模式、設置組件參數來達到目的。學習複雜的通訊框架、底層細節,在習慣於使用OO語言的程序員眼裏是絕對事倍功半的。以上做法無可厚非,但有一定的侷限性,本文講述的網絡編程頭前冠以「高性能」,它是指程序員設計編寫的服務器需要處理很大的吞吐量,這與簡單網絡應用就有了質的不同。因爲:1、高吞吐量下,容易觸發到一些設計上的邊界條件;2、偶然性的小概率事件,會在高吞吐量下變成必然性事件。3、IO是慢速的,高吞吐量通常意味着高併發,如同一時刻存在數以萬計、十萬計、百萬計的TCP活動連接。所以,做高性能網絡編程不能僅僅滿足於學會開源組件、中間件是如何幫我實現期望功能的,對於企業級產品來說,需要了解更多的知識。

掌握高性能網絡編程,涉及到對網絡、操作系統協議棧、進程與線程、常見的網絡組件等知識點,需要有豐富的項目開發經驗,能夠權衡服務器運行效率與項目開發效率。以下圖來談談我個人對高性能網絡編程的理解。

上面這張圖中,由上至下有以下特點:
•關注點,逐漸由特定業務向通用技術轉移
•使用場景上,由專業領域向通用領域轉移
•靈活性上要求越來越高
•性能要求越來越高
•對細節、原理的掌握,要求越來越高
•對各種異常情況的處理,要求越來越高
•穩定性越來越高,bug率越來越少
在做應用層的網絡編程時,若服務器吞吐量大,則應該適度瞭解以上各層的關注點。

如上圖紅色文字所示,我認爲編寫高性能服務器的關注點有3個:
1、如果基於通用組件編程,關注點多是在組件如何封裝套接字編程細節。爲了使應用程序不感知套接字層,這些組件往往是通過各種回調機制來嚮應用層代碼提供網絡服務,通常,出於爲應用層提供更高的開發效率,組件都大量使用了線程(Nginx等是個例外),當然,使用了線程後往往可以降低代碼複雜度。但多線程引入的併發解決機制還是需要重點關注的,特別是鎖的使用。另外,使用多線程意味着把應用層的代碼複雜度扔給了操作系統,大吞吐量時,需要關注多線程給操作系統內核帶來的性能損耗。
基於通用組件編程,爲了程序的高性能運行,需要清楚的瞭解組件的以下特性:怎麼使用IO多路複用或者異步IO的?怎麼實現併發性的? 怎麼組織線程模型的? 怎麼處理高吞吐量引發的異常情況的?

2、通用組件只是在封裝套接字,操作系統是通過提供套接字來爲進程提供網絡通訊能力的。所以,不瞭解套接字編程,往往對組件的性能就沒有原理上的認識。學習套接字層的編程是有必要的,或許很少會自己從頭去寫,但操作系統的API提供方式經久不變,一經學會,受用終身,同時在項目的架構設計時,選用何種網絡組件就非常準確了。
學習套接字編程,關注點主要在:套接字的編程方法有哪些?阻塞套接字的各方法是如何阻塞住當前代碼段的?非阻塞套接字上的方法如何不阻塞當前代碼段的?IO多路複用機制是怎樣與套接字結合的?異步IO是如何實現的?網絡協議的各種異常情況、操作系統的各種異常情況是怎麼通過套接字傳遞給應用性程序的?

3、網絡的複雜性會影響到服務器的吞吐量,而且,高吞吐量場景下,多種臨界條件會導致應用程序的不正常,特別是組件中有bug或考慮不周或沒有配置正確時。瞭解網絡分組可以定位出這些問題,可以正確的配置系統、組件,可以正確的理解系統的瓶頸。
這裏的關注點主要在:TCP、UDP、IP協議的特點?linux等操作系統如何處理這些協議的?使用tcpdump等抓包工具分析各網絡分組。

一般掌握以上3點,就可以揮灑自如的實現高性能網絡服務器了。

下面具體談談如何做到高性能網絡編程。
衆所周知,IO是計算機上最慢的部分,先不看磁盤IO,針對網絡編程,自然是針對網絡IO。網絡協議對網絡IO影響很大,當下, TCP/IP協議是毫無疑問的主流協議,本文就主要以TCP協議爲例來說明網絡IO。
網絡IO中應用服務器往往聚焦於以下幾個由網絡IO組成的功能中:A)與客戶端建立起TCP連接。B)讀取客戶端的請求流。C)向客戶端發送響應流。D)關閉TCP連接。E)向其他服務器發起TCP連接。
要掌握住這5個功能,不僅僅需要熟悉一些API的使用,更要理解底層網絡如何與上層API之間互相發生影響。同時,還需要對不同的場景下,如何權衡開發效率、進程、線程與這些API的組合使用。下面依次來說說這些網絡IO。


1、 與客戶端建立起TCP連接
談這個功能前,先來看看網絡、協議、應用服務器間的關係

上圖中可知:
爲簡化不同場景下的編程,TCP/IP協議族劃分了應用層、TCP傳輸層、IP網絡層、鏈路層等,每一層只專注於少量功能。
例如,IP層只專注於每一個網絡分組如何到達目的主機,而不管目的主機如何處理。
傳輸層最基本的功能是專注於端到端,也就是一臺主機上的進程發出的包,如何到達目的主機上的某個進程。當然,TCP層爲了可靠性,還額外需要解決3個大問題:丟包(網絡分組在傳輸中存在的丟失)、重複(協議層異常引發的多個相同網絡分組)、延遲(很久後網絡分組纔到達目的地)。
鏈路層則只關心以太網或其他二層網絡內網絡包的傳輸。

回到應用層,往往只需要調用類似於accept的API就可以建立TCP連接。建立連接的流程大家都瞭解--三次握手,它如何與accept交互呢?下面以一個不太精確卻通俗易懂的圖來說明之:

研究過backlog含義的朋友都很容易理解上圖。這兩個隊列是內核實現的,當服務器綁定、監聽了某個端口後,這個端口的SYN隊列和ACCEPT隊列就建立好了。客戶端使用connect向服務器發起TCP連接,當圖中1.1步驟客戶端的SYN包到達了服務器後,內核會把這一信息放到SYN隊列(即未完成握手隊列)中,同時回一個SYN+ACK包給客戶端。一段時間後,在較中2.1步驟中客戶端再次發來了針對服務器SYN包的ACK網絡分組時,內核會把連接從SYN隊列中取出,再把這個連接放到ACCEPT隊列(即已完成握手隊列)中。而服務器在第3步調用accept時,其實就是直接從ACCEPT隊列中取出已經建立成功的連接套接字而已。

現有我們可以來討論應用層組件:爲何有的應用服務器進程中,會單獨使用1個線程,只調用accept方法來建立連接,例如tomcat;有的應用服務器進程中,卻用1個線程做所有的事,包括accept獲取新連接。

原因在於:首先,SYN隊列和ACCEPT隊列都不是無限長度的,它們的長度限制與調用listen監聽某個地址端口時傳遞的backlog參數有關。既然隊列長度是一個值,那麼,隊列會滿嗎?當然會,如果上圖中第1步執行的速度大於第2步執行的速度,SYN隊列就會不斷增大直到隊列滿;如果第2步執行的速度遠大於第3步執行的速度,ACCEPT隊列同樣會達到上限。第1、2步不是應用程序可控的,但第3步卻是應用程序的行爲,假設進程中調用accept獲取新連接的代碼段長期得不到執行,例如獲取不到鎖、IO阻塞等。

那麼,這兩個隊列滿了後,新的請求到達了又將發生什麼?
若SYN隊列滿,則會直接丟棄請求,即新的SYN網絡分組會被丟棄;如果ACCEPT隊列滿,則不會導致放棄連接,也不會把連接從SYN列隊中移出,這會加劇SYN隊列的增長。所以,對應用服務器來說,如果ACCEPT隊列中有已經建立好的TCP連接,卻沒有及時的把它取出來,這樣,一旦導致兩個隊列滿了後,就會使客戶端不能再建立新連接,引發嚴重問題。
所以,如TOMCAT等服務器會使用獨立的線程,只做accept獲取連接這一件事,以防止不能及時的去accept獲取連接。

那麼,爲什麼如Nginx等一些服務器,在一個線程內做accept的同時,還會做其他IO等操作呢?
這裏就帶出阻塞和非阻塞的概念。應用程序可以把listen時設置的套接字設爲非阻塞模式(默認爲阻塞模式),這兩種模式會導致accept方法有不同的行爲。對阻塞套接字,accept行爲如下圖:

這幅圖中可以看到,阻塞套接字上使用accept,第一個階段是等待ACCEPT隊列不爲空的階段,它耗時不定,由客戶端是否向自己發起了TCP請求而定,可能會耗時很長。
對非阻塞套接字,accept會有兩種返回,如下圖:

非阻塞套接字上的accept,不存在等待ACCEPT隊列不爲空的階段,它要麼返回成功並拿到建立好的連接,要麼返回失敗。

所以,企業級的服務器進程中,若某一線程既使用accept獲取新連接,又繼續在這個連接上讀、寫字符流,那麼,這個連接對應的套接字通常要設爲非阻塞。原因如上圖,調用accept時不會長期佔用所屬線程的CPU時間片,使得線程能夠及時的做其他工作。


  

高性能網絡編程2----TCP消息的發送

標籤: tcpkernelnaglesocketlinux
2013-07-18 16:37  17001人閱讀  評論(33)  收藏  舉報
  分類:
 
   
上一篇中,我們已經建立好的TCP連接,對應着操作系統分配的1個套接字。操作TCP協議發送數據時,面對的是數據流。通常調用諸如send或者write方法來發送數據到另一臺主機,那麼,調用這樣的方法時,在操作系統內核中發生了什麼事情呢?我們帶着以下3個問題來細細分析:發送方法成功返回時,能保證TCP另一端的主機接收到嗎?能保證數據已經發送到網絡上了嗎?套接字爲阻塞或者非阻塞時,發送方法做的事情有何不同?

要回答上面3個問題涉及了不少知識點,我們先在TCP層面上看看,發送方法調用時內核做了哪些事。我不想去羅列內核中的數據結構、方法等,畢竟大部分應用程序開發者不需要了解這些,僅以一幅示意圖粗略表示,如下:

圖1 一種典型場景下發送TCP消息的流程
再詳述上圖10個步驟前,先要澄清幾個概念:MTU、MSS、 tcp_write_queue發送隊列、阻塞與非阻塞套接字、擁塞窗口、滑動窗口、 Nagle算法
當我們調用發送方法時,會把我們代碼中構造好的消息流作爲參數傳遞。這個消息流可大可小,例如幾個字節,或者幾兆字節。當消息流較大時,將有可能出現分片。我們先來討論分片問題。

1、MSS與TCP的分片
由上一篇文中可知,TCP層是第4層傳輸層,第3層IP網絡層、第2層數據鏈路層具備的約束條件同樣對TCP層生效。下面來看看數據鏈路層中的一個概念:最大傳輸單元MTU。
無論何種類型的數據鏈路層,都會對網絡分組的長度有一個限制。例如以太網限制爲1500字節,802.3限制爲1492字節。當內核的IP網絡層試圖發送報文時,若一個報文的長度大於MTU限制,就會被分成若干個小於MTU的報文,每個報文都會有獨立的IP頭部。

看看IP頭部的格式:

圖2 IP頭部格式
可以看到,其指定IP包總長度的是一個16位(2字節)的字段,這意味一個IP包最大可以是65535字節。
若TCP層在以太網中試圖發送一個大於1500字節的消息,調用IP網絡層方法發送消息時,IP層會自動的獲取所在局域網的MTU值,並按照所在網絡的MTU大小來分片。IP層同時希望這個分片對於傳輸層來說是透明的,接收方的IP層會根據收到的多個IP包頭部,將發送方IP層分片出的IP包重組爲一個消息。
這種IP層的分片效率是很差的,因爲必須所有分片都到達才能重組成一個包,其中任何一個分片丟失了,都必須重發所有分片。所以,TCP層會試圖避免IP層執行數據報分片。

爲了避免IP層的分片,TCP協議定義了一個新的概念:最大報文段長度MSS。它定義了一個TCP連接上,一個主機期望對端主機發送單個報文的最大長度。TCP3次握手建立連接時,連接雙方都要互相告知自己期望接收到的MSS大小。例如(使用tcpdump抓包):
15:05:08.230782 IP 10.7.80.57.64569 > houyi-vm02.dev.sd.aliyun.com.tproxy: S 3027092051:3027092051(0) win 8192 <mss 1460,nop,wscale 8,nop,nop,sackOK>
15:05:08.234267 IP houyi-vm02.dev.sd.aliyun.com.tproxy > 10.7.80.57.64569: S 26006838:26006838(0) ack 3027092052 win 5840 <mss 1460,nop,nop,sackOK,nop,wscale 9>
15:05:08.233320 IP 10.7.80.57.64543 > houyi-vm02.dev.sd.aliyun.com.tproxy: P 78972532:78972923(391) ack 12915963 win 255
由於例子中兩臺主機都在以太網內,以太網的MTU爲1500,減去IP和TCP頭部的長度,MSS就是1460,三次握手中,SYN包都會攜帶期望的MSS大小。

當應用層調用TCP層提供的發送方法時,內核的TCP模塊在tcp_sendmsg方法裏,會按照對方告知的MSS來分片,把消息流分爲多個網絡分組(如圖1中的3個網絡分組),再調用IP層的方法發送數據。

這個MSS就不會改變了嗎?
會的。上文說過,MSS就是爲了避免IP層分片,在建立握手時告知對方期望接收的MSS值並不一定靠得住。因爲這個值是預估的,TCP連接上的兩臺主機若處於不同的網絡中,那麼,連接上可能有許多中間網絡,這些網絡分別具有不同的數據鏈路層,這樣,TCP連接上有許多個MTU。特別是,若中間途徑的MTU小於兩臺主機所在的網絡MTU時,選定的MSS仍然太大了,會導致中間路由器出現IP層的分片。
怎樣避免中間網絡可能出現的分片呢?
通過IP頭部的DF標誌位,這個標誌位是告訴IP報文所途經的所有IP層代碼:不要對這個報文分片。如果一個IP報文太大必須要分片,則直接返回一個ICMP錯誤,說明必須要分片了,且待分片路由器網絡接受的MTU值。這樣,連接上的發送方主機就可以重新確定MSS。


2、發送方法返回成功後,數據一定發送到了TCP的另一端嗎?
答案當然是否定的。解釋這個問題前,先來看看TCP是如何保證可靠傳輸的。
TCP把自己要發送的數據流裏的每一個字節都看成一個序號,可靠性是要求連接對端在接收到數據後,要發送ACK確認,告訴它已經接收到了多少字節的數據。也就是說,怎樣確保數據一定發送成功了呢?必須等待發送數據對應序號的ACK到達,才能確保數據一定發送成功。 TCP層提供的send或者write這樣的方法是不會做這件事的,看看圖1,它究竟做了哪些事。

圖1中分爲10步。
(1)應用程序試圖調用send方法來發送一段較長的數據。
(2)內核主要通過tcp_sendmsg方法來完成。
(3)(4)內核真正執行報文的發送,與send方法的調用並不是同步的。即,send方法返回成功了,也不一定把IP報文都發送到網絡中了。因此,需要把用戶需要發送的用戶態內存中的數據,拷貝到內核態內存中,不依賴於用戶態內存,也使得進程可以快速釋放發送數據佔用的用戶態內存。但這個拷貝操作並不是簡單的複製,而是把待發送數據,按照MSS來劃分成多個儘量達到MSS大小的分片報文段,複製到內核中的sk_buff結構來存放,同時把這些分片組成隊列,放到這個TCP連接對應的 tcp_write_queue發送隊列中
(5)內核中爲這個TCP連接分配的內核緩存是有限的( /proc/sys/net/core/wmem_default )。當沒有多餘的內核態緩存來複制用戶態的待發送數據時,就需要調用一個方法 sk_stream_wait_memory來等待滑動窗口移動,釋放出一些緩存出來(收到ACK後,不需要再緩存原來已經發送出的報文,因爲既然已經確認對方收到,就不需要定時重發,自然就釋放緩存了)。例如:
[cpp]  view plain  copy
  1. wait_for_memory:  
  2.             if (copied)  
  3.                 tcp_push(sk, tp, flags & ~MSG_MORE, mss_now, TCP_NAGLE_PUSH);  
  4.   
  5.             if ((err = sk_stream_wait_memory(sk, &timeo)) != 0)  
  6.                 goto do_error;  

這裏的 sk_stream_wait_memory方法接受一個參數timeo,就是等待超時的時間。這個時間是tcp_sendmsg方法剛開始就拿到的,如下:
[cpp]  view plain  copy
  1. timeo = sock_sndtimeo(sk, flags & MSG_DONTWAIT);  

看看其實現:
[cpp]  view plain  copy
  1. static inline long sock_sndtimeo(const struct sock *sk, int noblock)  
  2. {  
  3.     return noblock ? 0 : sk->sk_sndtimeo;  
  4. }  

也就是說,當這個套接字是阻塞套接字時,timeo就是SO_SNDTIMEO選項指定的發送超時時間。如果這個套接字是非阻塞套接字, timeo變量就會是0。
實際上,sk_stream_wait_memory對於非阻塞套接字會直接返回,並將 errno錯誤碼置爲EAGAIN。
(6)在圖1的例子中,我們假定使用了阻塞套接字,且等待了足夠久的時間,收到了對方的ACK,滑動窗口釋放出了緩存。
(7)將剩下的用戶態數據都組成MSS報文拷貝到內核態的sk_buff中。
(8)最後,調用tcp_push等方法,它最終會調用IP層的方法來發送tcp_write_queue隊列中的報文。
注意,IP層返回時,並不一定是把報文發送了出去。
(9)(10)發送方法返回。

從圖1的10個步驟中可知,無論是使用阻塞還是非阻塞套接字,發送方法成功返回時(無論全部成功或者部分成功),既不代表TCP連接的另一端主機接收到了消息,也不代表本機把消息發送到了網絡上,只是說明,內核將會試圖保證把消息送達對方。


3、 Nagle算法、 滑動窗口、擁塞窗口對發送方法的影響
圖1第8步tcp_push方法做了些什麼呢?先來看看主要的流程:

圖3 發送TCP消息的簡易流程

下面簡單看看這幾個概念:
(1)滑動窗口
滑動窗口大家都比較熟悉,就不詳細介紹了。TCP連接上的雙方都會通知對方自己的接收窗口大小。而對方的接收窗口大小就是自己的發送窗口大小。tcp_push在發送數據時當然需要與發送窗口打交道。發送窗口是一個時刻變化的值,隨着ACK的到達會變大,隨着發出新的數據包會變小。當然,最大也只能到三次握手時對方通告的窗口大小。tcp_push在發送數據時,最終會使用tcp_snd_wnd_test方法來判斷當前待發送的數據,其序號是否超出了發送滑動窗口的大小,例如:
[cpp]  view plain  copy
  1. //檢查這一次要發送的報文最大序號是否超出了發送滑動窗口大小  
  2. static inline int tcp_snd_wnd_test(struct tcp_sock *tp, struct sk_buff *skb, unsigned int cur_mss)  
  3. {  
  4.         //end_seq待發送的最大序號  
  5.     u32 end_seq = TCP_SKB_CB(skb)->end_seq;  
  6.   
  7.     if (skb->len > cur_mss)  
  8.         end_seq = TCP_SKB_CB(skb)->seq + cur_mss;  
  9.   
  10.         //snd_una是已經發送過的數據中,最小的沒被確認的序號;而snd_wnd就是發送窗口的大小  
  11.     return !after(end_seq, tp->snd_una + tp->snd_wnd);  
  12. }  


(2)慢啓動和擁塞窗口
由於兩臺主機間的網絡可能很複雜,通過廣域網時,中間的路由器轉發能力可能是瓶頸。也就是說,如果一方簡單的按照另一方主機三次握手時通告的滑動窗口大小來發送數據的話,可能會使得網絡上的轉發路由器性能雪上加霜,最終丟失更多的分組。這時,各個操作系統內核都會對TCP的發送階段加入慢啓動和擁塞避免算法。慢啓動算法說白了,就是對方通告的窗口大小隻表示對方接收TCP分組的能力,不表示中間網絡能夠處理分組的能力。所以,發送方請悠着點發,確保網絡非常通暢了後,再按照對方通告窗口來敞開了發。
擁塞窗口就是下面的cwnd,它用來幫助慢啓動的實現。連接剛建立時,擁塞窗口的大小遠小於發送窗口,它實際上是一個MSS。每收到一個ACK,擁塞窗口擴大一個MSS大小,當然,擁塞窗口最大隻能到對方通告的接收窗口大小。當然,爲了避免指數式增長,擁塞窗口大小的增長會更慢一些,是線性的平滑的增長過程。
所以,在tcp_push發送消息時,還會檢查擁塞窗口,飛行中的報文數要小於擁塞窗口個數,而發送數據的長度也要小於擁塞窗口的長度。
如下所示,首先用 unsigned int tcp_cwnd_test方法檢查飛行的報文數是否小於擁塞窗口個數(多少個MSS的個數)
[cpp]  view plain  copy
  1. static inline unsigned int tcp_cwnd_test(struct tcp_sock *tp, struct sk_buff *skb)  
  2. {  
  3.     u32 in_flight, cwnd;  
  4.   
  5.     /* Don't be strict about the congestion window for the final FIN.  */  
  6.     if (TCP_SKB_CB(skb)->flags & TCPCB_FLAG_FIN)  
  7.         return 1;  
  8.   
  9.         //飛行中的數據,也就是沒有ACK的字節總數  
  10.     in_flight = tcp_packets_in_flight(tp);  
  11.     cwnd = tp->snd_cwnd;  
  12.         //如果擁塞窗口允許,需要返回依據擁塞窗口的大小,還能發送多少字節的數據  
  13.     if (in_flight < cwnd)  
  14.         return (cwnd - in_flight);  
  15.   
  16.     return 0;  
  17. }  

再通過tcp_window_allows方法獲取擁塞窗口與滑動窗口的最小長度,檢查待發送的數據是否超出:
[cpp]  view plain  copy
  1. static unsigned int tcp_window_allows(struct tcp_sock *tp, struct sk_buff *skb, unsigned int mss_now, unsigned int cwnd)  
  2. {  
  3.     u32 window, cwnd_len;  
  4.   
  5.     window = (tp->snd_una + tp->snd_wnd - TCP_SKB_CB(skb)->seq);  
  6.     cwnd_len = mss_now * cwnd;  
  7.     return min(window, cwnd_len);  
  8. }  


(3)是否符合NAGLE算法?
Nagle算法的初衷是這樣的:應用進程調用發送方法時,可能每次只發送小塊數據,造成這臺機器發送了許多小的TCP報文。對於整個網絡的執行效率來說,小的TCP報文會增加網絡擁塞的可能,因此,如果有可能,應該將相臨的TCP報文合併成一個較大的TCP報文(當然還是小於MSS的)發送。
Nagle算法要求一個TCP連接上最多隻能有一個發送出去還沒被確認的小分組,在該分組的確認到達之前不能發送其他的小分組。
內核中是通過  tcp_nagle_test 方法實現該算法的。我們簡單的看下:
[cpp]  view plain  copy
  1.   
[cpp]  view plain  copy
  1. static inline int tcp_nagle_test(struct tcp_sock *tp, struct sk_buff *skb,  
  2.                  unsigned int cur_mss, int nonagle)  
  3. {  
  4.     //nonagle標誌位設置了,返回1表示允許這個分組發送出去  
  5.     if (nonagle & TCP_NAGLE_PUSH)  
  6.         return 1;  
  7.   
  8.     //如果這個分組包含了四次握手關閉連接的FIN包,也可以發送出去  
  9.     if (tp->urg_mode ||  
  10.         (TCP_SKB_CB(skb)->flags & TCPCB_FLAG_FIN))  
  11.         return 1;  
  12.   
  13.         //檢查Nagle算法  
  14.     if (!tcp_nagle_check(tp, skb, cur_mss, nonagle))  
  15.         return 1;  
  16.   
  17.     return 0;  
  18. }  

再來看看 tcp_nagle_check方法,它與上一個方法不同,返回0表示可以發送,返回非0則不可以,正好相反。
[cpp]  view plain  copy
  1. static inline int tcp_nagle_check(const struct tcp_sock *tp,  
  2.                   const struct sk_buff *skb,   
  3.                   unsigned mss_now, int nonagle)  
  4. {  
  5.         //先檢查是否爲小分組,即報文長度是否小於MSS  
  6.     return (skb->len < mss_now &&  
  7.         ((nonagle&TCP_NAGLE_CORK) ||  
  8.         //如果開啓了Nagle算法  
  9.          (!nonagle &&  
  10.         //若已經有小分組發出(packets_out表示「飛行」中的分組)還沒有確認  
  11.           tp->packets_out &&  
  12.           tcp_minshall_check(tp))));  
  13. }  

最後看看tcp_minshall_check做了些什麼:
[cpp]  view plain  copy
  1. static inline int tcp_minshall_check(const struct tcp_sock *tp)  
  2. {  
  3.         //最後一次發送的小分組還沒有被確認  
  4.     return after(tp->snd_sml,tp->snd_una) &&  
  5.                 //將要發送的序號是要大於等於上次發送分組對應的序號  
  6.         !after(tp->snd_sml, tp->snd_nxt);  
  7. }  

想象一種場景,當對請求的時延非常在意且網絡環境非常好的時候(例如同一個機房內),Nagle算法可以關閉,這實在也沒必要。使用TCP_NODELAY套接字選項就可以關閉Nagle算法。看看setsockopt是怎麼與上述方法配合工作的:
[cpp]  view plain  copy
  1. static int do_tcp_setsockopt(struct sock *sk, int level,  
  2.         int optname, char __user *optval, int optlen)  
  3.         ...  
  4.     switch (optname) {  
  5.         ...  
  6.     case TCP_NODELAY:  
  7.         if (val) {  
  8.                         //如果設置了TCP_NODELAY,則更新nonagle標誌  
  9.             tp->nonagle |= TCP_NAGLE_OFF|TCP_NAGLE_PUSH;  
  10.             tcp_push_pending_frames(sk, tp);  
  11.         } else {  
  12.             tp->nonagle &= ~TCP_NAGLE_OFF;  
  13.         }  
  14.         break;  
  15.         }  
  16. }  

可以看到,nonagle標誌位就是這麼更改的。


當然,調用了IP層的方法返回後,也未必就保證此時數據一定發送到網絡中去了。
下一篇我們探討如何接收TCP消息,以及接收到ack後內核做了些什麼。

  

高性能網絡編程3----TCP消息的接收

標籤: linuxtcp高性能服務器網絡編程
2013-08-26 18:55  17975人閱讀  評論(29)  收藏  舉報
  分類:
 
   
這篇文章將試圖說明應用程序如何接收網絡上發送過來的TCP消息流,由於篇幅所限,暫時忽略ACK報文的回覆和接收窗口的滑動。
爲了快速掌握本文所要表達的思想,我們可以帶着以下問題閱讀:
1、應用程序調用read、recv等方法時,socket套接字可以設置爲阻塞或者非阻塞,這兩種方式是如何工作的?
2、若socket爲默認的阻塞套接字,此時recv方法傳入的len參數,是表示必須超時(SO_RCVTIMEO)或者接收到len長度的消息,recv方法纔會返回嗎?而且,socket上可以設置一個屬性叫做SO_RCVLOWAT,它會與len產生什麼樣的交集,又是決定recv等接收方法什麼時候返回?
3、應用程序開始收取TCP消息,與程序所在的機器網卡上接收到網絡裏發來的TCP消息,這是兩個獨立的流程。它們之間是如何互相影響的?例如,應用程序正在收取消息時,內核通過網卡又在這條TCP連接上收到消息時,究竟是如何處理的?若應用程序沒有調用read或者recv時,內核收到TCP連接上的消息後又是怎樣處理的?
4、recv這樣的接收方法還可以傳入各種flags,例如MSG_WAITALL、MSG_PEEK、MSG_TRUNK等等。它們是如何工作的?
5、1個socket套接字可能被多個進程在使用,出現併發訪問時,內核是怎麼處理這種狀況的?
6、linux的sysctl系統參數中,有類似tcp_low_latency這樣的開關,默認爲0或者配置爲1時是如何影響TCP消息處理流程的?


書接上文。本文將通過三幅圖講述三種典型的接收TCP消息場景,理清內核爲實現TCP消息的接收所實現的4個隊列容器。當然,瞭解內核的實現並不是目的,而是如何使用socket接口、如何配置操作系統內核參數,才能使TCP傳輸消息更高效,這纔是最終目的。

很多同學不希望被內核代碼擾亂了思維,如何閱讀本文呢?
我會在圖1的步驟都介紹完了纔來從代碼上說明tcp_v4_rcv等主要方法。像flags參數、非阻塞套接字會產生怎樣的效果我是在代碼介紹中說的。然後我會介紹圖2、圖3,介紹它們的步驟時我會穿插一些上文沒有涉及的少量代碼。不喜歡瞭解內核代碼的同學請直接看完圖1的步驟後,請跳到圖2、圖3中,我認爲這3幅圖覆蓋了主要的TCP接收場景,能夠幫助你理清其流程。

接收消息時調用的系統方法要比上一篇發送TCP消息複雜許多。接收TCP消息的過程可以一分爲二:首先是PC上的網卡接收到網線傳來的報文,通過軟中斷內核拿到並且解析其爲TCP報文,然後TCP模塊決定如何處理這個TCP報文。其次,用戶進程調用read、recv等方法獲取TCP消息,則是將內核已經從網卡上收到的消息流拷貝到用戶進程裏的內存中。

第一幅圖描述的場景是,TCP連接上將要收到的消息序號是S1(TCP上的每個報文都有序號,詳見《TCP/IP協議詳解》),此時操作系統內核依次收到了序號S1-S2的報文、S3-S4、S2-S3的報文,注意後兩個包亂序了。之後,用戶進程分配了一段len大小的內存用於接收TCP消息,此時,len是大於S4-S1的。另外,用戶進程始終沒有對這個socket設置過SO_RCVLOWAT參數,因此,接收閥值SO_RCVLOWAT使用默認值1。另外,系統參數tcp_low_latency設置爲0,即從操作系統的總體效率出發,使用prequeue隊列提升吞吐量。當然,由於用戶進程收消息時,並沒有新包來臨,所以此圖中prequeue隊列始終爲空。先不細表。
圖1如下:

上圖中有13個步驟,應用進程使用了阻塞套接字,調用recv等方法時flag標誌位爲0,用戶進程讀取套接字時沒有發生進程睡眠。內核在處理接收到的TCP報文時使用了4個隊列容器(當鏈表理解也可),分別爲receive、out_of_order、prequeue、backlog隊列,本文會說明它們存在的意義。下面詳細說明這13個步驟。
1、當網卡接收到報文並判斷爲TCP協議後,將會調用到內核的tcp_v4_rcv方法。此時,這個TCP連接上需要接收的下一個報文序號恰好就是S1,而這一步裏,網卡上收到了S1-S2的報文,所以,tcp_v4_rcv方法會把這個報文直接插入到receive隊列中。
注意:receive隊列是允許用戶進程直接讀取的,它是將已經接收到的TCP報文,去除了TCP頭部、排好序放入的、用戶進程可以直接按序讀取的隊列。由於socket不在進程上下文中(也就是沒有進程在讀socket),由於我們需要S1序號的報文,而恰好收到了S1-S2報文,因此,它進入了receive隊列。

2、接着,我們收到了S3-S4報文。在第1步結束後,這時我們需要收到的是S2序號,但到來的報文卻是S3打頭的,怎麼辦呢?進入out_of_order隊列!從這個隊列名稱就可以看出來,所有亂序的報文都會暫時放在這。

3、仍然沒有進入來讀取socket,但又過來了我們期望的S2-S3報文,它會像第1步一樣,直接進入receive隊列。不同的時,由於此時out_of_order隊列不像第1步是空的,所以,引發了接來的第4步。

4、每次向receive隊列插入報文時都會檢查out_of_order隊列。由於收到S2-S3報文後,期待的序號成爲了S3,這樣,out_of_order隊列裏的唯一報文S3-S4報文將會移出本隊列而插入到receive隊列中(這件事由tcp_ofo_queue方法完成)。

5、終於有用戶進程開始讀取socket了。做過應用端編程的同學都知道,先要在進程裏分配一塊內存,接着調用read或者recv等方法,把內存的首地址和內存長度傳入,再把建立好連接的socket也傳入。當然,對這個socket還可以配置其屬性。這裏,假定沒有設置任何屬性,都使用默認值,因此,此時socket是阻塞式,它的SO_RCVLOWAT是默認的1。當然,recv這樣的方法還會接收一個flag參數,它可以設置爲MSG_WAITALL、MSG_PEEK、MSG_TRUNK等等,這裏我們假定爲最常用的0。進程調用了recv方法。

6、無論是何種接口,C庫和內核經過層層封裝,接收TCP消息最終一定會走到tcp_recvmsg方法。下面介紹代碼細節時,它會是重點。

7、在tcp_recvmsg方法裏,會首先鎖住socket。爲什麼呢?因此socket是可以被多進程同時使用的,同時,內核中斷也會操作它,而下面的代碼都是核心的、操作數據的、有狀態的代碼,不可以被重入的,鎖住後,再有用戶進程進來時拿不到鎖就要休眠在這了。內核中斷看到被鎖住後也會做不同的處理,參見圖2、圖3。

8、此時,第1-4步已經爲receive隊列裏準備好了3個報文。最上面的報文是S1-S2,將它拷貝到用戶態內存中。由於第5步flag參數並沒有攜帶MSG_PEEK這樣的標誌位,因此,再將S1-S2報文從receive隊列的頭部移除,從內核態釋放掉。反之,MSG_PEEK標誌位會導致receive隊列不會刪除報文。所以,MSG_PEEK主要用於多進程讀取同一套接字的情形。

9、如第8步,拷貝S2-S3報文到用戶態內存中。當然,執行拷貝前都會檢查用戶態內存的剩餘空間是否足以放下當前這個報文,不足以時會直接返回已經拷貝的字節數。
10、同上。

11、receive隊列爲空了,此時會先來檢查SO_RCVLOWAT這個閥值。如果已經拷貝的字節數到現在還小於它,那麼可能導致進程會休眠,等待拷貝更多的數據。第5步已經說明過了,socket套接字使用的默認的SO_RCVLOWAT,也就是1,這表明,只要讀取到報文了,就認爲可以返回了。
做完這個檢查了,再檢查backlog隊列。backlog隊列是進程正在拷貝數據時,網卡收到的報文會進這個隊列。此時若backlog隊列有數據,就順帶處理下。圖3會覆蓋這種場景。

12、在本圖對應的場景中,backlog隊列是沒有數據的,已經拷貝的字節數爲S4-S1,它是大於1的,因此,釋放第7步里加的鎖,準備返回用戶態了。

13、用戶進程代碼開始執行,此時recv等方法返回的就是S4-S1,即從內核拷貝的字節數。


圖1描述的場景是最簡單的1種場景,下面我們來看看上述步驟是怎樣通過內核代碼實現的(以下代碼爲2.6.18內核代碼)。


我們知道,linux對中斷的處理是分爲上半部和下半部的,這是處於系統整體效率的考慮。我們將要介紹的都是在網絡軟中斷的下半部裏,例如這個tcp_v4_rcv方法。圖1中的第1-4步都是在這個方法裏完成的。
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. int tcp_v4_rcv(struct sk_buff *skb)  
  2. {  
  3.         ... ...  
  4.     //是否有進程正在使用這個套接字,將會對處理流程產生影響  
  5.         //或者從代碼層面上,只要在tcp_recvmsg裏,執行lock_sock後只能進入else,而release_sock後會進入if  
  6.     if (!sock_owned_by_user(sk)) {  
  7.         {  
  8.             //當 tcp_prequeue 返回0時,表示這個函數沒有處理該報文  
  9.             if (!tcp_prequeue(sk, skb))//如果報文放在prequeue隊列,即表示延後處理,不佔用軟中斷過長時間  
  10.                 ret = tcp_v4_do_rcv(sk, skb);//不使用prequeue或者沒有用戶進程讀socket時(圖3進入此分支),立刻開始處理這個報文  
  11.         }  
  12.     } else  
  13.         sk_add_backlog(sk, skb);//如果進程正在操作套接字,就把skb指向的TCP報文插入到backlog隊列(圖3涉及此分支)  
  14.         ... ...  
  15. }  

圖1第1步裏,我們從網絡上收到了序號爲S1-S2的包。此時,沒有用戶進程在讀取套接字,因此,sock_owned_by_user(sk)會返回0。所以,tcp_prequeue方法將得到執行。簡單看看它:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. static inline int tcp_prequeue(struct sock *sk, struct sk_buff *skb)  
  2. {  
  3.     struct tcp_sock *tp = tcp_sk(sk);  
  4.   
  5.     //檢查tcp_low_latency,默認其爲0,表示使用prequeue隊列。tp->ucopy.task不爲0,表示有進程啓動了拷貝TCP消息的流程  
  6.     if (!sysctl_tcp_low_latency && tp->ucopy.task) {  
  7.         //到這裏,通常是用戶進程讀數據時沒讀到指定大小的數據,休眠了。直接將報文插入prequeue隊列的末尾,延後處理  
  8.         __skb_queue_tail(&tp->ucopy.prequeue, skb);  
  9.         tp->ucopy.memory += skb->truesize;  
  10.         //當然,雖然通常是延後處理,但如果TCP的接收緩衝區不夠用了,就會立刻處理prequeue隊列裏的所有報文  
  11.         if (tp->ucopy.memory > sk->sk_rcvbuf) {  
  12.             while ((skb1 = __skb_dequeue(&tp->ucopy.prequeue)) != NULL) {  
  13.                                 //sk_backlog_rcv就是下文將要介紹的tcp_v4_do_rcv方法  
  14.                 sk->sk_backlog_rcv(sk, skb1);  
  15.             }  
  16.         } else if (skb_queue_len(&tp->ucopy.prequeue) == 1) {  
  17.                         //prequeue裏有報文了,喚醒正在休眠等待數據的進程,讓進程在它的上下文中處理這個prequeue隊列的報文  
  18.             wake_up_interruptible(sk->sk_sleep);  
  19.         }  
  20.   
  21.         return 1;  
  22.     }  
  23.     //prequeue沒有處理  
  24.     return 0;  
  25. }  

由於tp->ucopy.task此時是NULL,所以我們收到的第1個報文在tcp_prequeue函數裏直接返回了0,因此,將由 tcp_v4_do_rcv方法處理。
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. int tcp_v4_do_rcv(struct sock *sk, struct sk_buff *skb)  
  2. {  
  3.     if (sk->sk_state == TCP_ESTABLISHED) { /* Fast path */  
  4.         //當TCP連接已經建立好時,是由tcp_rcv_established方法處理接收報文的  
  5.         if (tcp_rcv_established(sk, skb, skb->h.th, skb->len))  
  6.             goto reset;  
  7.   
  8.         return 0;  
  9.     }  
  10.         ... ...  
  11. }  

tcp_rcv_established方法在圖1裏,主要調用tcp_data_queue方法將報文放入隊列中,繼續看看它又幹了些什麼事:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. static void tcp_data_queue(struct sock *sk, struct sk_buff *skb)  
  2. {  
  3.     struct tcp_sock *tp = tcp_sk(sk);  
  4.   
  5.     //如果這個報文是待接收的報文(看seq),它有兩個出路:進入receive隊列,正如圖1;直接拷貝到用戶內存中,如圖3  
  6.     if (TCP_SKB_CB(skb)->seq == tp->rcv_nxt) {  
  7.                 //滑動窗口外的包暫不考慮,篇幅有限,下次再細談  
  8.         if (tcp_receive_window(tp) == 0)  
  9.             goto out_of_window;  
  10.   
  11.         //如果有一個進程正在讀取socket,且正準備要拷貝的序號就是當前報文的seq序號  
  12.         if (tp->ucopy.task == current &&  
  13.             tp->copied_seq == tp->rcv_nxt && tp->ucopy.len &&  
  14.             sock_owned_by_user(sk) && !tp->urg_data) {  
  15.             //直接將報文內容拷貝到用戶態內存中,參見圖3  
  16.             if (!skb_copy_datagram_iovec(skb, 0, tp->ucopy.iov, chunk)) {  
  17.                 tp->ucopy.len -= chunk;  
  18.                 tp->copied_seq += chunk;  
  19.             }  
  20.         }  
  21.   
  22.         if (eaten <= 0) {  
  23. queue_and_out:  
  24.                         //如果沒有能夠直接拷貝到用戶內存中,那麼,插入receive隊列吧,正如圖1中的第1、3步  
  25.             __skb_queue_tail(&sk->sk_receive_queue, skb);  
  26.         }  
  27.                 //更新待接收的序號,例如圖1第1步中,更新爲S2  
  28.         tp->rcv_nxt = TCP_SKB_CB(skb)->end_seq;  
  29.   
  30.                 //正如圖1第4步,這時會檢查out_of_order隊列,若它不爲空,需要處理它  
  31.         if (!skb_queue_empty(&tp->out_of_order_queue)) {  
  32.                         //tcp_ofo_queue方法會檢查out_of_order隊列中的所有報文  
  33.             tcp_ofo_queue(sk);  
  34.         }  
  35.     }  
  36.         ... ...  
  37.   
  38.     //這個包是無序的,又在接收滑動窗口內,那麼就如圖1第2步,把報文插入到out_of_order隊列吧  
  39.     if (!skb_peek(&tp->out_of_order_queue)) {  
  40.         __skb_queue_head(&tp->out_of_order_queue,skb);  
  41.     } else {  
  42.                     ... ...  
  43.             __skb_append(skb1, skb, &tp->out_of_order_queue);  
  44.     }  
  45. }  

圖1第4步時,正是通過tcp_ofo_queue方法把之前亂序的S3-S4報文插入receive隊列的。
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. static void tcp_ofo_queue(struct sock *sk)  
  2. {  
  3.     struct tcp_sock *tp = tcp_sk(sk);  
  4.     __u32 dsack_high = tp->rcv_nxt;  
  5.     struct sk_buff *skb;  
  6.         //遍歷out_of_order隊列  
  7.     while ((skb = skb_peek(&tp->out_of_order_queue)) != NULL) {  
  8.         ... ...  
  9.                 //若這個報文可以按seq插入有序的receive隊列中,則將其移出out_of_order隊列  
  10.         __skb_unlink(skb, &tp->out_of_order_queue);  
  11.                 //插入receive隊列  
  12.         __skb_queue_tail(&sk->sk_receive_queue, skb);  
  13.                 //更新socket上待接收的下一個有序seq  
  14.         tp->rcv_nxt = TCP_SKB_CB(skb)->end_seq;  
  15.     }  
  16. }  


下面再介紹圖1第6步提到的tcp_recvmsg方法。
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. //參數裏的len就是read、recv方法裏的內存長度,flags正是方法的flags參數,nonblock則是阻塞、非阻塞標誌位  
  2. int tcp_recvmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg,  
  3.         size_t len, int nonblock, int flags, int *addr_len)  
  4. {  
  5.     //鎖住socket,防止多進程併發訪問TCP連接,告知軟中斷目前socket在進程上下文中  
  6.     lock_sock(sk);  
  7.   
  8.         //初始化errno這個錯誤碼  
  9.     err = -ENOTCONN;  
  10.   
  11.     //如果socket是阻塞套接字,則取出SO_RCVTIMEO作爲讀超時時間;若爲非阻塞,則timeo爲0。下面會看到timeo是如何生效的  
  12.     timeo = sock_rcvtimeo(sk, nonblock);  
  13.   
  14.     //獲取下一個要拷貝的字節序號  
  15.     //注意:seq的定義爲u32 *seq;,它是32位指針。爲何?因爲下面每向用戶態內存拷貝後,會更新seq的值,這時就會直接更改套接字上的copied_seq  
  16.     seq = &tp->copied_seq;  
  17.     //當flags參數有MSG_PEEK標誌位時,意味着這次拷貝的內容,當再次讀取socket時(比如另一個進程)還能再次讀到  
  18.     if (flags & MSG_PEEK) {  
  19.         //所以不會更新copied_seq,當然,下面會看到也不會刪除報文,不會從receive隊列中移除報文  
  20.         peek_seq = tp->copied_seq;  
  21.         seq = &peek_seq;  
  22.     }  
  23.   
  24.     //獲取SO_RCVLOWAT最低接收閥值,當然,target實際上是用戶態內存大小len和SO_RCVLOWAT的最小值  
  25.     //注意:flags參數中若攜帶MSG_WAITALL標誌位,則意味着必須等到讀取到len長度的消息才能返回,此時target只能是len  
  26.     target = sock_rcvlowat(sk, flags & MSG_WAITALL, len);  
  27.   
  28.         //以下開始讀取消息  
  29.     do {  
  30.         //從receive隊列取出1個報文  
  31.         skb = skb_peek(&sk->sk_receive_queue);  
  32.         do {  
  33.             //沒取到退出當前循環  
  34.             if (!skb)  
  35.                 break;  
  36.   
  37.   
  38.             //offset是待拷貝序號在當前這個報文中的偏移量,在圖1、2、3中它都是0,只有因爲用戶內存不足以接收完1個報文時才爲非0  
  39.             offset = *seq - TCP_SKB_CB(skb)->seq;  
  40.             //有些時候,三次握手的SYN包也會攜帶消息內容的,此時seq是多出1的(SYN佔1個序號),所以offset減1  
  41.             if (skb->h.th->syn)  
  42.                 offset--;  
  43.             //若偏移量還有這個報文之內,則認爲它需要處理  
  44.             if (offset < skb->len)  
  45.                 goto found_ok_skb;  
  46.   
  47.             skb = skb->next;  
  48.         } while (skb != (struct sk_buff *)&sk->sk_receive_queue);  
  49.   
  50.         //如果receive隊列爲空,則檢查已經拷貝的字節數,是否達到了SO_RCVLOWAT或者長度len。滿足了,且backlog隊列也爲空,則可以返回用戶態了,正如圖1的第11步  
  51.         if (copied >= target && !sk->sk_backlog.tail)  
  52.             break;  
  53.   
  54.                 //在tcp_recvmsg裏,copied就是已經拷貝的字節數  
  55.         if (copied) {  
  56.             ... ...  
  57.         } else {  
  58.                         //一個字節都沒拷貝到,但如果shutdown關閉了socket,一樣直接返回。當然,本文不涉及關閉連接  
  59.             if (sk->sk_shutdown & RCV_SHUTDOWN)  
  60.                 break;  
  61.   
  62.             //如果使用了非阻塞套接字,此時timeo爲0  
  63.             if (!timeo) {  
  64.                                 //非阻塞套接字讀取不到數據時也會返回,錯誤碼正是EAGAIN  
  65.                 copied = -EAGAIN;  
  66.                 break;  
  67.             }  
  68.                         ... ...  
  69.         }  
  70.   
  71.         //tcp_low_latency默認是關閉的,圖1、圖2都是如此,圖3則例外,即圖3不會走進這個if  
  72.         if (!sysctl_tcp_low_latency && tp->ucopy.task == user_recv) {  
  73.             //prequeue隊列就是爲了提高系統整體效率的,即prequeue隊列有可能不爲空,這是因爲進程休眠等待時可能有新報文到達prequeue隊列  
  74.             if (!skb_queue_empty(&tp->ucopy.prequeue))  
  75.                 goto do_prequeue;  
  76.         }  
  77.   
  78.         //如果已經拷貝了的字節數超過了最低閥值  
  79.         if (copied >= target) {  
  80.             //release_sock這個方法會遍歷、處理backlog隊列中的報文  
  81.             release_sock(sk);  
  82.             lock_sock(sk);  
  83.         } else  
  84.             sk_wait_data(sk, &timeo);//沒有讀取到足夠長度的消息,因此會進程休眠,如果沒有被喚醒,最長睡眠timeo時間  
  85.   
  86.         if (user_recv) {  
  87.             if (tp->rcv_nxt == tp->copied_seq &&  
  88.                 !skb_queue_empty(&tp->ucopy.prequeue)) {  
  89. do_prequeue:  
  90.                                 //接上面代碼段,開始處理prequeue隊列裏的報文  
  91.                 tcp_prequeue_process(sk);  
  92.             }  
  93.         }  
  94.   
  95.         //繼續處理receive隊列的下一個報文  
  96.         continue;  
  97.   
  98.     found_ok_skb:  
  99.         /* Ok so how much can we use? */  
  100.         //receive隊列的這個報文從其可以使用的偏移量offset,到總長度len之間,可以拷貝的長度爲used  
  101.         used = skb->len - offset;  
  102.         //len是用戶態空閒內存,len更小時,當然只能拷貝len長度消息,總不能導致內存溢出吧  
  103.         if (len < used)  
  104.             used = len;  
  105.   
  106.         //MSG_TRUNC標誌位表示不要管len這個用戶態內存有多大,只管拷貝數據吧  
  107.         if (!(flags & MSG_TRUNC)) {  
  108.             {  
  109.                 //向用戶態拷貝數據  
  110.                 err = skb_copy_datagram_iovec(skb, offset,  
  111.                         msg->msg_iov, used);  
  112.             }  
  113.         }  
  114.   
  115.         //因爲是指針,所以同時更新copied_seq--下一個待接收的序號  
  116.         *seq += used;  
  117.         //更新已經拷貝的長度  
  118.         copied += used;  
  119.         //更新用戶態內存的剩餘空閒空間長度  
  120.         len -= used;  
  121.   
  122.                 ... ...  
  123.     } while (len > 0);  
  124.   
  125.     //已經裝載了接收器  
  126.     if (user_recv) {  
  127.         //prequeue隊列不爲空則處理之  
  128.         if (!skb_queue_empty(&tp->ucopy.prequeue)) {  
  129.             tcp_prequeue_process(sk);  
  130.         }  
  131.   
  132.         //準備返回用戶態,socket上不再裝載接收任務  
  133.         tp->ucopy.task = NULL;  
  134.         tp->ucopy.len = 0;  
  135.     }  
  136.   
  137.     //釋放socket時,還會檢查、處理backlog隊列中的報文  
  138.     release_sock(sk);  
  139.     //向用戶返回已經拷貝的字節數  
  140.     return copied;  
  141. }  


圖2給出了第2種場景,這裏涉及到prequeue隊列。用戶進程調用recv方法時,連接上沒有任何接收並緩存到內核的報文,而socket是阻塞的,所以進程睡眠了。然後網卡中收到了TCP連接上的報文,此時prequeue隊列開始產生作用。圖2中tcp_low_latency爲默認的0,套接字socket的SO_RCVLOWAT是默認的1,仍然是阻塞socket,如下圖:

簡單描述上述11個步驟:
1、用戶進程分配了一塊len大小的內存,將其傳入recv這樣的函數,同時socket參數皆爲默認,即阻塞的、SO_RCVLOWAT爲1。調用接收方法,其中flags參數爲0。

2、C庫和內核最終調用到tcp_recvmsg方法來處理。

3、鎖住socket。

4、由於此時receive、prequeue、backlog隊列都是空的,即沒有拷貝1個字節的消息到用戶內存中,而我們的最低要求是拷貝至少SO_RCVLOWAT爲1長度的消息。此時,開始進入阻塞式套接字的等待流程。最長等待時間爲SO_RCVTIMEO指定的時間。
這個等待函數叫做sk_wait_data,有必要看下其實現:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. int sk_wait_data(struct sock *sk, long *timeo)  
  2. {  
  3.         //注意,它的自動喚醒條件有兩個,要麼timeo時間到達,要麼receive隊列不爲空  
  4.     rc = sk_wait_event(sk, timeo, !skb_queue_empty(&sk->sk_receive_queue));  
  5. }  

sk_wait_event也值得我們簡單看下:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. #define sk_wait_event(__sk, __timeo, __condition)       \  
  2. ({  int rc;                         \  
  3.     release_sock(__sk);                 \  
  4.     rc = __condition;                   \  
  5.     if (!rc) {                      \  
  6.         *(__timeo) = schedule_timeout(*(__timeo));  \  
  7.     }                           \  
  8.     lock_sock(__sk);                    \  
  9.     rc = __condition;                   \  
  10.     rc;                         \  
  11. })  

注意,它在睡眠前會調用release_sock,這個方法會釋放socket鎖,使得下面的第5步中,新到的報文不再只能進入backlog隊列。

5、這個套接字上期望接收的序號也是S1,此時網卡恰好收到了S1-S2的報文,在tcp_v4_rcv方法中,通過調用tcp_prequeue方法把報文插入到prequeue隊列中。

6、插入prequeue隊列後,此時會接着調用wake_up_interruptible方法,喚醒在socket上睡眠的進程。參見tcp_prequque方法。

7、用戶進程被喚醒後,重新調用lock_sock接管了這個socket,此後再進來的報文都只能進入backlog隊列了。

8、進程醒來後,先去檢查receive隊列,當然仍然是空的;再去檢查prequeue隊列,發現有一個報文S1-S2,正好是socket連接待拷貝的起始序號S1,於是,從prequeue隊列中取出這個報文並把內容複製到用戶內存中,再釋放內核中的這個報文。

9、目前已經拷貝了S2-S1個字節到用戶態,檢查這個長度是否超過了最低閥值(即len和SO_RCVLOWAT的最小值)。

10、由於SO_RCVLOWAT使用了默認的1,所以準備返回用戶。此時會順帶再看看backlog隊列中有沒有數據,若有,則檢查這個無序的隊列中是否有可以直接拷貝給用戶的報文。當然,此時是沒有的。所以準備返回,釋放socket鎖。

11、返回用戶已經拷貝的字節數。

圖3給出了第3種場景。這個場景中,我們把系統參數tcp_low_latency設爲1,socket上設置了SO_RCVLOWAT屬性的值。服務器先是收到了S1-S2這個報文,但S2-S1的長度是小於SO_RCVLOWAT的,用戶進程調用recv方法讀套接字時,雖然讀到了一些,但沒有達到最小閥值,所以進程睡眠了,與此同時,在睡眠前收到的亂序的S3-S4包直接進入backlog隊列。此時先到達了S2-S3包,由於沒有使用prequeue隊列,而它起始序號正是下一個待拷貝的值,所以直接拷貝到用戶內存中,總共拷貝字節數已滿足SO_RCVLOWAT的要求!最後在返回用戶前把backlog隊列中S3-S4報文也拷貝給用戶了。如下圖:

簡明描述上述15個步驟:
1、內核收到報文S1-S2,S1正是這個socket連接上待接收的序號,因此,直接將它插入有序的receive隊列中。

2、用戶進程所處的linux操作系統上,將sysctl中的tcp_low_latency設置爲1。這意味着,這臺服務器希望TCP進程能夠更及時的接收到TCP消息。用戶調用了recv方法接收socket上的消息,這個socket上設置了SO_RCVLOWAT屬性爲某個值n,這個n是大於S2-S1,也就是第1步收到的報文大小。這裏,仍然是阻塞socket,用戶依然是分配了足夠大的len長度內存以接收TCP消息。

3、通過tcp_recvmsg方法來完成接收工作。先鎖住socket,避免併發進程讀取同一socket的同時,也在告訴內核網絡軟中斷處理到這一socket時要有不同行爲,如第6步。

4、準備處理內核各個接收隊列中的報文。

5、receive隊列中的有序報文可直接拷貝,在檢查到S2-S1是小於len之後,將報文內容拷貝到用戶態內存中。

6、在第5步進行的同時,socket是被鎖住的,這時內核又收到了一個S3-S4報文,因此報文直接進入backlog隊列。注意,這個報文不是有序的,因爲此時連接上期待接收序號爲S2。

7、在第5步,拷貝了S2-S1個字節到用戶內存,它是小於SO_RCVLOWAT的,因此,由於socket是阻塞型套接字(超時時間在本文中忽略),進程將不得不轉入睡眠。轉入睡眠之前,還會幹一件事,就是處理backlog隊列裏的報文,圖2的第4步介紹過休眠方法sk_wait_data,它在睡眠前會執行release_sock方法,看看是如何實現的:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. void fastcall release_sock(struct sock *sk)  
  2. {  
  3.     mutex_release(&sk->sk_lock.dep_map, 1, _RET_IP_);  
  4.   
  5.     spin_lock_bh(&sk->sk_lock.slock);  
  6.         //這裏會遍歷backlog隊列中的每一個報文  
  7.     if (sk->sk_backlog.tail)  
  8.         __release_sock(sk);  
  9.         //這裏是網絡中斷執行時,告訴內核,現在socket並不在進程上下文中  
  10.     sk->sk_lock.owner = NULL;  
  11.     if (waitqueue_active(&sk->sk_lock.wq))  
  12.         wake_up(&sk->sk_lock.wq);  
  13.     spin_unlock_bh(&sk->sk_lock.slock);  
  14. }  

再看看__release_sock方法是如何遍歷backlog隊列的:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. static void __release_sock(struct sock *sk)  
  2. {  
  3.     struct sk_buff *skb = sk->sk_backlog.head;  
  4.   
  5.         //遍歷backlog隊列  
  6.     do {  
  7.         sk->sk_backlog.head = sk->sk_backlog.tail = NULL;  
  8.         bh_unlock_sock(sk);  
  9.   
  10.         do {  
  11.             struct sk_buff *next = skb->next;  
  12.   
  13.             skb->next = NULL;  
  14.                         //處理報文,其實就是tcp_v4_do_rcv方法,上文介紹過,不再贅述  
  15.             sk->sk_backlog_rcv(sk, skb);  
  16.   
  17.             cond_resched_softirq();  
  18.   
  19.             skb = next;  
  20.         } while (skb != NULL);  
  21.   
  22.         bh_lock_sock(sk);  
  23.     } while((skb = sk->sk_backlog.head) != NULL);  
  24. }  

此時遍歷到S3-S4報文,但因爲它是失序的,所以從backlog隊列中移入out_of_order隊列中(參見上文說過的tcp_ofo_queue方法)。

8、進程休眠,直到超時或者receive隊列不爲空。

9、內核接收到了S2-S3報文。注意,這裏由於打開了tcp_low_latency標誌位,這個報文是不會進入prequeue隊列以待進程上下文處理的。

10、此時,由於S2是連接上正要接收的序號,同時,有一個用戶進程正在休眠等待接收數據中,且它要等待的數據起始序號正是S2,於是,這種種條件下,使得這一步同時也是網絡軟中斷執行上下文中,把S2-S3報文直接拷貝進用戶內存。

11、上文介紹tcp_data_queue方法時大家可以看到,每處理完1個有序報文(無論是拷貝到receive隊列還是直接複製到用戶內存)後都會檢查out_of_order隊列,看看是否有報文可以處理。那麼,S3-S4報文恰好是待處理的,於是拷貝進用戶內存。然後喚醒用戶進程。

12、用戶進程被喚醒了,當然喚醒後會先來拿到socket鎖。以下執行又在進程上下文中了。

13、此時會檢查已拷貝的字節數是否大於SO_RCVLOWAT,以及backlog隊列是否爲空。兩者皆滿足,準備返回。

14、釋放socket鎖,退出tcp_recvmsg方法。

15、返回用戶已經複製的字節數S4-S1。


好了,這3個場景讀完,想必大家對於TCP的接收流程是怎樣的已經非常清楚了,本文起始的6個問題也在這一大篇中都涉及到了。下一篇我們來討論TCP連接的關閉。

高性能網絡編程4--TCP連接的關閉

標籤: tcpcloseshutdown四次握手網絡編程
2013-10-26 12:24  9907人閱讀  評論(2)  收藏  舉報
  分類:
 
   
TCP連接的關閉有兩個方法close和shutdown,這篇文章將盡量精簡的說明它們分別做了些什麼。
爲方便閱讀,我們可以帶着以下5個問題來閱讀本文:
1、當socket被多進程或者多線程共享時,關閉連接時有何區別?
2、關連接時,若連接上有來自對端的還未處理的消息,會怎麼處理?
3、關連接時,若連接上有本進程待發送卻未來得及發送出的消息,又會怎麼處理?
4、so_linger這個功能的用處在哪?
5、對於監聽socket執行關閉,和對處於ESTABLISH這種通訊的socket執行關閉,有何區別?

下面分三部分進行:首先說說多線程多進程關閉連接的區別;再用一幅流程圖談談close;最後用一幅流程圖說說shutdown。

先不提其原理和實現,從多進程、多線程下 close和shutdown方法調用時的區別說起。
看看close與shutdown這兩個系統調用對應的內核函數:(參見unistd.h文件)
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. #define __NR_close                               3  
  2. __SYSCALL(__NR_close, sys_close)  
  3. #define __NR_shutdown                           48  
  4. __SYSCALL(__NR_shutdown, sys_shutdown)  

但sys_close和sys_shutdown這兩個系統調用最終是由tcp_close和tcp_shutdown方法來實現的,調用過程如下圖所示:

sys_shutdown與多線程和多進程都沒有任何關係,而sys_close則不然,上圖中可以看到,層層封裝調用中有一個方法叫fput,它有一個引用計數,記錄這個socket被引用了多少次。在說明多線程或者多進程調用close的區別前,先在代碼上簡單看下close是怎麼調用的,對內核代碼沒興趣的同學可以僅看fput方法:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. void fastcall fput(struct file *file)  
  2. {  
  3.     if (atomic_dec_and_test(&file->f_count))//檢查引用計數,直到爲0纔會真正去關閉socket  
  4.         __fput(file);  
  5. }  

當這個socket的引用計數f_count不爲0時,是不會觸發到真正關閉TCP連接的tcp_close方法的。
那麼,這個引用計數的意義何在呢?爲了說明它,先要說道下進程與線程的區別。

大家知道,所謂線程其實就是「輕量級」的進程。創建進程只能是一個進程(父進程)創建另一個進程(子進程),子進程會複製父進程的資源,這裏的」複製「針對不同的資源其意義是不同的,例如對內存、文件、TCP連接等。創建進程是由clone系統調用實現的,而創建線程時同樣也是clone實現的,只不過clone的參數不同,其行爲也很不同。這個話題是很大的,這裏我們僅討論下TCP連接。
在clone系統調用中,會調用方法copy_files來拷貝文件描述符(包括socket)。創建線程時,傳入的flag參數中包含標誌位CLONE_FILES,此時,線程將會共享父進程中的文件描述符。而創建進程時沒有這個標誌位,這時,會把進程打開的所有文件描述符的引用計數加1,即把file數據結構的f_count成員加1,如下:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. static int copy_files(unsigned long clone_flags, struct task_struct * tsk)  
  2. {  
  3.     if (clone_flags & CLONE_FILES) {  
  4.         goto out;//創建線程  
  5.     }  
  6.     newf = dup_fd(oldf, &error);  
  7. out:  
  8.     return error;  
  9. }  

再看看dup_fd方法:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. static struct files_struct *dup_fd(struct files_struct *oldf, int *errorp)  
  2. {  
  3.     for (i = open_files; i != 0; i--) {  
  4.         struct file *f = *old_fds++;  
  5.         if (f) {  
  6.             get_file(f);//創建進程  
  7.         }  
  8.     }  
  9. }  

get_file宏就會加引用計數。
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. #define get_file(x) atomic_inc(&(x)->f_count)  

所以,子進程會將父進程中已經建立的socket加上引用計數。當進程中close一個socket時,只會減少引用計數,僅當引用計數爲0時纔會觸發tcp_close。

到這裏,對於第一個問題的close調用自然有了結論:單線程(進程)中使用close與多線程中是一致的,但這兩者與多進程的行爲並不一致,多進程中共享的同一個socket必須都調用了close纔會真正的關閉連接。

而shutdown則不然,這裏是沒有引用計數什麼事的,只要調用了就會去試圖按需關閉連接。所以,調用shutdown與多線程、多進程無關。


下面我們首先深入探討下close的行爲,因爲close比較shutdown來說要複雜許多。順便回答其餘四個問題。
TCP連接是一種雙工的連接,何謂雙工?即連接雙方可以並行的發送或者接收消息,而無須顧及對方此時到底在發還是收消息。這樣,關閉連接時,就存在3種情形:完全關閉連接;關閉發送消息的功能;關閉接收消息的功能。其中,後兩者就叫做半關閉,由shutdown實現(所以 shutdown多出一個參數正是控制關閉發送或者關閉接收),前者由close實現。

TCP連接是一種可靠的連接,在這裏可以這麼理解:既要確認本機發出的包得到確認,又要確認收到的任何消息都已告知連接的對端。
以下主要從雙工、可靠性這兩點上理解連接的關閉。

TCP雙工的這個特性使得連接的正常關閉需要四次握手,其含義爲:主動端關閉了發送的功能;被動端認可;被動端也關閉了發送的功能;主動端認可。
但還存在程序異常的情形,此時,則通過異常的那端發送RST復位報文通知另一端關閉連接。
下圖是close的主要流程:

這個圖稍複雜,這是因爲它覆蓋了關閉監聽句柄、關閉普通連接、關閉設置了SO_LINGER的連接這三種主要場景。

1)關閉監聽句柄
先從最右邊的分支說說關閉監聽socket的那些事。用於listen的監聽句柄也是使用close關閉,關閉這樣的句柄含義當然很不同,它本身並不對應着某個TCP連接,但是,附着在它之上的卻可能有半成品連接。什麼意思呢?之前說過TCP是雙工的,它的打開需要三次握手,三次握手也就是3個步驟,其含義爲:客戶端打開接收、發送的功能;服務器端認可並也打開接收、發送的功能;客戶端認可。當第1、2步驟完成、第3步步驟未完成時,就會在服務器上有許多半連接,close這個操作主要是清理這些連接。
參照上圖,close首先會移除keepalive定時器。keepalive功能常用於服務器上,防止僵死、異常退出的客戶端佔用服務器連接資源。移除此定時器後,若ESTABLISH狀態的TCP連接在tcp_keepalive_time時間(如服務器上常配置爲2小時)內沒有通訊,服務器就會主動關閉連接。
接下來,關閉每一個半連接。如何關閉半連接?這時當然不能發FIN包,即正常的四次握手關閉連接,而是會發送RST復位標誌去關閉請求。處理完所有半打開的連接close的任務就基本完成了。

2)關閉普通ESTABLISH狀態的連接(未設置so_linger)
首先檢查是否有接收到卻未處理的消息。
如果close調用時存在收到遠端的、沒有處理的消息,這時根據close這一行爲的意義,是要丟棄這些消息的。但丟棄消息後,意味着連接遠端誤以爲發出的消息已經被本機收到處理了(因爲ACK包確認過了),但實際上確是收到未處理,此時也不能使用正常的四次握手關閉,而是會向遠端發送一個RST非正常復位關閉連接。這個做法的依據請參考draft-ietf-tcpimpl-prob-03.txt文檔3.10節, Failure to RST on close with data pending。所以,這也要求我們程序員在關閉連接時,要確保已經接收、處理了連接上的消息。

如果此時沒有未處理的消息,那麼進入發送FIN來關閉連接的階段。
這時,先看看是否有待發送的消息。前一篇已經說過,發消息時要計算滑動窗口、擁塞窗口、angle算法等,這些因素可能導致消息會延遲發送的。如果有待發送的消息,那麼要盡力保證這些消息都發出去的。所以,會在最後一個報文中加入FIN標誌,同時,關閉用於減少網絡中小報文的angle算法,向連接對端發送消息。如果沒有待發送的消息,則構造一個報文,僅含有FIN標誌位,發送出去關閉連接。

3)使用了so_linger的連接
首先要澄清,爲何要有so_linger這個功能?因爲我們可能有強可靠性的需求,也就是說,必須確保發出的消息、FIN都被對方收到。例如,有些響應發出後調用close關閉連接,接下來就會關閉進程。如果close時發出的消息其實丟失在網絡中了,那麼,進程突然退出時連接上發出的RST就可能被對方收到,而且,之前丟失的消息不會有重發來保障可靠性了。
so_linger用來保證對方收到了close時發出的消息,即,至少需要對方通過發送ACK且到達本機。
怎麼保證呢?等待!close會阻塞住進程,直到確認對方收到了消息再返回。然而,網絡環境又得複雜的,如果對方總是不響應怎麼辦?所以還需要l_linger這個超時時間,控制close阻塞進程的最長時間。注意,務必慎用so_linger,它會在不經意間降低你程序中代碼的執行速度(close的阻塞)。

所以,當這個進程設置了so_linger後,前半段依然沒變化。檢查是否有未讀消息,若有則發RST關連接,不會觸發等待。接下來檢查是否有未發送的消息時與第2種情形一致,設好FIN後關閉angle算法發出。接下來,則會設置最大等待時間l_linger,然後開始將進程睡眠,直到確認對方收到後纔會醒來,將控制權交還給用戶進程。

這裏需要注意,so_linger不是確保連接被四次握手關閉再使close返回,而只是保證我方發出的消息都已被對方收到。例如,若對方程序寫的有問題,當它收到FIN進入CLOSE_WAIT狀態,卻一直不調用close發出FIN,此時,對方仍然會通過ACK確認,我方收到了ACK進入FIN_WAIT2狀態,但沒收到對方的FIN,我方的close調用卻不會再阻塞,close直接返回,控制權交還用戶進程。

從上圖可知,so_linger還有個偏門的用法,若l_linger超時時間竟被設爲0,則不會觸發FIN包的發送,而是直接RST復位關閉連接。我個人認爲,這種玩法確沒多大用處。


最後做個總結。調用close時,可能導致發送RST復位關閉連接,例如有未讀消息、打開so_linger但l_linger卻爲0、關閉監聽句柄時半打開的連接。更多時會導致發FIN來四次握手關閉連接,但打開so_linger可能導致close阻塞住等待着對方的ACK表明收到了消息。

最後來看看較爲簡單的shutdown。

解釋下上圖:
1)shutdown可攜帶一個參數,取值有3個,分別意味着:只關閉讀、只關閉寫、同時關閉讀寫。
對於監聽句柄,如果參數爲關閉寫,顯然沒有任何意義。但關閉讀從某方面來說是有意義的,例如不再接受新的連接。看看最右邊藍色分支,針對監聽句柄,若參數爲關閉寫,則不做任何事;若爲關閉讀,則把端口上的半打開連接使用RST關閉,與close如出一轍。
2)若shutdown的是半打開的連接,則發出RST來關閉連接。
3)若shutdown的是正常連接,那麼關閉讀其實與對端是沒有關係的。只要本機把接收掉的消息丟掉,其實就等價於關閉讀了,並不一定非要對端關閉寫的。實際上,shutdown正是這麼幹的。若參數中的標誌位含有關閉讀,只是標識下,當我們調用read等方法時這個標識就起作用了,會使進程讀不到任何數據。
4)若參數中有標誌位爲關閉寫,那麼下面做的事與close是一致的:發出FIN包,告訴對方,本機不會再發消息了。


以上,就是close與shutdown的主要行爲,同時也回答了本文最初的5個問題。下一篇,我們開始討論多路複用中常見的epoll。

高性能網絡編程5--IO複用與併發編程

標籤: epoll網絡編程高性能邊緣觸發ET
2013-12-04 15:57  13784人閱讀  評論(4)  收藏  舉報
  分類:
 
   
對於服務器的併發處理能力,我們需要的是:每一毫秒服務器都能及時處理這一毫秒內收到的數百個不同TCP連接上的報文,與此同時,可能服務器上還有數以十萬計的最近幾秒沒有收發任何報文的相對不活躍連接。同時處理多個並行發生事件的連接,簡稱爲併發;同時處理萬計、十萬計的連接,則是高併發。服務器的併發編程所追求的就是處理的併發連接數目無限大,同時維持着高效率使用CPU等資源,直至物理資源首先耗盡。

併發編程有很多種實現模型,最簡單的就是與「線程」捆綁,1個線程處理1個連接的全部生命週期。優點:這個模型足夠簡單,它可以實現複雜的業務場景,同時,線程個數是可以遠大於CPU個數的。然而,線程個數又不是可以無限增大的,爲什麼呢?因爲線程什麼時候執行是由操作系統內核調度算法決定的,調度算法並不會考慮某個線程可能只是爲了一個連接服務的,它會做大一統的玩法:時間片到了就執行一下,哪怕這個線程一執行就會不得不繼續睡眠。這樣來回的喚醒、睡眠線程在次數不多的情況下,是廉價的,但如果操作系統的線程總數很多時,它就是昂貴的(被放大了),因爲這種技術性的調度損耗會影響到線程上執行的業務代碼的時間。舉個例子,這時大部分擁有不活躍連接的線程就像我們的國企,它們執行效率太低了,它總是喚醒就睡眠在做無用功,而它喚醒爭到CPU資源的同時,就意味着處理活躍連接的民企線程減少獲得了CPU的機會,CPU是核心競爭力,它的無效率進而影響了GDP總吞吐量。我們所追求的是併發處理數十萬連接,當幾千個線程出現時,系統的執行效率就已經無法滿足高併發了。

對高併發編程,目前只有一種模型,也是本質上唯一有效的玩法。
從這個系列的前4篇文章可知,連接上的消息處理,可以分爲兩個階段:等待消息準備好、消息處理。當使用默認的阻塞套接字時(例如上面提到的1個線程捆綁處理1個連接),往往是把這兩個階段合而爲一,這樣操作套接字的代碼所在的線程就得睡眠來等待消息準備好,這導致了高併發下線程會頻繁的睡眠、喚醒,從而影響了CPU的使用效率。

高併發編程方法當然就是把兩個階段分開處理。即,等待消息準備好的代碼段,與處理消息的代碼段是分離的。當然,這也要求套接字必須是非阻塞的,否則,處理消息的代碼段很容易導致條件不滿足時,所在線程又進入了睡眠等待階段。那麼問題來了,等待消息準備好這個階段怎麼實現?它畢竟還是等待,這意味着線程還是要睡眠的!解決辦法就是,線程主動查詢,或者讓1個線程爲所有連接而等待!
這就是IO多路複用了。多路複用就是處理等待消息準備好這件事的,但它可以同時處理多個連接!它也可能「等待」,所以它也會導致線程睡眠,然而這不要緊,因爲它一對多、它可以監控所有連接。這樣,當我們的線程被喚醒執行時,就一定是有一些連接準備好被我們的代碼執行了,這是有效率的!沒有那麼多個線程都在爭搶處理「等待消息準備好」階段,整個世界終於清淨了!

多路複用有很多種實現,在linux上,2.4內核前主要是select和poll,現在主流是epoll,它們的使用方法似乎很不同,但本質是一樣的。
效率卻也不同,這也是epoll完全替代了select的原因。

簡單的談下epoll爲何會替代select。
前面提到過,高併發的核心解決方案是1個線程處理所有連接的「等待消息準備好」,這一點上epoll和select是無爭議的。但select預估錯誤了一件事,就像我們開篇所說,當數十萬併發連接存在時,可能每一毫秒只有數百個活躍的連接,同時其餘數十萬連接在這一毫秒是非活躍的。select的使用方法是這樣的:
返回的活躍連接 ==select(全部待監控的連接)
什麼時候會調用select方法呢?在你認爲需要找出有報文到達的活躍連接時,就應該調用。所以,調用select在高併發時是會被頻繁調用的。這樣,這個頻繁調用的方法就很有必要看看它是否有效率,因爲,它的輕微效率損失都會被「頻繁」二字所放大。它有效率損失嗎?顯而易見,全部待監控連接是數以十萬計的,返回的只是數百個活躍連接,這本身就是無效率的表現。被放大後就會發現,處理併發上萬個連接時,select就完全力不從心了。

看幾個圖。當併發連接爲一千以下,select的執行次數不算頻繁,與epoll似乎並無多少差距:

然而,併發數一旦上去,select的缺點被「執行頻繁」無限放大了,且併發數越多越明顯:

再來說說epoll是如何解決的。它很聰明的用了3個方法來實現select方法要做的事:
新建的epoll描述符==epoll_create()
epoll_ctrl(epoll描述符,添加或者刪除所有待監控的連接)
返回的活躍連接 ==epoll_wait( epoll描述符 )
這麼做的好處主要是:分清了頻繁調用和不頻繁調用的操作。例如,epoll_ctrl是不太頻繁調用的,而epoll_wait是非常頻繁調用的。這時,epoll_wait卻幾乎沒有入參,這比select的效率高出一大截,而且,它也不會隨着併發連接的增加使得入參越發多起來,導致內核執行效率下降。

epoll是怎麼實現的呢?其實很簡單,從這3個方法就可以看出,它比select聰明的避免了每次頻繁調用「哪些連接已經處在消息準備好階段」的 epoll_wait時,是不需要把所有待監控連接傳入的。這意味着,它在內核態維護了一個數據結構保存着所有待監控的連接。這個數據結構就是一棵紅黑樹,它的結點的增加、減少是通過epoll_ctrl來完成的。用我在《深入理解Nginx》第8章中所畫的圖來看,它是非常簡單的:

圖中左下方的紅黑樹由所有待監控的連接構成。左上方的鏈表,同是目前所有活躍的連接。於是,epoll_wait執行時只是檢查左上方的鏈表,並返回左上方鏈表中的連接給用戶。這樣,epoll_wait的執行效率能不高嗎?

最後,再看看epoll提供的2種玩法ET和LT,即翻譯過來的邊緣觸發和水平觸發。其實這兩個中文名字倒也有些貼切。這2種使用方式針對的仍然是效率問題,只不過變成了epoll_wait返回的連接如何能夠更準確些。
例如,我們需要監控一個連接的寫緩衝區是否空閒,滿足「可寫」時我們就可以從用戶態將響應調用write發送給客戶端 。但是,或者連接可寫時,我們的「響應」內容還在磁盤上呢,此時若是磁盤讀取還未完成呢?肯定不能使線程阻塞的,那麼就不發送響應了。但是,下一次epoll_wait時可能又把這個連接返回給你了,你還得檢查下是否要處理。可能,我們的程序有另一個模塊專門處理磁盤IO,它會在磁盤IO完成時再發送響應。那麼,每次epoll_wait都返回這個「可寫」的、卻無法立刻處理的連接,是否符合用戶預期呢?

於是,ET和LT模式就應運而生了。LT是每次滿足期待狀態的連接,都得在epoll_wait中返回,所以它一視同仁,都在一條水平線上。ET則不然,它傾向更精確的返回連接。在上面的例子中,連接第一次變爲可寫後,若是程序未向連接上寫入任何數據,那麼下一次epoll_wait是不會返回這個連接的。ET叫做 邊緣觸發,就是指,只有連接從一個狀態轉到另一個狀態時,纔會觸發epoll_wait返回它。可見,ET的編程要複雜不少,至少應用程序要小心的防止epoll_wait的返回的連接出現:可寫時未寫數據後卻期待下一次「可寫」、可讀時未讀盡數據卻期待下一次「可讀」。

當然,從一般應用場景上它們性能是不會有什麼大的差距的,ET可能的優點是,epoll_wait的調用次數會減少一些,某些場景下連接在不必要喚醒時不會被喚醒(此喚醒指epoll_wait返回)。但如果像我上面舉例所說的,有時它不單純是一個網絡問題,跟應用場景相關。當然,大部分開源框架都是基於ET寫的,框架嘛,它追求的是純技術問題,當然力求盡善盡美。

高性能網絡編程6--reactor反應堆與定時器管理

標籤: 網絡編程反應堆定時器redislibevent
2013-12-20 19:37  10841人閱讀  評論(7)  收藏  舉報
  分類:
 
反應堆開發模型被絕大多數高性能服務器所選擇,上一篇所介紹的IO多路複用是它的實現基礎。定時觸發功能通常是服務器必備組件,反應堆模型往往還不得不將定時器的管理囊括在內。本篇將介紹反應堆模型的特點和用法。

首先我們要談談,網絡編程界爲什麼需要反應堆?有了IO複用,有了epoll,我們已經可以使服務器併發幾十萬連接的同時,維持高TPS了,難道這還不夠嗎?
我的答案是,技術層面足夠了,但在軟件工程層面卻是不夠的。
程序使用IO複用的難點在哪裏呢?1個請求雖然由多次IO處理完成,但相比傳統的單線程完整處理請求生命期的方法,IO複用在人的大腦思維中並不自然,因爲,程序員編程中,處理請求A的時候,假定A請求必須經過多個IO操作A1-An(兩次IO間可能間隔很長時間),每經過一次IO操作,再調用IO複用時,IO複用的調用返回裏,非常可能不再有A,而是返回了請求B。即請求A會經常被請求B打斷,處理請求B時,又被C打斷。這種思維下,編程容易出錯。
形象的說,傳統編程方法就好像是到了銀行營業廳裏,每個窗口前排了長隊,業務員們在窗口後一個個的解決客戶們的請求。一個業務員可以盡情思考着客戶A依次提出的問題,例如:
「我要買2萬XX理財產品。「
「看清楚了,5萬起售。」
「等等,查下我活期餘額。」
「餘額5萬。」
「那就買 5萬吧。」
業務員開始錄入信息。
」對了,XX理財產品年利率8%?」
「是預期8%,最低無利息保本。「
」早不說,拜拜,我去買餘額寶。「
業務員無表情的刪着已經錄入的信息進行事務回滾。
」下一個!「

用了IO複用則是大師業務員開始挑戰極限,在超大營業廳裏給客戶們人手一個牌子,黑壓壓的客戶們都在大廳中,有問題時舉牌申請提問,大師目光敏銳點名指定某人提問,該客戶迅速得到大師的答覆後,要經過一段時間思考,查查自己的銀袋子,諮詢下LD,才能再次進行下一個提問,直到得到完整的滿意答覆退出大廳。例如:大師剛指導A填寫轉帳單的某一項,B又來申請兌換泰銖,給了B兌換單後,C又來辦理定轉活,然後D與F在爭搶有限的圓珠筆時出現了不和諧現象,被大師叫停業務,暫時等待。
這就是基於事件驅動的IO複用編程比起傳統1線程1請求的方式來,有難度的設計點了,客戶們都是上帝,既不能出錯,還不能厚此薄彼。

當沒有反應堆時,我們可能的設計方法是這樣的:大師把每個客戶的提問都記錄下來,當客戶A提問時,首先查閱A之前問過什麼做過什麼,這叫聯繫上下文,然後再根據上下文和當前提問查閱有關的銀行規章制度,有針對性的回答A,並把回答也記錄下來。當圓滿回答了A的所有問題後,刪除A的所有記錄。

回到碼農生涯,即,某一瞬間,服務器共有10萬個併發連接,此時,一次IO複用接口的調用返回了100個活躍的連接等待處理。先根據這100個連接找出其對應的對象,這並不難,epoll的返回連接數據結構裏就有這樣的指針可以用。接着,循環的處理每一個連接,找出這個對象此刻的上下文狀態,再使用read、write這樣的網絡IO獲取此次的操作內容,結合上下文狀態查詢此時應當選擇哪個業務方法處理,調用相應方法完成操作後,若請求結束,則刪除對象及其上下文。

這樣,我們就陷入了面向過程編程方法之中了,在面向應用、快速響應爲王的移動互聯網時代,這樣做早晚得把自己玩死。我們的主程序需要關注各種不同類型的請求,在不同狀態下,對於不同的請求命令選擇不同的業務處理方法。這會導致隨着請求類型的增加,請求狀態的增加,請求命令的增加,主程序複雜度快速膨脹,導致維護越來越困難,苦逼的程序員再也不敢輕易接新需求、重構。

反應堆是解決上述軟件工程問題的一種途徑,它也許並不優雅,開發效率上也不是最高的,但其執行效率與面向過程的使用IO複用卻幾乎是等價的,所以,無論是nginx、memcached、redis等等這些高性能組件的代名詞,都義無反顧的一頭扎進了反應堆的懷抱中。
反應堆模式可以在軟件工程層面,將事件驅動框架分離出具體業務,將不同類型請求之間用OO的思想分離。通常,反應堆不僅使用IO複用處理網絡事件驅動,還會實現定時器來處理時間事件的驅動(請求的超時處理或者定時任務的處理),就像下面的示意圖:

這幅圖有5點意思:
(1)處理應用時基於OO思想,不同的類型的請求處理間是分離的。例如,A類型請求是用戶註冊請求,B類型請求是查詢用戶頭像,那麼當我們把用戶頭像新增多種分辨率圖片時,更改B類型請求的代碼處理邏輯時,完全不涉及A類型請求代碼的修改。

(2)應用處理請求的邏輯,與事件分發框架完全分離。什麼意思呢?即寫應用處理時,不用去管何時調用IO複用,不用去管什麼調用epoll_wait,去處理它返回的多個socket連接。應用代碼中,只關心如何讀取、發送socket上的數據,如何處理業務邏輯。事件分發框架有一個抽象的事件接口,所有的應用必須實現抽象的事件接口,通過這種抽象才把應用與框架進行分離。

(3)反應堆上提供註冊、移除事件方法,供應用代碼使用,而分發事件方法,通常是循環的調用而已,是否提供給應用代碼調用,還是由框架簡單粗暴的直接循環使用,這是框架的自由。

(4)IO多路複用也是一個抽象,它可以是具體的select,也可以是epoll,它們只必須提供採集到某一瞬間所有待監控連接中活躍的連接。

(5)定時器也是由反應堆對象使用,它必須至少提供4個方法,包括添加、刪除定時器事件,這該由應用代碼調用。最近超時時間是需要的,這會被反應堆對象使用,用於確認select或者epoll_wait執行時的阻塞超時時間,防止IO的等待影響了定時事件的處理。遍歷也是由反應堆框架使用,用於處理定時事件。

下面用極簡流程來形象說明下反應堆是如何處理一個請求的,下圖中桔色部分皆爲反應堆的分發事件流程:

可以看到,分發IO、定時器事件都由反應堆框架來完成,應用代碼只會關注於如何處理可讀、可寫事件。
當然,上圖是極度簡化的流程,實際上要處理的異常情況都沒有列入。

這裏可以看到,爲什麼定時器集合需要提供最近超時事件距離現在的時間?因爲,調用epoll_wait或者select時,並不能夠始終傳入-1作爲timeout參數。因爲,我們的服務器主營業務往往是網絡請求處理,如果網絡請求很少時,那麼CPU的所有時間都會被頻繁卻又不必要的epoll_wait調用所佔用。在服務器閒時使進程的CPU利用率降低是很有意義的,它可以使服務器上其他進程得到更多的執行機會,也可以延長服務器的壽命,還可以省電。這樣,就需要傳入準確的timeout最大阻塞時間給epoll_wait了。

什麼樣的timeout時間纔是準確的呢?這等價於,我們需要準確的分析,什麼樣的時段進程可以真正休息,進入sleep狀態?
一個沒有意義的答案是:不需要進程執行任務的時間段內是可以休息的。
這就要求我們仔細想想,進程做了哪幾類任務,例如:
1、所有網絡包的處理,例如TCP連接的建立、讀寫、關閉,基本上所有的正常請求都由網絡包來驅動的。對這類任務而言,沒有新的網絡分組到達本機時,就是可以使進程休息的時段。
2、定時器的管理,它與網絡、IO複用無關,雖然它們在業務上可能有相關性。定時器裏的事件需要及時的觸發執行,不能因爲其他原因,例如阻塞在epoll_wait上時耽誤了定時事件的處理。當一段時間內,可以預判沒有定時事件達到觸發條件時(這也是提供接口查詢最近一個定時事件距當下的時間的意義所在),對定時任務的管理而言,進程就可以休息了。
3、其他類型的任務,例如磁盤IO執行完成,或者收到其他進程的signal信號,等等,這些任務明顯不需要執行的時間段內,進程可以休息。

於是,使用反應堆模型的進程代碼中,通常除了epoll_wait這樣的IO複用外,其他調用都會基於無阻塞的方式使用。所以,epoll_wait的timeout超時時間,就是除網絡外,其他任務所能允許的進程睡眠時間。而只考慮常見的定時器任務時,就像上圖中那樣,只需要定時器集合能夠提供最近超時事件到現在的時間即可。

從這裏也可以推導出,定時器集合通常會採用有序容器這樣的數據結構,好處是:
1、容易取到最近超時事件的時間。
2、可以從最近超時事件開始,向後依次遍歷已經超時的事件,直到第一個沒有超時的事件爲止即可停止遍歷,不用全部遍歷到。

因此,粗暴的採用無序的數據結構,例如普通的鏈表,通常是不足取的。但事無絕對,redis就是用了個毫無順序的鏈表,原因何在?因爲redis的客戶端連接沒有超時概念,所以對於併發的成千上萬個連上,都不會因爲超時被斷開。redis的定時器唯一的用途在於定時的將內存數據刷到磁盤上,這樣的定時事件通常只有個位數,其性能無關緊要。

如果定時事件非常多,綜合插入、遍歷、刪除的使用頻率,使用樹的機會最多,例如小根堆(libevent)、二叉平衡樹(nginx紅黑樹)。當然,場景特殊時,儘可以用有序數組、跳躍表等等實現。


綜上所述,反應堆模型開發效率上比起直接使用IO複用要高,它通常是單線程的,設計目標是希望單線程使用一顆CPU的全部資源,但也有附帶優點,即每個事件處理中很多時候可以不考慮共享資源的互斥訪問。可是缺點也是明顯的,現在的硬件發展,已經不再遵循摩爾定律,CPU的頻率受制於材料的限制不再有大的提升,而改爲是從核數的增加上提升能力,當程序需要使用多核資源時,反應堆模型就會悲劇,爲何呢?
如果程序業務很簡單,例如只是簡單的訪問一些提供了併發訪問的服務,就可以直接開啓多個反應堆,每個反應堆對應一顆CPU核心,這些反應堆上跑的請求互不相關,這是完全可以利用多核的。例如Nginx這樣的http靜態服務器。
如果程序比較複雜,例如一塊內存數據的處理希望由多核共同完成,這樣反應堆模型就很難做到了,需要昂貴的代價,引入許多複雜的機制。所以,大家就可以理解像redis、nodejs這樣的服務,爲什麼只能是單線程,爲什麼memcached簡單些的服務確可以是多線程。

  

高性能網絡編程7--tcp連接的內存使用

標籤: tcpmemorykernel緩存linux
2014-01-23 17:47  15540人閱讀  評論(19)  收藏  舉報
  分類:
 
 
當服務器的併發TCP連接數以十萬計時,我們就會對一個TCP連接在操作系統內核上消耗的內存多少感興趣。socket編程方法提供了SO_SNDBUF、SO_RCVBUF這樣的接口來設置連接的讀寫緩存,linux上還提供了以下系統級的配置來整體設置服務器上的TCP內存使用,但這些配置看名字卻有些互相沖突、概念模糊的感覺,如下(sysctl -a命令可以查看這些配置):
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  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內存相關的配置:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  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抓三次握手的報文:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  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即初始接收窗口 ):
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  1. int init_cwnd = 4;  
  2. if (mss > 1460*3)  
  3.  init_cwnd = 2;  
  4. else if (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)才能傳輸完數據,體驗不好。

那麼這時大家可能有疑問,當窗口從初始窗口一路擴張到最大接收窗口時,最大接收窗口就是最大讀緩存嗎?
不是,因爲必須分一部分緩存用於應用程序的延時報文讀取。到底會分多少出來呢?這是可配的系統選項,如下:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  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內核不再對這樣的連接執行自動調整功能!

那麼,這個功能到底是怎樣起作用的呢?看以下配置:
[cpp]  view plain  copy
 
  在CODE上查看代碼片 派生到我的代碼片
  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]數組表示寫緩存,與
相關文章
相關標籤/搜索