從 I/O 模型到 Netty(一)

如何使用咖啡讀文件

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

原本這是上個月在公司內部作的一次關於NIO的分享,發現不少概念可能當時理解的很清楚,過了一段時間就會感到模糊了。在這裏整理一下,以備之後查看,同時也將做爲另外一個系列的開端。javascript

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

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

五種經典的I/O模型

這個部分的內容是理解各類I/O編程的基礎,也是網上被講解的最多的部分,這裏將簡單介紹一下Unix中5種I/O模型,因爲操做系統的理論大可能是相通的,因此大體流行的操做系統基本上都是這5中I/O模型。這一節的圖例描述的是從網卡讀取UDP數據包的過程,可是其模型放到更高層的系統設計中是一樣有效的。node

這一節的圖均可以在「Unix網絡編程」這本書裏找到react

0. 寫在前面

從操做系統層面來看,I/O操做是分不少步驟的,如:等待數據、將數據拷貝到內核空間的PageCache(若是是Buffered I/O的話)、將數據拷貝到用戶空間等。下面的幾個模型有幾個可能看起來很類似(在高級語言的環境中看,這TM不就是換了個概念從新講一次嗎),但從操做系統的角度來看他們是不一樣的。web

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

這是最基礎的I/O模型,也有人會叫它「同步阻塞I/O」,以下圖(從網卡讀取UDP數據)所示,請求數據的進程須要一直阻塞等待讀取完成才能返回,同時整個讀取的動做(這裏是recvfrom)也是要同步等待I/O操做的完成才返回。編程

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的操做。服務器

NIO

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

以下圖所示,在調用recvfrom以前先調用另一個系統調用select,當它返回時就表示咱們的數據準備好了,而後再調用recvfrom就能直接讀取到數據了。在這種場景下,整個讀取的動做(由兩個系統調用組成)是異步的,同時select動做是會一直阻塞等待I/O事件的到來。網絡

這種模式有個優勢,這裏的select每每能夠監聽不少事件,它每每是在多線程的場景下使用,好比在Java的NIO編程中,多個線程能夠向同一個Selector註冊多個事件,這樣就達到了多路複用的效果。多線程

I/O多路複用

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

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

從操做系統的角度來看,「信號驅動I/O」和#3中介紹的「多路複用」還有下面要介紹的「AIO」是有很大的不一樣的。
可是從概念上講,它們是很類似的,其餘兩種其實也能夠說是由某種信號驅動的I/O。I/O多路複用的信號是select調用的返回,AIO則是由更底層的實現來傳遞信號。
固然,還有一個區別是「數據從內核空間拷貝到用戶空間」這個動做再也不須要recvfrom等待,而是在AIO的信號到來時就已經完成。

信號驅動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這個概念。

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模式構建的服務端

一個通過改進的多線程的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)。

用Reactor來武裝本身

可能有人已經看到問題了,小紅能夠直接讓 小綠前桌1前桌2分別處理一張小紙條(方案#3),能夠達到一樣的效果啊(三張小紙條收到回覆的時間一樣是三、四、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

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

能夠看到,分發器的角色其實還在,只是集成在了老師身上了。

我知道圖太挫,先湊合着看吧

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

  1. 小紅拿到小紙條放到老師那裏,而且告訴老師讀完後通知本身,而後本身就能夠去作別的事情了(好比學習)。
  2. 老師讀完後通知小紅,小紅在小綠前桌1前桌2之中找一我的來思考回信。
  3. 思考完以後告訴後桌1去寫回信。

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

Reactor的啓發

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

啓發一,事件處理循環

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

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和微服務,,可能原本就是這樣的,咱們搞這麼多的設計模式,最佳實踐,各類花哨的術語和概念,最根本的目的仍是要寫出更好的代碼。或者……也有例外?

具體狀況具體分析
相關文章
相關標籤/搜索