高并發四種IO模型的底層原理
1 IO讀寫的基本原理
為了避免用戶進程直接操作內核,保證內核安全,操作系統將內存(虛擬內存)劃分為兩部分:一部分是內核空間(Kernel-Space),另一部分是用戶空間(User-Space)。在Linux系統中,內核模塊運行在內核空間,對應的進程處于內核態;用戶程序運行在用戶空間,對應的進程處于用戶態。
操作系統的核心是內核程序,它獨立于普通的應用程序,既有權限訪問受保護的內核空間,也有權限訪問硬件設備,而普通的應用程序并沒有這樣的權限。內核空間總是駐留在內存中,是為操作系統的內核保留的。應用程序不允許直接在內核空間區域進行讀寫,也不允許直接調用內核代碼定義的函數。每個應用程序進程都有一個單獨的用戶空間,對應的進程處于用戶態,用戶態進程不能訪問內核空間中的數據,也不能直接調用內核函數,因此需要將進程切換到內核態才能進行系統調用。
內核態進程可以執行任意命令,調用系統的一切資源,而用戶態進程只能執行簡單的運算,不能直接調用系統資源,那么問題來了:用戶態進程如何執行系統調用呢?答案是:用戶態進程必須通過系統調用(System Call)向內核發出指令,完成調用系統資源之類的操作。
說明:
如果沒有特別聲明,本書后文所提到的內核是指操作系統的內核。
用戶程序進行IO的讀寫依賴于底層的IO讀寫,基本上會用到底層的read和write兩大系統調用。雖然在不同的操作系統中read和write兩大系統調用的名稱和形式可能不完全一樣,但是它們的基本功能是一樣的。
操作系統層面的read系統調用并不是直接從物理設備把數據讀取到應用的內存中,write系統調用也不是直接把數據寫入物理設備。上層應用無論是調用操作系統的read還是調用操作系統的write,都會涉及緩沖區。具體來說,上層應用通過操作系統的read系統調用把數據從內核緩沖區復制到應用程序的進程緩沖區,通過操作系統的write系統調用把數據從應用程序的進程緩沖區復制到操作系統的內核緩沖區。
簡單來說,應用程序的IO操作實際上不是物理設備級別的讀寫,而是緩存的復制。read和write兩大系統調用都不負責數據在內核緩沖區和物理設備(如磁盤、網卡等)之間的交換。這個底層的讀寫交換操作是由操作系統內核(Kernel)來完成的。所以,在應用程序中,無論是對socket的IO操作還是對文件的IO操作,都屬于上層應用的開發,它們在輸入(Input)和輸出(Output)維度上的執行流程是類似的,都是在內核緩沖區和進程緩沖區之間進行數據交換。
2 內核緩沖區與進程緩沖區
為什么設置那么多的緩沖區,導致讀寫過程那么麻煩呢?
緩沖區的目的是減少與設備之間的頻繁物理交換。計算機的外部物理設備與內存和CPU相比,有著非常大的差距,外部設備的直接讀寫涉及操作系統的中斷。發生系統中斷時,需要保存之前的進程數據和狀態等信息,結束中斷之后,還需要恢復之前的進程數據和狀態等信息。為了減少底層系統的頻繁中斷所導致的時間損耗、性能損耗,出現了內核緩沖區。
操作系統會對內核緩沖區進行監控,等待緩沖區達到一定數量的時候,再進行IO設備的中斷處理,集中執行物理設備的實際IO操作,通過這種機制來提升系統的性能。至于具體什么時候執行系統中斷(包括讀中斷、寫中斷)則由操作系統的內核來決定,應用程序不需要關心。
上層應用使用read系統調用時,僅僅把數據從內核緩沖區復制到應用的緩沖區(進程緩沖區)?;上層應用使用write系統調用時,僅僅把數據從應用的緩沖區復制到內核緩沖區。
內核緩沖區與應用緩沖區在數量上也不同。在Linux系統中,操作系統內核只有一個內核緩沖區。每個用戶程序(進程)都有自己獨立的緩沖區,叫作用戶緩沖區或者進程緩沖區。在大多數情況下,Linux系統中用戶程序的IO讀寫程序并沒有進行實際的IO操作,而是在用戶緩沖區和內核緩沖區之間直接進行數據的交換。
3 典型的系統調用流程
用戶程序所使用的系統調用read和write并不是使數據在內核緩沖區和物理設備之間交換:read調用把數據從內核緩沖區復制到應用的用戶緩沖區,write調用把數據從應用的用戶緩沖區復制到內核緩沖區。兩個系統調用的大致流程如圖2-1所示。
這里以read系統調用為例,看一下一個完整輸入流程的兩個階段:
- 應用程序等待數據準備好。
- 從內核緩沖區向用戶緩沖區復制數據。
如果是讀取一個socket(套接字)?,那么以上兩個階段的具體處理流程如下:
- 第一個階段,應用程序等待數據通過網絡到達網卡,當所等待的分組到達時,數據被操作系統復制到內核緩沖區中。這個工作由操作系統自動完成,用戶程序無感知。
- 第二個階段,內核將數據從內核緩沖區復制到應用的用戶緩沖區。
再具體一點,如果是在Java客戶端和服務端之間完成一次socket請求和響應(包括read和write)的數據交換,其完整的流程如下:
- 客戶端發送請求:Java客戶端程序通過write系統調用將數據復制到內核緩沖區,Linux將內核緩沖區的請求數據通過客戶端機器的網卡發送出去。在服務端,這份請求數據會從接收網卡中讀取到服務端機器的內核緩沖區。
- 服務端獲取請求:Java服務端程序通過read系統調用從Linux內核緩沖區讀取數據,再送入Java進程緩沖區。
- 服務端業務處理:Java服務器在自己的用戶空間中完成客戶端的請求所對應的業務處理。
- 服務端返回數據:Java服務器完成處理后,構建好的響應數據將從用戶緩沖區寫入內核緩沖區,這里用到的是write系統調用,操作系統會負責將內核緩沖區的數據發送出去。
- 發送給客戶端:服務端Linux系統將內核緩沖區中的數據寫入網卡,網卡通過底層的通信協議將數據發送給目標客戶端。
由于生產環境的Java高并發應用基本都運行在Linux操作系統上,因此以上案例中的操作系統以Linux作為實例。
4 四種主要的IO模型
服務端高并發IO編程往往要求的性能都非常高,一般情況下需要選用高性能的IO模型。
4.1 同步阻塞IO
首先,解釋一下阻塞與非阻塞。阻塞IO指的是需要內核IO操作徹底完成后才返回到用戶空間執行用戶程序的操作指令。?“阻塞”指的是用戶程序(發起IO請求的進程或者線程)的執行狀態。可以說傳統的IO模型都是阻塞IO模型,并且在Java中默認創建的socket都屬于阻塞IO模型。
其次,解釋一下同步與異步。簡單來說,可以將同步與異步看成發起IO請求的兩種方式。同步IO是指用戶空間(進程或者線程)是主動發起IO請求的一方,系統內核是被動接收方。異步IO則反過來,系統內核是主動發起IO請求的一方,用戶空間是被動接收方。
同步阻塞IO(Blocking IO)指的是用戶空間(或者線程)主動發起,需要等待內核IO操作徹底完成后才返回到用戶空間的IO操作。在IO操作過程中,發起IO請求的用戶進程(或者線程)處于阻塞狀態。
在Java中
在Java應用程序進程中所有對socket連接進行的IO操作都是同步阻塞IO。在阻塞式IO模型中,從Java應用程序發起IO系統調用開始,一直到系統調用返回,這段時間內發起IO請求的Java進程(或者線程)是阻塞的。直到返回成功后,應用進程才能開始處理用戶空間的緩沖區數據。
同步阻塞IO的具體流程如圖2-2所示。
舉個例子,在Java中發起一個socket的read操作的系統調用,流程大致如下:
- (1)從Java進行IO讀后發起read系統調用開始,用戶線程(或者線程)就進入阻塞狀態。
- (2)當系統內核收到read系統調用后就開始準備數據。一開始,數據可能還沒有到達內核緩沖區(例如,還沒有收到一個完整的socket數據包)?,這時內核就要等待。
- (3)內核一直等到完整的數據到達,就會將數據從內核緩沖區復制到用戶緩沖區(用戶空間的內存)?,然后內核返回結果(例如返回復制到用戶緩沖區中的字節數)?。
- (4)直到內核返回后用戶線程才會解除阻塞的狀態,重新運行起來。阻塞IO的特點是在內核執行IO操作的兩個階段,發起IO請求的用戶進程(或者線程)被阻塞了。
阻塞IO的優點是:應用程序開發非常簡單;在阻塞等待數據期間,用戶線程掛起,基本不會占用CPU資源。
阻塞IO的缺點是:一般情況下會為每個連接配備一個獨立的線程,一個線程維護一個連接的IO操作。在并發量小的情況下,這樣做沒有什么問題。在高并發的應用場景下,阻塞IO模型需要大量的線程來維護大量的網絡連接,內存、線程切換開銷會非常巨大,性能很低,基本上是不可用的。
4.2 同步非阻塞IO
非阻塞IO(Non-Blocking IO, NIO)指的是用戶空間的程序不需要等待內核IO操作徹底完成,可以立即返回用戶空間去執行后續的指令,即發起IO請求的用戶進程(或者線程)處于非阻塞狀態,與此同時,內核會立即返回給用戶一個IO狀態值。
阻塞和非阻塞的區別是什么呢?阻塞是指用戶進程(或者線程)一直在等待,而不能做別的事情;非阻塞是指用戶進程(或者線程)獲得內核返回的狀態值就返回自己的空間,可以去做別的事情。在Java中,非阻塞IO的socket被設置為NONBLOCK模式。
說明:
同步非阻塞IO也可以簡稱為NIO,但是它不是Java編程中的NIO。Java編程中的NIO(New IO)類庫組件所歸屬的不是基礎IO模型中的NIO模型,而是IO多路復用模型。
同步非阻塞IO指的是用戶進程主動發起,不需要等待內核IO操作徹底完成就能立即返回用戶空間的IO操作。在IO操作過程中,發起IO請求的用戶進程(或者線程)處于非阻塞狀態。
在java中
在Linux系統下,socket連接默認是阻塞模式,可以將socket設置成非阻塞模式。在NIO模型中,應用程序一旦開始IO系統調用,就會出現以下兩種情況:
- (1)在內核緩沖區中沒有數據的情況下,系統調用會立即返回一個調用失敗的信息。
- (2)在內核緩沖區中有數據的情況下,在數據的復制過程中系統調用是阻塞的,直到完成數據從內核緩沖區復制到用戶緩沖區。復制完成后,系統調用返回成功,用戶進程(或者線程)可以開始處理用戶空間的緩沖區數據。
同步非阻塞IO的流程如圖2-3所示。
舉個例子,發起一個非阻塞socket的read操作的系統調用,流程如下:
- (1)在內核數據沒有準備好的階段,用戶線程發起IO請求時立即返回。所以,為了讀取最終的數據,用戶進程(或者線程)需要不斷地發起IO系統調用。
- (2)內核數據到達后,用戶進程(或者線程)發起系統調用,用戶進程(或者線程)阻塞。內核開始復制數據,它會將數據從內核緩沖區復制到用戶緩沖區,然后內核返回結果(例如返回復制到的用戶緩沖區的字節數)?。
- (3)用戶進程(或者線程)讀到數據后,才會解除阻塞狀態,重新運行起來。也就是說,用戶空間需要經過多次嘗試才能保證最終真正讀到數據,而后繼續執行。
同步非阻塞IO的特點是應用程序的線程需要不斷地進行IO系統調用,輪詢數據是否已經準備好,如果沒有準備好就繼續輪詢,直到完成IO系統調用為止。
同步非阻塞IO的優點是每次發起的IO系統調用在內核等待數據過程中可以立即返回,用戶線程不會阻塞,實時性較好。
同步非阻塞IO的缺點是不斷地輪詢內核,這將占用大量的CPU時間,效率低下。
總體來說,在高并發應用場景中,同步非阻塞IO是性能很低的,也是基本不可用的,一般Web服務器都不使用這種IO模型。在Java的實際開發中,不會涉及這種IO模型,但是此模型還是有價值的,其作用在于其他IO模型中可以使用非阻塞IO模型作為基礎,以實現其高性能。
4.3 IO多路復用
概念理解
為了提高性能,操作系統引入了一種新的系統調用,專門用于查詢IO文件描述符(含socket連接)的就緒狀態。在Linux系統中,新的系統調用為 select/epoll
系統調用。通過該系統調用,一個用戶進程(或者線程)可以監視多個文件描述符,一旦某個描述符就緒(一般是內核緩沖區可讀/可寫)?,內核就能夠將文件描述符的就緒狀態返回給用戶進程(或者線程)?,用戶空間可以根據文件描述符的就緒狀態進行相應的IO系統調用。
IO多路復用(IO Multiplexing)屬于一種經典的Reactor模式實現,有時也稱為異步阻塞IO, Java中的Selector屬于這種模型。
系統應用
如何避免同步非阻塞IO模型中輪詢等待的問題呢?答案是采用IO多路復用模型。
目前支持IO多路復用的系統調用有select、epoll等。幾乎所有的操作系統都支持select系統調用,它具有良好的跨平臺特性。epoll是在Linux 2.6內核中提出的,是select系統調用的Linux增強版本。
在IO多路復用模型中通過select/epoll系統調用,單個應用程序的線程可以不斷地輪詢成百上千的socket連接的就緒狀態,當某個或者某些socket網絡連接有IO就緒狀態時就返回這些就緒的狀態(或者說就緒事件)?。
舉個例子來說明IO多路復用模型的流程。發起一個多路復用IO的read操作的系統調用,流程如下:
- (1)選擇器注冊。首先,將需要read操作的目標文件描述符(socket連接)提前注冊到Linux的select/epoll選擇器中,在Java中所對應的選擇器類是Selector類。然后,開啟整個IO多路復用模型的輪詢流程。
- (2)就緒狀態的輪詢。通過選擇器的查詢方法,查詢所有提前注冊過的目標文件描述符(socket連接)的IO就緒狀態。通過查詢的系統調用,內核會返回一個就緒的socket列表。當任何一個注冊過的socket中的數據準備好或者就緒了就說明內核緩沖區有數據了,內核將該socket加入就緒的列表中,并且返回就緒事件。
- (3)用戶線程獲得了就緒狀態的列表后,根據其中的socket連接發起read系統調用,用戶線程阻塞。內核開始復制數據,將數據從內核緩沖區復制到用戶緩沖區。
- (4)復制完成后,內核返回結果,用戶線程才會解除阻塞的狀態,用戶線程讀取到了數據,繼續執行。
說明:
在用戶進程進行IO就緒事件的輪詢時,需要調用選擇器的select查詢方法,發起查詢的用戶進程或者線程是阻塞的。
當然,如果使用了查詢方法的非阻塞的重載版本,發起查詢的用戶進程或者線程也不會阻塞,重載版本會立即返回。
IO多路復用模型的read系統調用流程如圖2-4所示。
IO多路復用模型的特點是:IO多路復用模型的IO涉及兩種系統調用,一種是IO操作的系統調用,另一種是select/epoll就緒查詢系統調用。IO多路復用模型建立在操作系統的基礎設施之上,即操作系統的內核必須能夠提供多路分離的系統調用select/epoll。
和NIO模型相似,多路復用IO也需要輪詢。負責select/epoll狀態查詢調用的線程,需要不斷地進行select/epoll輪詢,以找出達到IO操作就緒的socket連接。
IO多路復用模型與同步非阻塞IO模型是有密切關系的,具體來說,注冊在選擇器上的每一個可以查詢的socket連接一般都設置成同步非阻塞模型,只是這一點對于用戶程序而言是無感知的。
IO多路復用模型的優點是一個選擇器查詢線程可以同時處理成千上萬的網絡連接,所以用戶程序不必創建大量的線程,也不必維護這些線程,從而大大減少了系統的開銷。與一個線程維護一個連接的阻塞IO模式相比,這一點是IO多路復用模型的最大優勢。
通過JDK的源碼可以看出,Java語言的NIO組件在Linux系統上是使用epoll系統調用實現的。所以,Java語言的NIO組件所使用的就是IO多路復用模型。
IO多路復用模型的缺點是,本質上 select/epoll
系統調用是阻塞式的,屬于同步IO,需要在讀寫事件就緒后由系統調用本身負責讀寫,也就是說這個讀寫過程是阻塞的。要徹底地解除線程的阻塞,就必須使用異步IO模型。
4.4 異步IO
異步IO(Asynchronous IO, AIO)指的是用戶空間的線程變成被動接收者,而內核空間成為主動調用者。在異步IO模型中,當用戶線程收到通知時,數據已經被內核讀取完畢并放在了用戶緩沖區內,內核在IO完成后通知用戶線程直接使用即可。
異步IO類似于Java中典型的回調模式,用戶進程(或者線程)向內核空間注冊了各種IO事件的回調函數,由內核去主動調用。
系統應用
異步IO模型的基本流程是:用戶線程通過系統調用向內核注冊某個IO操作。內核在整個IO操作(包括數據準備、數據復制)完成后通知用戶程序,用戶執行后續的業務操作。
在異步IO模型中,在整個內核的數據處理過程(包括內核將數據從網絡物理設備(網卡)讀取到內核緩沖區、將內核緩沖區的數據復制到用戶緩沖區)中,用戶程序都不需要阻塞。
異步IO模型的流程如圖2-5所示。
舉個例子,發起一個異步IO的read操作的系統調用,流程如下:
- (1)當用戶線程發起了read系統調用后,立刻就可以去做其他的事,用戶線程不阻塞。
- (2)內核開始IO的第一個階段:準備數據。準備好數據,內核就會將數據從內核緩沖區復制到用戶緩沖區。
- (3)內核會給用戶線程發送一個信號(Signal),或者回調用戶線程注冊的回調方法,告訴用戶線程read系統調用已經完成,數據已經讀入用戶緩沖區。
- (4)用戶線程讀取用戶緩沖區的數據,完成后續的業務操作。
異步IO模型的特點是在內核等待數據和復制數據的兩個階段,用戶線程都不是阻塞的。用戶線程需要接收內核的IO操作完成的事件,或者用戶線程需要注冊一個IO操作完成的回調函數。正因為如此,異步IO有的時候也被稱為信號驅動IO。
異步IO模型的缺點是應用程序僅需要進行事件的注冊與接收,其余的工作都留給了操作系統,也就是說需要底層內核提供支持。
理論上來說,異步IO是真正的異步輸入輸出,它的吞吐量高于IO多路復用模型的吞吐量。就目前而言,Windows系統下通過IOCP實現了真正的異步IO。在Linux系統下,異步IO模型在2.6版本才引入,JDK對它的支持目前并不完善,因此異步IO在性能上沒有明顯的優勢。
大多數高并發服務端的程序都是基于Linux系統的。因而,目前這類高并發網絡應用程序的開發大多采用IO多路復用模型。大名鼎鼎的Netty框架使用的就是IO多路復用模型,而不是異步IO模型。
參考
- 書名:Java高并發核心編程 卷1:NIO、Netty、Redis、ZooKeeper
- 作者:尼恩