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

   編寫服務器時,許多程序員習慣於使用高層次的組件、中間件(例如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時間片,使得線程能夠及時的做其他工作。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章