前言的前言
服務器模型涉及到線程模式和IO模式,搞清楚這些就能針對各種場景有的放矢。該系列分成三部分:
單線程/多線程阻塞I/O模型
單線程非阻塞I/O模型
多線程非阻塞I/O模型,Reactor及其改進
前言
這里探討的服務器模型主要指的是服務器端對I/O的處理模型。從不同維度可以有不同的分類,這里從I/O的阻塞與非阻塞、I/O處理的單線程與多線程角度探討服務器模型。
對于I/O,可以分成阻塞I/O與非阻塞I/O兩大類型。阻塞I/O在做I/O讀寫操作時會使當前線程進入阻塞狀態,而非阻塞I/O則不進入阻塞狀態。
對于線程,單線程情況下由一條線程負責所有客戶端連接的I/O操作,而多線程情況下則由若干線程共同處理所有客戶端連接的I/O操作。
單線程阻塞I/O模型
單線程阻塞I/O模型是最簡單的一種服務器模型,幾乎所有程序員在剛開始接觸網絡編程時都從這個簡單的模型開始。這種模型只能同時處理一個客戶端訪問,并且在I/O操作上是阻塞的,線程會一直在等待,而不會做其他事情。對于多個客戶端訪問,必須要等到前一個客戶端訪問結束才能進行下一個訪問的處理,請求一個一個排隊,只提供一問一答服務。
首先,服務器必須初始化一個套接字服務器,并綁定某個端口號并使之監聽客戶端的訪問。接著,客戶端1調用服務器的服務,服務器接收到請求后對其進行處理,處理完后寫數據回客戶端1,整個過程都是在一個線程里面完成的。最后,處理客戶端2的請求并寫數據回客戶端2,期間就算客戶端2在服務器處理完客戶端1之前就進行請求,也要等服務器對客戶端1響應完后才會對客戶端2進行響應處理。
這種模型的特點在于單線程和阻塞I/O。單線程即服務器端只有一個線程處理客戶端的所有請求,客戶端連接與服務器端的處理線程比是n:1,它無法同時處理多個連接,只能串行處理連接。而阻塞I/O是指服務器在讀寫數據時是阻塞的,讀取客戶端數據時要等待客戶端發送數據并且把操作系統內核復制到用戶進程中,這時才解除阻塞狀態。寫數據回客戶端時要等待用戶進程將數據寫入內核并發送到客戶端后才解除阻塞狀態。這種阻塞給網絡編程帶來了一個問題,服務器必須要等到客戶端成功接收才能繼續往下處理另外一個客戶端的請求,在此期間線程將無法響應任何客戶端請求。
該模型的特點:它是最簡單的服務器模型,整個運行過程都只有一個線程,只能支持同時處理一個客戶端的請求(如果有多個客戶端訪問,就必須排隊等待),服務器系統資源消耗較小,但并發能力低,容錯能力差。
多線程阻塞I/O模型
針對單線程阻塞I/O模型的缺點,我們可以使用多線程對其進行改進,使之能并發地對多個客戶端同時進行響應。多線程模型的核心就是利用多線程機制為每個客戶端分配一個線程。服務器端開始監聽客戶端的訪問,假如有兩個客戶端發送請求過來,服務器端在接收到客戶端請求后分別創建兩個線程對它們進行處理,每條線程負責一個客戶端連接,直到響應完成。期間兩個線程并發地為各自對應的客戶端處理請求,包括讀取客戶端數據、處理客戶端數據、寫數據回客戶端等操作。
這種模型的I/O操作也是阻塞的,因為每個線程執行到讀取或寫入操作時都將進入阻塞狀態,直到讀取到客戶端的數據或數據成功寫入客戶端后才解除阻塞狀態。盡管I/O操作阻塞,但這種模式比單線程處理的性能明顯高了,它不用等到第一個請求處理完才處理第二個,而是并發地處理客戶端請求,客戶端連接與服務器端處理線程的比例是1:1。
多線程阻塞I/O模型的特點:支持對多個客戶端并發響應,處理能力得到大幅提高,有較大的并發量,但服務器系統資源消耗量較大,而且多線程之間會產生線程切換成本,同時擁有較復雜的結構。
單線程非阻塞I/O模型
多線程阻塞I/O模型通過引入多線程確實提高了服務器端的并發處理能力,但每個連接都需要一個線程負責I/O操作。當連接數量較多時可能導致機器線程數量太多,而這些線程大多數時間卻處于等待狀態,造成極大的資源浪費。鑒于多線程阻塞I/O模型的缺點,有沒有可能用一個線程就可以維護多個客戶端連接并且不會阻塞在讀寫操作呢?下面介紹單線程非阻塞I/O模型。
單線程非阻塞I/O模型最重要的一個特點是,在調用讀取或寫入接口后立即返回,而不會進入阻塞狀態。在探討單線程非阻塞I/O模型前必須要先了解非阻塞情況下套接字事件的檢測機制,因為對于單線程非阻塞模型最重要的事情是檢測哪些連接有感興趣的事件發生。一般會有如下三種檢測方式。
應用程序遍歷套接字的事件檢測
當多個客戶端向服務器請求時,服務器端會保存一個套接字連接列表中,應用層線程對套接字列表輪詢嘗試讀取或寫入。對于讀取操作,如果成功讀取到若干數據,則對讀取到的數據進行處理;如果讀取失敗,則下一個循環再繼續嘗試。對于寫入操作,先嘗試將數據寫入指定的某個套接字,寫入失敗則下一個循環再繼續嘗試。
這樣看來,不管有多少個套接字連接,它們都可以被一個線程管理,一個線程負責遍歷這些套接字列表,不斷地嘗試讀取或寫入數據。這很好地利用了阻塞的時間,處理能力得到提升。但這種模型需要在應用程序中遍歷所有的套接字列表,同時需要處理數據的拼接,連接空閑時可能也會占用較多CPU資源,不適合實際使用。對此改進的方法是使用事件驅動的非阻塞方式。
內核遍歷套接字的事件檢測
這種方式將套接字的遍歷工作交給了操作系統內核,把對套接字遍歷的結果組織成一系列的事件列表并返回應用層處理。對于應用層,它們需要處理的對象就是這些事件,這就是其中一種事件驅動的非阻塞方式的實現。
服務器端有多個客戶端連接,應用層向內核請求讀寫事件列表。內核遍歷所有套接字并生成對應的可讀列表readList和可寫列表writeList。readList標明了每個套接字是否可讀,例如套接字1的值為1,表示可讀,socket2的值為0,表示不可讀。writeList則標明了每個套接字是否可寫。應用層遍歷讀寫事件列表readList和writeList,做相應的讀寫操作。
內核遍歷套接字時已經不用在應用層對所有套接字進行遍歷,將遍歷工作下移到內核層,這種方式有助于提高檢測效率。然而,它需要將所有連接的可讀事件列表和可寫事件列表傳到應用層,假如套接字連接數量變大,列表從內核復制到應用層也是不小的開銷。另外,當活躍連接較少時,內核與應用層之間存在很多無效的數據副本,因為它將活躍和不活躍的連接狀態都復制到應用層中。
內核基于回調的事件檢測
通過遍歷的方式檢測套接字是否可讀可寫是一種效率比較低的方式,不管是在應用層中遍歷還是在內核中遍歷。所以需要另外一種機制來優化遍歷的方式,那就是回調函數。內核中的套接字都對應一個回調函數,當客戶端往套接字發送數據時,內核從網卡接收數據后就會調用回調函數,在回調函數中維護事件列表,應用層獲取此事件列表即可得到所有感興趣的事件。
內核基于回調的事件檢測方式有兩種。第一種是用可讀列表readList和可寫列表writeList標記讀寫事件,套接字的數量與readList和writeList兩個列表的長度一樣,readList第一個元素標為1則表示套接字1可讀,同理,writeList第二個元素標為1則表示套接字2可寫。如圖所示,多個客戶端連接服務器端,當客戶端發送數據過來時,內核從網卡復制數據成功后調用回調函數將readList第一個元素置為1,應用層發送請求讀、寫事件列表,返回內核包含了事件標識的readList和writeList事件列表,進而分表遍歷讀事件列表readList和寫事件列表writeList,對置為1的元素對應的套接字進行讀或寫操作。這樣就避免了遍歷套接字的操作,但仍然有大量無用的數據(狀態為0的元素)從內核復制到應用層中。于是就有了第二種事件檢測方式。
內核基于回調的事件檢測方式二如圖所示。服務器端有多個客戶端套接字連接。首先,應用層告訴內核每個套接字感興趣的事件。接著,當客戶端發送數據過來時,對應會有一個回調函數,內核從網卡復制數據成功后即調回調函數將套接字1作為可讀事件event1加入到事件列表。同樣地,內核發現網卡可寫時就將套接字2作為可寫事件event2添加到事件列表中。最后,應用層向內核請求讀、寫事件列表,內核將包含了event1和event2的事件列表返回應用層,應用層通過遍歷事件列表得知套接字1有數據待讀取,于是進行讀操作,而套接字2則可以寫入數據。
上面兩種方式由操作系統內核維護客戶端的所有連接并通過回調函數不斷更新事件列表,而應用層線程只要遍歷這些事件列表即可知道可讀取或可寫入的連接,進而對這些連接進行讀寫操作,極大提高了檢測效率,自然處理能力也更強。
對于Java來說,非阻塞I/O的實現完全是基于操作系統內核的非阻塞I/O,它將操作系統的非阻塞I/O的差異屏蔽并提供統一的API,讓我們不必關心操作系統。JDK會幫我們選擇非阻塞I/O的實現方式,例如對于Linux系統,在支持epoll的情況下JDK會優先選擇用epoll實現Java的非阻塞I/O。這種非阻塞方式的事件檢測機制就是效率最高的“內核基于回調的事件檢測”中的第二種方式。
在了解了非阻塞模式下的事件檢測方式后,重新回到對單線程非阻塞I/O模型的討論。雖然只有一個線程,但是它通過把非阻塞讀寫操作與上面幾種檢測機制配合就可以實現對多個連接的及時處理,而不會因為某個連接的阻塞操作導致其他連接無法處理。在客戶端連接大多數都保持活躍的情況下,這個線程會一直循環處理這些連接,它很好地利用了阻塞的時間,大大提高了這個線程的執行效率。
單線程非阻塞I/O模型的主要優勢體現在對多個連接的管理,一般在同時需要處理多個連接的發場景中會使用非阻塞NIO模式,此模型下只通過一個線程去維護和處理連接,這樣大大提高了機器的效率。一般服務器端才會使用NIO模式,而對于客戶端,出于方便及習慣,可使用阻塞模式的套接字進行通信。
多線程非阻塞I/O模型
單線程非阻塞I/O模型已經大大提高了機器的效率,而在多核的機器上可以通過多線程繼續提高機器效率。最樸實、最自然的做法就是將客戶端連接按組分配給若干線程,每個線程負責處理對應組內的連接。如圖所示,有4個客戶端訪問服務器,服務器將套接字1和套接字2交由線程1管理,而線程2則管理套接字3和套接字4,通過事件檢測及非阻塞讀寫就可以讓每個線程都能高效處理。
最經典的多線程非阻塞I/O模型方式是Reactor模式。首先看單線程下的Reactor,Reactor將服務器端的整個處理過程分成若干個事件,例如分為接收事件、讀事件、寫事件、執行事件等。Reactor通過事件檢測機制將這些事件分發給不同處理器去處理。如圖所示,若干客戶端連接訪問服務器端,Reactor負責檢測各種事件并分發到處理器,這些處理器包括接收連接的accept處理器、讀數據的read處理器、寫數據的write處理器以及執行邏輯的process處理器。在整個過程中只要有待處理的事件存在,即可以讓Reactor線程不斷往下執行,而不會阻塞在某處,所以處理效率很高。
基于單線程Reactor模型,根據實際使用場景,把它改進成多線程模式。常見的有兩種方式:一種是在耗時的process處理器中引入多線程,如使用線程池;另一種是直接使用多個Reactor實例,每個Reactor實例對應一個線程。
Reactor模式的一種改進方式如圖所示。其整體結構基本上與單線程的Reactor類似,只是引入了一個線程池。由于對連接的接收、對數據的讀取和對數據的寫入等操作基本上都耗時較少,因此把它們都放到Reactor線程中處理。然而,對于邏輯處理可能比較耗時的工作,可以在process處理器中引入線程池,process處理器自己不執行任務,而是交給線程池,從而在Reactor線程中避免了耗時的操作。將耗時的操作轉移到線程池中后,盡管Reactor只有一個線程,它也能保證Reactor的高效。
Reactor模式的另一種改進方式如圖所示。其中有多個Reactor實例,每個Reactor實例對應一個線程。因為接收事件是相對于服務器端而言的,所以客戶端的連接接收工作統一由一個accept處理器負責,accept處理器會將接收的客戶端連接均勻分配給所有Reactor實例,每個Reactor實例負責處理分配到該Reactor上的客戶端連接,包括連接的讀數據、寫數據和邏輯處理。這就是多Reactor實例的原理。
多線程非阻塞I/O模式讓服務器端處理能力得到很大提高,它充分利用機器的CPU,適合用于處理高并發的場景,但它也讓程序更復雜,更容易出現問題。
-
服務器
+關注
關注
12文章
9582瀏覽量
86937 -
多線程
+關注
關注
0文章
279瀏覽量
20229 -
阻塞
+關注
關注
0文章
24瀏覽量
8219 -
非阻塞
+關注
關注
0文章
13瀏覽量
2237 -
單線程
+關注
關注
0文章
18瀏覽量
1815
原文標題:最全服務器模型詳解——從單線程阻塞到多線程非阻塞
文章出處:【微信號:magedu-Linux,微信公眾號:馬哥Linux運維】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
阻塞與非阻塞I/O詳解
阻塞與非阻塞I/O
LabVIEW中使用多線程運行速度是否會更快
Java多線程總結之Queue

多線程好還是單線程好?單線程和多線程的區別 優缺點分析
Nodejs搭建的異步非阻塞服務器與傳統的阻塞多線程服務器區別

多線程服務器編程模型:如何正確使用mutex 和condition variable

評論