天天看點

帶你讀《Netty、Redis、ZooKeeper高并發實戰》之二:高并發IO的底層原理第2章

點選檢視第一章 點選檢視第三章

第2章

高并發IO的底層原理

本書的原則是:從基礎講起。IO的原理和模型是隐藏在程式設計知識底下的,是開發人員必須掌握的基礎原理,是基礎的基礎,更是通關大公司面試的必備知識。本章從作業系統的底層原理入手,通過圖文并茂的方式,為大家深入剖析高并發IO的底層原理,并介紹如何通過設定來讓作業系統支援高并發。

2.1 IO讀寫的基礎原理

大家知道,使用者程式進行IO的讀寫,依賴于底層的IO讀寫,基本上會用到底層的read&write兩大系統調用。在不同的作業系統中,IO讀寫的系統調用的名稱可能不完全一樣,但是基本功能是一樣的。

這裡涉及一個基礎的知識:read系統調用,并不是直接從實體裝置把資料讀取到記憶體中;write系統調用,也不是直接把資料寫入到實體裝置。上層應用無論是調用作業系統的read,還是調用作業系統的write,都會涉及緩沖區。具體來說,調用作業系統的read,是把資料從核心緩沖區複制到程序緩沖區;而write系統調用,是把資料從程序緩沖區複制到核心緩沖區。

也就是說,上層程式的IO操作,實際上不是實體裝置級别的讀寫,而是緩存的複制。read&write兩大系統調用,都不負責資料在核心緩沖區和實體裝置(如磁盤)之間的交換,這項底層的讀寫交換,是由作業系統核心(Kernel)來完成的。注:本書後文如果沒有特别指明,核心即指作業系統核心。

在使用者程式中,無論是Socket的IO、還是檔案IO操作,都屬于上層應用的開發,它們的輸入(Input)和輸出(Output)的處理,在程式設計的流程上,都是一緻的。

2.1.1 核心緩沖區與程序緩沖區

為什麼設定那麼多的緩沖區,為什麼要那麼麻煩呢?緩沖區的目的,是為了減少頻繁地與裝置之間的實體交換。大家都知道,外部裝置的直接讀寫,涉及作業系統的中斷。發生系統中斷時,需要儲存之前的程序資料和狀态等資訊,而結束中斷之後,還需要恢複之前的程序資料和狀态等資訊。為了減少這種底層系統的時間損耗、性能損耗,于是出現了記憶體緩沖區。

有了記憶體緩沖區,上層應用使用read系統調用時,僅僅把資料從核心緩沖區複制到上層應用的緩沖區(程序緩沖區);上層應用使用write系統調用時,僅僅把資料從程序緩沖區複制到核心緩沖區中。底層操作會對核心緩沖區進行監控,等待緩沖區達到一定數量的時候,再進行IO裝置的中斷處理,集中執行實體裝置的實際IO操作,這種機制提升了系統的性能。至于什麼時候中斷(讀中斷、寫中斷),由作業系統的核心來決定,使用者程式則不需要關心。

從數量上來說,在Linux系統中,作業系統核心隻有一個核心緩沖區。而每個使用者程式(程序),有自己獨立的緩沖區,叫作程序緩沖區。是以,使用者程式的IO讀寫程式,在大多數情況下,并沒有進行實際的IO操作,而是在程序緩沖區和核心緩沖區之間直接進行資料的交換。

2.1.2 詳解典型的系統調用流程

前面講到,使用者程式所使用的系統調用read&write,它們不等價于資料在核心緩沖區和磁盤之間的交換。read把資料從核心緩沖區複制到程序緩沖區,write把資料從程序緩沖區複制到核心緩沖區,具體的流程,如圖2-1所示。

帶你讀《Netty、Redis、ZooKeeper高并發實戰》之二:高并發IO的底層原理第2章

圖2-1 系統調用read&write的流程

這裡以read系統調用為例,先看下一個完整輸入流程的兩個階段:

  • 等待資料準備好。
  • 從核心向程序複制資料。

如果是read一個socket(套接字),那麼以上兩個階段的具體處理流程如下:

  • 第一個階段,等待資料從網絡中到達網卡。當所等待的分組到達時,它被複制到核心中的某個緩沖區。這個工作由作業系統自動完成,使用者程式無感覺。
  • 第二個階段,就是把資料從核心緩沖區複制到應用程序緩沖區。

再具體一點,如果是在Java伺服器端,完成一次socket請求和響應,完整的流程如下:

  • 用戶端請求:Linux通過網卡讀取用戶端的請求資料,将資料讀取到核心緩沖區。
  • 擷取請求資料:Java伺服器通過read系統調用,從Linux核心緩沖區讀取資料,再送入Java程序緩沖區。
  • 伺服器端業務處理:Java伺服器在自己的使用者空間中處理用戶端的請求。
  • 伺服器端傳回資料:Java伺服器完成處理後,建構好的響應資料,将這些資料從使用者緩沖區寫入核心緩沖區。這裡用到的是write系統調用。
  • 發送給用戶端:Linux核心通過網絡IO,将核心緩沖區中的資料寫入網卡,網卡通過底層的通信協定,會将資料發送給目标用戶端。

2.2 四種主要的IO模型

伺服器端程式設計,經常需要構造高性能的網絡應用,需要選用高性能的IO模型,這也是通關大公司面試必備的知識。

本章從最為基礎的模型開始,為大家揭秘IO模型。常見的IO模型有四種:

1. 同步阻塞IO(Blocking IO)

首先,解釋一下這裡的阻塞與非阻塞:

阻塞IO,指的是需要核心IO操作徹底完成後,才傳回到使用者空間執行使用者的操作。阻塞指的是使用者空間程式的執行狀态。傳統的IO模型都是同步阻塞IO。在Java中,預設建立的socket都是阻塞的。

其次,解釋一下同步與異步:

同步IO,是一種使用者空間與核心空間的IO發起方式。同步IO是指使用者空間的線程是主動發起IO請求的一方,核心空間是被動接受方。異步IO則反過來,是指系統核心是主動發起IO請求的一方,使用者空間的線程是被動接受方。

2. 同步非阻塞IO(Non-blocking IO)

非阻塞IO,指的是使用者空間的程式不需要等待核心IO操作徹底完成,可以立即傳回使用者空間執行使用者的操作,即處于非阻塞的狀态,與此同時核心會立即傳回給使用者一個狀态值。

簡單來說:阻塞是指使用者空間(調用線程)一直在等待,而不能幹别的事情;非阻塞是指使用者空間(調用線程)拿到核心傳回的狀态值就傳回自己的空間,IO操作可以幹就幹,不可以幹,就去幹别的事情。

非阻塞IO要求socket被設定為NONBLOCK。

強調一下,這裡所說的NIO(同步非阻塞IO)模型,并非Java的NIO(New IO)庫。

3. IO多路複用(IO Multiplexing)

即經典的Reactor反應器設計模式,有時也稱為異步阻塞IO,Java中的Selector選擇器和Linux中的epoll都是這種模型。

4. 異步IO(Asynchronous IO)

異步IO,指的是使用者空間與核心空間的調用方式反過來。使用者空間的線程變成被動接受者,而核心空間成了主動調用者。這有點類似于Java中比較典型的回調模式,使用者空間的線程向核心空間注冊了各種IO事件的回調函數,由核心去主動調用。

2.2.1 同步阻塞IO(Blocking IO)

在Java應用程式程序中,預設情況下,所有的socket連接配接的IO操作都是同步阻塞IO(Blocking IO)。

在阻塞式IO模型中,Java應用程式從IO系統調用開始,直到系統調用傳回,在這段時間内,Java程序是阻塞的。傳回成功後,應用程序開始處理使用者空間的緩存區資料。

同步阻塞IO的具體流程,如圖2-2所示。

帶你讀《Netty、Redis、ZooKeeper高并發實戰》之二:高并發IO的底層原理第2章

圖2-2 同步阻塞IO的流程

舉個例子,在Java中發起一個socket的read讀操作的系統調用,流程大緻如下:

(1)從Java啟動IO讀的read系統調用開始,使用者線程就進入阻塞狀态。

(2)當系統核心收到read系統調用,就開始準備資料。一開始,資料可能還沒有到達核心緩沖區(例如,還沒有收到一個完整的socket資料包),這個時候核心就要等待。

(3)核心一直等到完整的資料到達,就會将資料從核心緩沖區複制到使用者緩沖區(使用者空間的記憶體),然後核心傳回結果(例如傳回複制到使用者緩沖區中的位元組數)。

(4)直到核心傳回後,使用者線程才會解除阻塞的狀态,重新運作起來。

總之,阻塞IO的特點是:在核心進行IO執行的兩個階段,使用者線程都被阻塞了。

阻塞IO的優點是:應用的程式開發非常簡單;在阻塞等待資料期間,使用者線程挂起。在阻塞期間,使用者線程基本不會占用CPU資源。

阻塞IO的缺點是:一般情況下,會為每個連接配接配備一個獨立的線程;反過來說,就是一個線程維護一個連接配接的IO操作。在并發量小的情況下,這樣做沒有什麼問題。但是,當在高并發的應用場景下,需要大量的線程來維護大量的網絡連接配接,記憶體、線程切換開銷會非常巨大。是以,基本上阻塞IO模型在高并發應用場景下是不可用的。

2.2.2 同步非阻塞NIO(None Blocking IO)

socket連接配接預設是阻塞模式,在Linux系統下,可以通過設定将socket變成為非阻塞的模式(Non-Blocking)。使用非阻塞模式的IO讀寫,叫作同步非阻塞IO(None Blocking IO),簡稱為NIO模式。在NIO模型中,應用程式一旦開始IO系統調用,會出現以下兩種情況:

(1)在核心緩沖區中沒有資料的情況下,系統調用會立即傳回,傳回一個調用失敗的資訊。

(2)在核心緩沖區中有資料的情況下,是阻塞的,直到資料從核心緩沖複制到使用者程序緩沖。複制完成後,系統調用傳回成功,應用程序開始處理使用者空間的緩存資料。

同步非阻塞IO的流程,如圖2-3所示。

帶你讀《Netty、Redis、ZooKeeper高并發實戰》之二:高并發IO的底層原理第2章

圖2-3 同步非阻塞IO的流程

舉個例子。發起一個非阻塞socket的read讀操作的系統調用,流程如下:

(1)在核心資料沒有準備好的階段,使用者線程發起IO請求時,立即傳回。是以,為了讀取到最終的資料,使用者線程需要不斷地發起IO系統調用。

(2)核心資料到達後,使用者線程發起系統調用,使用者線程阻塞。核心開始複制資料,它會将資料從核心緩沖區複制到使用者緩沖區(使用者空間的記憶體),然後核心傳回結果(例如傳回複制到的使用者緩沖區的位元組數)。

(3)使用者線程讀到資料後,才會解除阻塞狀态,重新運作起來。也就是說,使用者程序需要經過多次的嘗試,才能保證最終真正讀到資料,而後繼續執行。

同步非阻塞IO的特點:應用程式的線程需要不斷地進行IO系統調用,輪詢資料是否已經準備好,如果沒有準備好,就繼續輪詢,直到完成IO系統調用為止。

同步非阻塞IO的優點:每次發起的IO系統調用,在核心等待資料過程中可以立即傳回。使用者線程不會阻塞,實時性較好。

同步非阻塞IO的缺點:不斷地輪詢核心,這将占用大量的CPU時間,效率低下。

總體來說,在高并發應用場景下,同步非阻塞IO也是不可用的。一般Web伺服器不使用這種IO模型。這種IO模型一般很少直接使用,而是在其他IO模型中使用非阻塞IO這一特性。在Java的實際開發中,也不會涉及這種IO模型。

這裡說明一下,同步非阻塞IO,可以簡稱為NIO,但是,它不是Java中的NIO,雖然它們的英文縮寫一樣,希望大家不要混淆。Java的NIO(New IO),對應的不是四種基礎IO模型中的NIO(None Blocking IO)模型,而是另外的一種模型,叫作IO多路複用模型( IO Multiplexing)。

2.2.3 IO多路複用模型(IO Multiplexing)

如何避免同步非阻塞IO模型中輪詢等待的問題呢?這就是IO多路複用模型。

在IO多路複用模型中,引入了一種新的系統調用,查詢IO的就緒狀态。在Linux系統中,對應的系統調用為select/epoll系統調用。通過該系統調用,一個程序可以監視多個檔案描述符,一旦某個描述符就緒(一般是核心緩沖區可讀/可寫),核心能夠将就緒的狀态傳回給應用程式。随後,應用程式根據就緒的狀态,進行相應的IO系統調用。

目前支援IO多路複用的系統調用,有select、epoll等等。select系統調用,幾乎在所有的作業系統上都有支援,具有良好的跨平台特性。epoll是在Linux 2.6核心中提出的,是select系統調用的Linux增強版本。

在IO多路複用模型中通過select/epoll系統調用,單個應用程式的線程,可以不斷地輪詢成百上千的socket連接配接,當某個或者某些socket網絡連接配接有IO就緒的狀态,就傳回對應的可以執行的讀寫操作。

舉個例子來說明IO 多路複用模型的流程。發起一個多路複用IO的read讀操作的系統調用,流程如下:

(1)選擇器注冊。在這種模式中,首先,将需要read操作的目标socket網絡連接配接,提前注冊到select/epoll選擇器中,Java中對應的選擇器類是Selector類。然後,才可以開啟整個IO多路複用模型的輪詢流程。

(2)就緒狀态的輪詢。通過選擇器的查詢方法,查詢注冊過的所有socket連接配接的就緒狀态。通過查詢的系統調用,核心會傳回一個就緒的socket清單。當任何一個注冊過的socket中的資料準備好了,核心緩沖區有資料(就緒)了,核心就将該socket加入到就緒的清單中。

當使用者程序調用了select查詢方法,那麼整個線程會被阻塞掉。

(3)使用者線程獲得了就緒狀态的清單後,根據其中的socket連接配接,發起read系統調用,使用者線程阻塞。核心開始複制資料,将資料從核心緩沖區複制到使用者緩沖區。

(4)複制完成後,核心傳回結果,使用者線程才會解除阻塞的狀态,使用者線程讀取到了資料,繼續執行。

IO多路複用模型的流程,如圖2-4所示。

帶你讀《Netty、Redis、ZooKeeper高并發實戰》之二:高并發IO的底層原理第2章

圖2-4 IO多路複用模型的流程

IO多路複用模型的特點:IO多路複用模型的IO涉及兩種系統調用(System Call),另一種是select/epoll(就緒查詢),一種是IO操作。IO多路複用模型建立在作業系統的基礎設施之上,即作業系統的核心必須能夠提供多路分離的系統調用select/epoll。

和NIO模型相似,多路複用IO也需要輪詢。負責select/epoll狀态查詢調用的線程,需要不斷地進行select/epoll輪詢,查找出達到IO操作就緒的socket連接配接。

IO多路複用模型與同步非阻塞IO模型是有密切關系的。對于注冊在選擇器上的每一個可以查詢的socket連接配接,一般都設定成為同步非阻塞模型。僅是這一點,對于使用者程式而言是無感覺的。

IO多路複用模型的優點:與一個線程維護一個連接配接的阻塞IO模式相比,使用select/epoll的最大優勢在于,一個選擇器查詢線程可以同時處理成千上萬個連接配接(Connection)。系統不必建立大量的線程,也不必維護這些線程,進而大大減小了系統的開銷。

Java語言的NIO(New IO)技術,使用的就是IO多路複用模型。在Linux系統上,使用的是epoll系統調用。

IO多路複用模型的缺點:本質上,select/epoll系統調用是阻塞式的,屬于同步IO。都需要在讀寫事件就緒後,由系統調用本身負責進行讀寫,也就是說這個讀寫過程是阻塞的。

如何徹底地解除線程的阻塞,就必須使用異步IO模型。

2.2.4 異步IO模型(Asynchronous IO)

異步IO模型(Asynchronous IO,簡稱為AIO)。AIO的基本流程是:使用者線程通過系統調用,向核心注冊某個IO操作。核心在整個IO操作(包括資料準備、資料複制)完成後,通知使用者程式,使用者執行後續的業務操作。

在異步IO模型中,在整個核心的資料處理過程中,包括核心将資料從網絡實體裝置(網卡)讀取到核心緩沖區、将核心緩沖區的資料複制到使用者緩沖區,使用者程式都不需要阻塞。

異步IO模型的流程,如圖2-5所示。

帶你讀《Netty、Redis、ZooKeeper高并發實戰》之二:高并發IO的底層原理第2章

圖2-5 異步IO模型的流程

舉個例子。發起一個異步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版本才引入,目前并不完善,其底層實作仍使用epoll,與IO多路複用相同,是以在性能上沒有明顯的優勢。

大多數的高并發伺服器端的程式,一般都是基于Linux系統的。因而,目前這類高并發網絡應用程式的開發,大多采用IO多路複用模型。

大名鼎鼎的Netty架構,使用的就是IO多路複用模型,而不是異步IO模型。

2.3 通過合理配置來支援百萬級并發連接配接

本章所聚焦的主題,是高并發IO的底層原理。前面已經深入淺出地介紹了高并發IO的模型。但是,即使采用了最先進的模型,如果不進行合理的配置,也沒有辦法支撐百萬級的網絡連接配接并發。

這裡所涉及的配置,就是Linux作業系統中檔案句柄數的限制。

順便說下,在生産環境中,大家都使用Linux系統,是以,後續文字中假想的生産作業系統,都是Linux系統。另外,由于大多數同學使用Windows進行學習和工作,是以,後續文字中假想的開發所用的作業系統都是Windows系統。

在生産環境Linux系統中,基本上都需要解除檔案句柄數的限制。原因是,Linux的系統預設值為1024,也就是說,一個程序最多可以接受1024個socket連接配接。這是遠遠不夠的。

本書的原則是:從基礎講起。

檔案句柄,也叫檔案描述符。在Linux系統中,檔案可分為:普通檔案、目錄檔案、連結檔案和裝置檔案。檔案描述符(File Descriptor)是核心為了高效管理已被打開的檔案所建立的索引,它是一個非負整數(通常是小整數),用于指代被打開的檔案。所有的IO系統調用,包括socket的讀寫調用,都是通過檔案描述符完成的。

在Linux下,通過調用ulimit指令,可以看到單個程序能夠打開的最大檔案句柄數量,這個指令的具體使用方法是:

ulimit -n           

什麼是ulimit指令呢?它是用來顯示和修改目前使用者程序一些基礎限制的指令,-n指令選項用于引用或設定目前的檔案句柄數量的限制值。Linux的系統預設值為1024。

預設的數值為1024,對絕大多數應用(例如Apache、桌面應用程式)來說已經足夠了。但是,是對于一些使用者基數很大的高并發應用,則是遠遠不夠的。一個高并發的應用,面臨的并發連接配接數往往是十萬級、百萬級、千萬級、甚至像騰訊QQ一樣的上億級。

檔案句柄數不夠,會導緻什麼後果呢?當單個程序打開的檔案句柄數量,超過了系統配置的上限值時,就會發出“Socket/File:Can't open so many files”的錯誤提示。

對于高并發、高負載的應用,就必須要調整這個系統參數,以适應處理并發處理大量連接配接的應用場景。可以通過ulimit來設定這兩個參數。方法如下:

ulimit  -n  1000000           

在上面的指令中,n的設定值越大,可以打開的檔案句柄數量就越大。建議以root使用者來執行此指令。

然而,使用ulimit指令來修改目前使用者程序的一些基礎限制,僅在目前使用者環境有效。直白地說,就是在目前的終端工具連接配接目前shell期間,修改是有效的;一旦斷開連接配接,使用者退出後,它的數值就又變回系統預設的1024了。也就是說,ulimit隻能作為臨時修改,系統重新開機後,句柄數量又會恢複為預設值。

如果想永久地把設定值儲存下來,可以編輯/etc/rc.local開機啟動檔案,在檔案中添加如下内容:

ulimit -SHn 1000000           

增加-S和-H兩個指令選項。選項-S表示軟性極限值,-H表示硬性極限值。硬性極限是實際的限制,就是最大可以是100萬,不能再多了。軟性極限是系統警告(Warning)的極限值,超過這個極限值,核心會發出警告。

普通使用者通過ulimit指令,可将軟極限更改到硬極限的最大設定值。如果要更改硬極限,必須擁有root使用者權限。

終極解除Linux系統的最大檔案打開數量的限制,可以通過編輯Linux的極限配置檔案/etc/security/limits.conf來解決,修改此檔案,加入如下内容:

soft nofile 1000000
hard nofile 1000000           

soft nofile表示軟性極限,hard nofile表示硬性極限。

在使用和安裝目前非常火的分布式搜尋引擎——ElasticSearch,就必須去修改這個檔案,增加最大的檔案句柄數的極限值。

在伺服器運作Netty時,也需要去解除檔案句柄數量的限制,修改/etc/security/limits.conf檔案即可。

2.4 本章小結

本書的原則是:從基礎講起。本章徹底展現了這個原則。

本章聚焦的主題:一是底層IO操作的兩個階段,二是最為基礎的四種IO模型,三是作業系統對高并發的底層的支援。

四種IO模型,基本上概況了目前主要的IO處理模型,理論上來說,從阻塞IO到異步IO,越往後,阻塞越少,效率也越優。在這四種IO模型中,前三種屬于同步IO,因為真正的IO操作都将阻塞應用線程。

隻有最後一種異步IO模型,才是真正的異步IO模型,可惜目前Linux作業系統尚欠完善。不過,通過應用層優秀架構如Netty,同樣能在IO多路複用模型的基礎上,開發出具備支撐高并發(如百萬級以上的連接配接)的伺服器端應用。

最後強調一下,本章是理論課,比較抽象,但是一定要懂。了解了這些理論之後,再學習後面的章節就會事半功倍。