大白話詳解 5 種網絡 IO 模型

1 前言

我們都知道,爲了實現高性能的通信服務器,BIO 在高併發的情況下會出現性能急劇下降的問題,甚至會由於創建過多線程而導致系統 OOM。因此在 Java 業界,BIO 的性能問題一直被開發者所詬病,所幸的是,JDK1.4 推出了 NIO,NIO 基本解決了 BIO 的性能問題,是目前實現 Java 高性能服務器的基礎框架。NIO 官方的叫法叫做 New IO, 而對應於操作系統層面來說其實也是 Non-Blocking IO。

大名鼎鼎的 Netty 就是 NIO 框架,而目前很多開源框架比如 Dubbo,RocketMQ,Seata,Spark,Flink 都是採用 Netty 作爲基礎通信組件。因此,學好 Netty 很重要,但是 NIO 作爲 Netty 的基礎,這裏想說的是學好 NIO 也一樣重要!

學好 NIO,那麼必須先理解操作系統層面的 5 種網絡 IO 模型。

2 5 種 IO 模型

2.1 阻塞 IO 模型

阻塞 IO 模型如下圖:從上圖可以看到,不管有無數據報到來,進程(線程)是阻塞於recvfrom系統調用的。這是什麼意思呢?說白了就是假如我們要用套接字讀取數據,此時我們必然會調用read方法,此時這個read方法就會觸發操作系統內核的一次recvfrom系統調用,此時有兩種情況:

  1. 內核還未接收到遠端數據,此時數據報沒有準備好,那麼讀取數據的線程就會一直阻塞,直到遠端發來數據報,這一阻塞的過程對應上圖序號 1 的過程;然後在數據報被從內核複製到用戶空間這一過程中,該線程會再次阻塞,直到複製完成,這一過程對應上圖的序號 2 的過程;

  2. 內核已經接收到遠端數據,此時數據報已經準備好,那麼數據報就會被從內核複製到用戶空間,這一過程是阻塞的,對應上圖序號 2 的過程。

可見,阻塞 IO 模型的話,讀一次數據會發生一次recvfrom系統調用, 整個過程都是阻塞的,即在內核的數據報還未準備好的時候,此時用戶進程( 線程)阻塞;當內核的數據報準備好的時候,此時數據報要從內核拷貝到用戶空間,此時用戶進程(線程)也一直阻塞;直到數據報拷貝到用戶空間後,此時用戶進程(線程)纔會醒過來,然後處理這些數據報即執行一些用戶的業務邏輯。當然,如果用戶進程(線程)在阻塞過程中,如果recvfrom系統調用被信號中斷,此時阻塞也是會被喚醒的。

思考: 這裏的recvfrom系統調用被信號中斷什麼情況下會發生?這個信號中斷指的是線程中斷(Thread.interrupt())麼?自行思考。

2.2 非阻塞 IO 模型

非阻塞 IO 模型如下圖:如上圖,根據內核中的數據報有無準備好,有以下兩種情形:

  1. 當內核中的數據報還沒準備好,此時recvfrom系統調用立即返回一個EWOULDBLOCK錯誤,即不會將用戶進程(線程)至於阻塞狀態。我們拿 Java 的 NIO 來說,當我們配置ServerSocketChannel.configureBlocking(false);SocketChannel..configureBlocking(false);時,我們調用ServerSocketChannel.accept()nullSocketChannel.read(buffer)不會阻塞的,若沒有新連接接入或內核中沒有數據報準備好,此時會理解返回null0 的返回結果, 說白了這個返回結果就是對應EWOULDBLOCK錯誤;

  2. 當內核中的數據報已經準備好時,此時recvfrom系統調用,用戶進程(線程)還是會阻塞,直到內核中的數據報已經拷貝到了用戶空間,此時用戶進程(線程)纔會被喚醒來處理接收的數據報。

非阻塞 IO 在用戶數據報還沒準備好的時候,recvfrom系統調用不會阻塞,接着會繼續進行下一輪的recvfrom系統調用看數據報有無準備好,週而復始,進程(線程)不斷輪訓,因此這是非常耗費 CPU 的。這種模型不是很常用,適合用在某臺 CPU 專爲某些功能準備的場合。

2.3 IO 複用模型

IO 複用模型如下圖:初步從以上 IO 複用模型來看,這不是跟 IO 阻塞模型差不多麼?當內核無數據報準備好時,select系統調用會阻塞;當內核數據拷貝到用戶空間時,此時recvfrom系統調用依然會阻塞,實在是看不到跟 IO 阻塞模型有啥區別?區別就是 IO 複用模型還比阻塞 IO 模型還多一次recvfrom系統調用,這不是明擺着多浪費一次 CPU 資源麼?

如果我們這麼想,那爲什麼 IO 複用模型得到大規模廣泛應用呢?其實 IO 複用模型真正佔優勢的地方在於select操作,這個select操作可以選擇多個文件描述符,分別對應 Java NIO 中的OP_CONNECT,OP_ACCEPT,OP_READOP_WRITE就緒事件。正是基於一次 recvfrom 系統調用中一個線程的 select 操作可以選擇多個文件描述符這個功能,我們現在用一個用戶線程就能監聽不同channelOP_CONNECT,OP_ACCEPT,OP_READOP_WRITE這些就緒事件,然後根據某個就緒事件拿到相應的channel來做對應的操作。而不用像阻塞 IO 模型或非阻塞 IO 模型那樣,一次 recvfrom 系統調用中一個線程就只能選擇一個文件描述符,這樣就嚴重限制了伸縮性。這麼說很抽象,就比如拿阻塞 IO 模型來說,由於用戶進程(線程)每一次recvfrom系統調用都是阻塞且只對應一個文件描述符,此時如果服務端線程阻塞於客戶端 A 的讀操作時,如果有另外的客戶端 B 需要接入服務端,此時服務端線程由於阻塞於客戶端 A 的讀操作,因此無法處理客戶端 B 的連接操作。此時,必然要一個線程一個文件描述符即服務端線程每accept了一個客戶端連接,此時就需要新建一個線程去處理這個客戶端連接的讀寫操作。我們都知道,線程是一種很昂貴的 CPU 資源,當開啓成千上萬的線程後,線程切換的成本很高,CPU 性能肯定下降,說不定高併發下還會 OOM。說到這裏,也許有同學會說,對於阻塞 IO 模型,我們不一個線程一個 socket,用線程池替代,當然,這是一個優化的點,但沒解決阻塞 IO 模型的根本。怎麼說呢?當線程池的所有線程都阻塞於客戶端的讀或寫操作時,此時其他新接入的線程將會積壓在線程池的隊列中阻塞等待。

2.4 信號驅動 IO 模型

信號驅動 IO 模型如下圖:可見,信號驅動 IO 模型在等待數據報期間是不會阻塞的,即用戶進程(線程)發送一個sigaction系統調用後,此時立刻返回,並不會阻塞,然後用戶進程(線程)繼續執行;當數據報準備好時,此時內核就爲該進程(線程)產生一個SIGIO信號,此時該進程(線程)就發生一次recvfrom系統調用將數據報從內核複製到用戶空間,注意,這個階段是阻塞的。

PS: 網上找了下信號驅動 IO 模型的 java 代碼,沒找到,會碼信號驅動 IO 模型代碼的下夥伴們可以教教我。

2.5 異步 IO 模型

異步 IO 模型如下圖:異步 IO 模型也很好理解,即用戶進程(線程)在等待數據報和數據報從內核拷貝到用戶空間這兩階段都是非阻塞的,即用戶進程(線程)發生一次系統調用後,立即返回,然後該用戶進程(線程)繼續往下執行。當內核把接收到數據報並把數據報拷貝到了用戶空間後,此時再通知用戶進程(線程)來處理用戶空間的數據報。也就是說,這一些列 IO 操作都交給了內核去處理了,用戶進程無須同步阻塞,因此是異步非阻塞的。

擴展: 異步 IO 模型跟信號驅動 IO 模型的區別在於當內核準備好數據報後,對於信號驅動 IO 模型,此時內核會通知用戶進程說數據報準備好啦,你需要發起系統調用來將數據報從內核拷貝到用戶空間,此過程是同步阻塞的;而對於異步 IO 模型,當數據報準備好時,內核不會再通知用戶進程,而是自己默默將數據報從內核拷貝到用戶空間後然後再通知用戶進程說,數據已經拷貝到用戶空間啦,你直接進行業務邏輯處理就行。

3 各種 IO 模型區別

通過 5 種 IO 模型的比對,可以發現,前 4 種 IO 模型都是同步阻塞 IO 模型,因爲其第二階段數據報從內核拷貝到用戶空間都是同步阻塞的,只是第一階段等待數據報的處理不同;最後一種 IO 模型(異步 IO 模型)纔是真正的異步非阻塞 IO 模型,內核將一切事情都幹完(內核:我真的好累)。

4 總結

好了,五種 IO 模型基本就已經總結完了,基本是自己基於《UNIX 網絡編程_卷 1_套接字》的讀書總結,接下來再通過 java 代碼將這幾種 IO 模型實現一遍。

參考:《UNIX 網絡編程_卷 1_套接字》

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/r0J65J1CykAf666u_Zuikw