解讀I/O多路複用技術
前言
當我們要編寫一個 echo 伺服器程式的時候,需要對使用者從標準輸入鍵入的互動命令做出響應。在這種情況下,伺服器必須響應兩個相互獨立的I/O事件:
1)網路客戶端發起網路連線請求
2)使用者在鍵盤上鍵入命令列
我們先等待哪個事件呢?沒有哪個選擇是理想的。
如果在 acceptor 中等待一個連線請求,我們就不能響應輸入的命令。類似地,如果在 read 中等待一個輸入命令,我們就不能響應任何連線請求。針對這種困境的一個解決辦法就是 I/O 多路複用技術。基本思路就是使用 select 函式,要求核心掛起程序,只有在一個或多個 I/O 事件發生後,才將控制返回給應用程式。——《UNIX網路程式設計》
我們以書中的這段描述來引出我們要講述的 I/O 多路複用技術。
I/O 多路複用概述
圖1
I/O 多路複用,I/O 就是指的我們網路 I/O,多路指多個 TCP 連線(或多個 Channel),複用指複用一個或少量執行緒。串起來理解就是很多個網路 I/O 複用一個或少量的執行緒來處理這些連線。
現在大部分講述 I/O 多路複用的文章用到的上面這張圖是《UNIX網路程式設計》一書的。那麼這也是當前我們理解 I/O 多路複用技術的基礎知識。從這張圖裡面我們 GET 到哪些點呢?
個人理解有:
-
1、怎麼區分的應用程序與核心
-
2、有兩次系統呼叫分別是 select 和 recvfrom
-
3、兩次系統呼叫程序都阻塞
-
4、等待哪些資料準備好
下面我們逐一闡述。
使用者程序和核心
圖2
根據網路 OSI 七層模型和網際網協議族的同比,我們可以知道這裡說的使用者程序和核心是以傳輸層為分割線,傳輸層以上(不包括)是指使用者程序,傳輸層以下(包括)是指核心。上三層,web 客戶端比如瀏覽器、web 伺服器這些都屬於應用層,裡面跑的程式則是應用程序。下四層處理所有的通訊細節、傳送資料、等待確認、給無序到達的資料排序等等。這四層也是通常作為作業系統核心的一部分提供。由此可見圖1中說的系統呼叫的地方正是第四層和第五層之間的位置。
為了理解使用者程序和核心,再來看一張圖,網路資料流向圖。也清晰的標明瞭使用者程序和核心的位置。值得注意的一點是客戶與伺服器之間的資訊流在其中一端是向下通過協議棧的,跨越網路後,在另一端是向上通過協議棧的。這張圖描述的是區域網內,如果是在廣域網那麼就是通過很多個路由器承載實際資料流。
圖3
select 和 recvfrom
select
理解了 select 就抓住了 I/O 多路複用的精髓,對應的作業系統中呼叫的則是系統的 select 函式,該函式會等待多個 I/O 事件(比如讀就緒,寫)的任何一個發生,並且只要有一個網路事件發生,select 執行緒就會執行。如果沒有任何一個事件發生則阻塞。我們在下面小節中會重點講述。函式如下:
#include<sys/select.h> #include<sys/time.h> int select(int maxfdpl,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout);
從這個函式的定義中的引數,我們能夠看出它描述的是,當呼叫 select 的時候告知核心對那些事件(讀就緒,寫)感興趣以及等待多長時間。
為了方便我們理解 select 呼叫,可以參照下面這張圖,是 jdk 的基於 I/O 多路複用技術的 NIO 實現。重點在於理解 Selector 複用器。
大致程式碼如下:
ServerSocketChannel serverChannel = ServerSocketChannel.open(); //開啟一個未繫結的serversocketchannel Selector selector = Selector.open(); //建立一個Selector serverChannel.configureBlocking(false); //設定非阻塞模式 serverChannel.register(selector, SelectionKey.OP_READ); //將ServerSocketChannel註冊到Selector while(true) { int readyChannels = selector.select(); if(readyChannels == 0) continue; Set selectedKeys = selector.selectedKeys(); Iterator keyIterator = selectedKeys.iterator(); while(keyIterator.hasNext()) { SelectionKey key = keyIterator.next(); if(key.isAcceptable()) { // a connection was accepted by a ServerSocketChannel. } else if (key.isConnectable()) { //連線就緒 // a connection was established with a remote server. } else if (key.isReadable()) { //讀就緒 // a channel is ready for reading } else if (key.isWritable()) { //寫就緒 // a channel is ready for writing } keyIterator.remove(); } }
recvfrom
recvfrom 一般用於 UDP 協議中,但是如果在 TCP 中 connect 函式呼叫後也可以用。用於從(已連線)套介面上接收資料,並捕獲資料傳送源的地址。也就是我們本文中以及書中說的真正的 I/O 操作。
阻塞、非阻塞
這張圖可以看出阻塞式 I/O、非阻塞式 I/O、I/O 複用、訊號驅動式 I/O 他們的第二階段都相同,也就是都會阻塞到 recvfrom 呼叫上面就是圖中“發起”的動作。非同步式 I/O 兩個階段都要處理。這裡我們重點對比阻塞式 I/O(也就是我們常說的傳統的 BIO)和 I/O 複用之間的區別。
阻塞式 I/O 和 I/O 複用,兩個階段都阻塞,那區別在哪裡呢?就在於第三節講述的 Selector,雖然第一階段都是阻塞,但是阻塞式 I/O 如果要接收更多的連線,就必須建立更多的執行緒。I/O 複用模式下在第一個階段大量的連線統統都可以過來直接註冊到 Selector 複用器上面,同時只要單個或者少量的執行緒來迴圈處理這些連線事件就可以了,一旦達到“就緒”的條件,就可以立即執行真正的 I/O 操作。這就是 I/O 複用與傳統的阻塞式 I/O 最大的不同。也正是 I/O 複用的精髓所在。
從應用程序的角度去理解始終是阻塞的,等待資料和將資料複製到使用者程序這兩個階段都是阻塞的。這一點我們從應用程式是可以清楚的得知,比如我們呼叫一個以 I/O 複用為基礎的 NIO 應用服務。呼叫端是一直阻塞等待返回結果的。
從核心的角度等待 Selector 上面的網路事件就緒,是阻塞的,如果沒有任何一個網路事件就緒則一直等待直到有一個或者多個網路事件就緒。但是從核心的角度考慮,有一點是不阻塞的,就是複製資料,因為核心不用等待,當有就緒條件滿足的時候,它直接複製,其餘時間在處理別的就緒的條件。這也是大家一直說的非阻塞 I/O。實際上是就是指的這個地方的非阻塞。
當我們閱讀《UNIX網路程式設計》(第三版)一書的時候。P124,6.2.3 小節中“而不是阻塞在真正的 I/O 系統呼叫上”這裡的阻塞是相對核心來說的。P127,6.2.7 小節“因為其中真正的 I/O 操作(recvfrom)將阻塞程序”這裡的阻塞是相對使用者程序來說的。明白了這兩點,理解起來就不矛盾了,而且一通到底!
適用場景
當服務程式需要承載大量 TCP 連結的時候,比如我們的訊息推送系統,IM 通訊,web 聊天等等,在我們已經理解 Selector 原理的情況下,知道使用 I/O 複用可以用少量的執行緒處理大量的連結。I/O 多路複用技術以事件驅動程式設計為基礎。它執行在單一程序上下文中,因此每個邏輯流都能訪問該程序的全部地址空間,這樣在流之間共享資料變得很容易。
總結
我們通常說的 NIO 大多數場景下都是基於 I/O 複用技術的 NIO,比如 jdk 中的 NIO,當然 Tomcat8 以後的 NIO 也是指的基於 I/O 複用的 NIO。注意,使用 NIO != 高效能,當連線數 < 1000,併發程度不高或者區域網環境下 NIO 並沒有顯著的效能優勢。如果放到線上環境,網路情況在有時候並不穩定的情況下,這種基於 I/O 複用技術的 NIO 的優勢就是傳統 BIO 不可同比的了。那麼使用 select 的優勢在於我們可以等到網路事件就緒,那麼用少量的執行緒去輪詢 Selector 上面註冊的事件,不就緒的不處理,就緒的拿出來立即執行真正的 I/O 操作。這個使得我們就可以用極少量的執行緒去 HOLD 住大量的連線。
Reference
https://www.jianshu.com/p/db5da880154a