從 I/O 模型到 Netty(一)

Alchemist發表於2017-03-13

從 I/O 模型到 Netty(一)
如何使用咖啡讀檔案

I/O是任何一個程式設計者都無法忽略的存在,很多高階程式語言都在嘗試使用巧妙的設計遮蔽I/O的實際存在,減小它對程式的影響,但是要真正的理解並更好運用這些語言,還是要搞清楚I/O的一些基本理念。本文將從最基本的I/O概念開始,試圖理清當前I/O處理存在的問題和與之對應一些手段及背後的思想。

本來這是上個月在公司內部做的一次關於NIO的分享,發現很多概念可能當時理解的很清楚,過了一段時間就會感到模糊了。在這裡整理一下,以備以後檢視,同時也將作為另一個系列的開端。

由於篇幅限制,本文將只包含I/O模型到Reactor的部分,下一篇會繼續講到Netty和Dubbo中的I/O。本文包含以下內容:

  1. 五種典型的I/O模型
  2. 同步&非同步、阻塞&非阻塞的概念
  3. Reactor & Proactor
  4. Reactor的啟發

五種經典的I/O模型

這個部分的內容是理解各種I/O程式設計的基礎,也是網上被講解的最多的部分,這裡將簡單介紹一下Unix中5種I/O模型,由於作業系統的理論大多是相通的,所以大致流行的作業系統基本上都是這5中I/O模型。這一節的圖例描述的是從網路卡讀取UDP資料包的過程,但是其模型放到更高層的系統設計中是同樣有效的。

這一節的圖都可以在「Unix網路程式設計」這本書裡找到

0. 寫在前面

從作業系統層面來看,I/O操作是分很多步驟的,如:等待資料、將資料拷貝到核心空間的PageCache(如果是Buffered I/O的話)、將資料拷貝到使用者空間等。下面的幾個模型有幾個可能看起來很相似(在高階語言的環境中看,這TM不就是換了個概念重新講一次嗎),但從作業系統的角度來看他們是不同的。

1. Blocking I/O(阻塞I/O)

這是最基礎的I/O模型,也有人會叫它「同步阻塞I/O」,如下圖(從網路卡讀取UDP資料)所示,請求資料的程式需要一直阻塞等待讀取完成才能返回,同時整個讀取的動作(這裡是recvfrom)也是要同步等待I/O操作的完成才返回。

從 I/O 模型到 Netty(一)
BIO

這個模型最大的問題在於比較耗時和浪費CPU資源,I/O裝置(這裡是網路卡)往往是一種傳輸速率較慢的裝置,如果在需要很大吞吐量的系統中這種模型就不太適合了。

但是,有時候我們必須等待從I/O裝置中傳入的資料或者要向它寫入某些資料,這個時候阻塞I/O往往是最適合的。比如你的專案中有一個配置檔案,裡邊包含了很多關於專案的配置資訊,那麼在啟動專案的時候就必須等待這個檔案的內容被全部讀取並解析後才能繼續啟動專案,這種場景下BIO是最合適的。

//程式碼1
//在Java中使用同步阻塞I/O實現檔案的讀取
public static void main(String[] args) throws IOException {
    FileInputStream fis = new FileInputStream(new File(PRO_FILE_PATH));
    Properties pro = new Properties();
    pro.load(fis);
    for (Object key : pro.keySet()) {
        System.out.println(key);
        System.out.println(pro.getProperty((String)key));
    }
}複製程式碼
2. Nonblocking I/O(非阻塞I/O)

如下圖所示,它與BIO剛好相反,當資料沒有準備好的時候,recvfrom呼叫仍然是同步返回結果,只是如果I/O不可用,它會即時返回一個錯誤結果,然後使用者程式不斷輪訓,那麼對於整個使用者程式而言,它是非阻塞的。通常情況下,這是一種低效且十分浪費CPU的操作。

從 I/O 模型到 Netty(一)
NIO

3. I/O Multiplexing(I/O多路複用)

如下圖所示,在呼叫recvfrom之前先呼叫另外一個系統呼叫select,當它返回時就表示我們的資料準備好了,然後再呼叫recvfrom就能直接讀取到資料了。在這種場景下,整個讀取的動作(由兩個系統呼叫組成)是非同步的,同時select動作是會一直阻塞等待I/O事件的到來。

這種模式有個優點,這裡的select往往可以監聽很多事件,它往往是在多執行緒的場景下使用,比如在Java的NIO程式設計中,多個執行緒可以向同一個Selector註冊多個事件,這樣就達到了多路複用的效果。

從 I/O 模型到 Netty(一)
I/O多路複用

4. Signal-Driven I/O(訊號驅動I/O)

如下圖所示,使用者程式告訴網路卡說,你準備好了叫我一聲,然後可以去做別的事情,當網路卡來叫的時候就可以繼續讀操作了。按照上邊幾種模式的分類方法,很容易就把它同樣分到了非同步非阻塞模型中。

從作業系統的角度來看,「訊號驅動I/O」和#3中介紹的「多路複用」還有下面要介紹的「AIO」是有很大的不同的。
但是從概念上講,它們是很相似的,其他兩種其實也可以說是由某種訊號驅動的I/O。I/O多路複用的訊號是select呼叫的返回,AIO則是由更底層的實現來傳遞訊號。
當然,還有一個區別是「資料從核心空間拷貝到使用者空間」這個動作不再需要recvfrom等待,而是在AIO的訊號到來時就已經完成。

從 I/O 模型到 Netty(一)
訊號驅動I/O

5. Asynchronous I/O (非同步I/O)

如下圖所示,使用者程式使用作業系統提供的非同步I/O的系統呼叫aio_read,這個呼叫會即時返回,當整個I/O操作完成後它會通知使用者程式。典型的非同步非阻塞I/O,與「訊號驅動I/O」不同的是這個訊號是等到所有的I/O動作都執行完之後(資料已經被拷貝到使用者空間),才被髮送給使用者程式。

AIO是一個很好的理念,使用起來也更簡單,但是其內部實現就沒那麼簡單了,POSIX中定義的AIO是通過多執行緒來實現的,它最底層的I/O模組呼叫還是BIO,而Linux那群人就自己搞了一個真的核心級的非同步非阻塞I/O,但是目前僅支援Linux,而且還引入了Direct I/O這個概念。

從 I/O 模型到 Netty(一)
AIO

在有些平臺中,AIO是預設實現的,比如nodejs,其底層其實也是使用阻塞I/O實現的非同步,但是對於開發者來說,可以認為它是完全非同步的。下面是nodejs讀取檔案的一個例子:

//程式碼2
//node環境下非同步讀取一個檔案
const fs = require('fs')
const file='/Users/lk/Desktop/pro.properties'
fs.readFile(file,'utf-8', (err,data)=>{console.log(data)});複製程式碼

同步&非同步、阻塞&非阻塞的概念

「Unix網路程式設計」中說道,按照POSIX標準中的術語,同步指的是I/O動作會導致使用者程式阻塞,非同步則剛好相反。按照這種分類,上邊5種I/O模型中,只有AIO一種是非同步的,其他都是同步的。

但是從高階語言的角度看,「I/O多路複用」和「訊號驅動I/O」都沒有導致使用者程式的完全被阻塞,因為在很多高階語言中,程式大多是在多執行緒環境下執行的,一個執行緒阻塞並不會阻塞整個程式的執行。從這個角度來看,同步&非同步、阻塞&非阻塞這兩對概念只是從不同角度對同一個場景的描述。

在Java中,同步非同步往往指的是函式是否會等待整個操作處理完成後返回,而阻塞與非阻塞指的往往是使用者執行緒是否需要等待某個事件的到來而阻塞。

Reactor & Proactor

把視線從底層的I/O概念中移開,放到普通的應用層實現上,通常基於以上幾種I/O模型,可以對應幾個程式設計模式,這裡將重點介紹Reactor和Proactor。

使用Reactor模式構建的服務端

從 I/O 模型到 Netty(一)
一個經過改進的多執行緒的Reactor模式

簡單來說,Reactor指的是反應器,在這個模式中有一個角色叫分發器(分發器的叫法多種多樣,acceptor、selector或者dispatcher),它會分發各種事件給Reactor,Reactor再去根據不同的事件來做相應的動作。在上圖中Reactor進行計算的方式是通過執行緒池實現的,這是在簡單的Reactor模式上又新增了更多的能力,來進一步提高吞吐量,這也是Netty的基本架構。

舉個栗子-BIO

假設一個初中二年級的班級正在上自習,小紅是班上的班花,班上很多男孩子都喜歡她,其中就有小明、小黑和小白,於是他們三個人開始給她寫情書,然後通過同學把紙條傳給小紅。小紅一次只能讀一封小紙條,所以她只能順序地拿到小紙條,讀小紙條(讓老師幫忙讀並理解小紙條),思考如何回覆,最後把想法寫在紙條上(假設後桌1寫字好看,小紅必須讓她來寫回信),再傳送小紙條發還回去。

這就是普通的BIO(方案#1)。

舉個栗子-Reactor

上個例子中的模式中,後邊到來的小紙條往往要很久才能收到回信,造成了很壞的使用者體驗。

假如小紅讀(看小紙條,耗時t1)、想(回信的內容,耗時t2)、回(把回信的內容寫到紙條上,耗時t3)的每個步驟都需要1分鐘,則第n個小紙條從收到到發回要耗時:T = n*(t1 + t2 + t3),那麼第一個人只需要3分鐘就能拿到回信,第二個人需要6分鐘,第3個人就需要9分鐘。

於是小紅開始想,可以發動四周的同學幫自己思考回覆的方案,並讓自己的同桌小綠幫自己注意著「老師讀完小紙條,後桌1寫完小紙條」這兩個事件。當有三個紙條同時到來時,小紅都放到老師那裡,老師順序的讀,每條讀完後再交給前桌1前桌2來思考回覆策略,然後交給後桌1寫紙條。這樣,第n個人拿到回覆的時間是T = n*t1 + t2 + t3,它們分別是3分鐘、4分鐘、5分鐘。使用者體驗明顯提高,而且小紅自己還可以空出來很多的時間學習(方案#2)。

從 I/O 模型到 Netty(一)
用Reactor來武裝自己

可能有人已經看到問題了,小紅可以直接讓小綠前桌1前桌2分別處理一張小紙條(方案#3),可以達到同樣的效果啊(三張小紙條收到回覆的時間同樣是3、4、5分鐘),幹嘛套路這麼多。。。

首先,方案#2和方案#3雖然耗時相同,但它們所浪費的資源是不同的,在方案#2裡除了老師後桌1兩個不可或缺的資源外,前桌1前桌2只保留一個人就夠了,少一個人幫忙就少一個人分禮物。

其次,在這個例子裡剛好t1+t2+t3==3(執行緒數)*t1,而實際情況是t1+t2+t3>3(執行緒數)*t1,同時,這裡的問題規模也不大,如果只有3個人同時給小紅寫信,這個方案當然是好的,但是小紅太popular了,經常會同時有10個小紙條過來,這種情況下方案#3就要比方案#2慢了(具體的計算過程就不放了)。

Reactor的好處和壞處

Reactor帶來的好處是顯而易見的:

  1. 吞吐量大
    對小紅來說,同樣的資源可以傳遞更多的小紙條
  2. 對計算資源(CPU)更充分的利用

當然也有一些壞處:

  1. 系統設計更復雜了
  2. 由於系統更復雜,導致除錯很困難
  3. 不適合傳輸大量資料的場景
舉個栗子-Proactor

話說,老師發現小綠一直守在自己身邊,就問了她是什麼情況,然後他跟小紅說,「你下次不要讓小綠來守著我了,我讀完紙條後通知你就行啦」。於是,小綠就不用做分發器的角色了,也被解放出來做計算工作了。

可以看到,分發器的角色其實還在,只是整合在了老師身上了。

從 I/O 模型到 Netty(一)
我知道圖太挫,先湊合著看吧

如上圖所示,小紅收發小紙條的過程變成了這樣:

  1. 小紅拿到小紙條放到老師那裡,並且告訴老師讀完後通知自己,然後自己就可以去做別的事情了(比如學習)。
  2. 老師讀完後通知小紅,小紅在小綠前桌1前桌2之中找一個人來思考回信。
  3. 思考完之後告訴後桌1去寫回信。

Proactor模式相比Reactor明顯要更好,但唯一的不好的地方就在於,它有一個前提條件是「老師必須支援傳遞訊息」。它與Reactor是一脈相承的,Reactor的缺點同時也是Proactor的缺點。

Reactor的啟發

道理是死的,人是活的。對於每一種設計模式或者最佳實踐,其最有價值的部分其實是背後的思想。

啟發一,事件處理迴圈

Proactor相比Reactor更好的地方在於,I/O操作和訊息通知的過程被下層實現了,業務程式不再需要考慮這些,可以將Proactor看做是對Reactor的又一次封裝。根據這個思路可以再進一步,在Reactor模式中不阻塞select,而是在每個業務邏輯執行完後去處理這些事件,也就是在每次迴圈結束時去處理當前積攢下來的事件(這個模型裡如何定義一個迴圈是很重要的)。

從 I/O 模型到 Netty(一)
nodejs中的event loop

假設在某種場景下,整個程式的目的都是處理單一的事情(比如一個web伺服器的目的只是處理請求),我們可以將「與處理請求無關」的邏輯封裝到一個框架內,在每次請求處理完後,都執行一次事件的分發和處理,這就是event loop了。很多語言中都有這種概念,如nodejs中的event loop,iOS中的run loop。

啟發二,訊息通知&多路複用

Reactor和Proactor的思想是一樣的,都是要通過「訊息通知」和「多路複用」提高整個系統的吞吐量。在I/O之外,其實這兩個思想對於我們日常開發也是很有用的,比如我們在某處需要分別執行三個互相不影響(正交)的任務,之後才能做其他事情,根據這兩種思想可以寫出程式如下:

//程式碼3
void asyncCall(long millSeconds, Runnable... tasks) {
    if (tasks == null || tasks.length < 1) {
        return;
    }
    CountDownLatch latch = new CountDownLatch(tasks.length);
    for (Runnable task : tasks) {
        Runnable t = () -> {
            task.run();
            latch.countDown();
        };
        new Thread(t).start();
    }
    try {
        latch.await(millSeconds, TimeUnit.SECONDS);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}複製程式碼

這是一個很普通的多執行緒應用,也可以通過NIO的思想進行解釋。這裡通過CountDownLatch來進行訊息傳遞,而多個正交的任務複用這一個訊息。當然這個例子存在很多問題,每個任務都開一個執行緒明顯造成了資源的浪費,但這些不在這裡的考慮範圍之內。

還有一個明顯的例子是Dubbo的客戶端呼叫,這個下次再說吧。

總結

看了很多概念之後,有時候會突然發現,這不就是之前的某某某概念重新包裝了一下嗎,如享元模式和單例模式,SOA和微服務,,可能本來就是這樣的,我們搞這麼多的設計模式,最佳實踐,各種花哨的術語和概念,最根本的目的還是要寫出更好的程式碼。或者……也有例外?

從 I/O 模型到 Netty(一)
具體情況具體分析

相關文章