BIO、NIO、NIO2、AIO、Reactor、Proactor、EventLoop、Linux五種IO模型
上述術語和概念,相信大多數人都知道或部分知道,但都無法完整表達他們之間的意思,處於模棱兩可的狀態。我也不例外,而觸發寫這篇文章的契機,是有人問起我Vertx高性能的原因是什麼時?我不假思索地回答NIO,因爲在我的印象中,Vertx基於Netty實現,Netty又是對NIO的包裝。但仔細想想,用NIO就能高性能嗎?NIO和Vertx的EventLoop有什麼關係呢?和Reactor又是什麼關係呢?——我不禁開始思考人生。
下面就開始探索吧
疑問
帶着疑問探索是最有效手段。本文專注於搞懂如下幾個問題
- Java中的各種IO概念是怎麼回事?
- Linux五種IO模型,以及他們和Java IO的關係?
- Netty和NIO?
- Java IO和Reactor設計模式的關係?
Java的IO
截止目前,Java的IO有三種:BIO、NIO、NIO2(AIO),他們被引入的時間點如下。
- JDK 1.0 - JDK1.3 Java都只有BIO,很多Unix的概念或接口在其庫中都沒有體現。
- 2002年發佈JDK 1.4,增加java.nio包,主要引入了支持異步操作的各種核心類庫:管道、緩衝區、Channel、Selector等。
- 2011年發佈JDK 1.7,對原來的NIO包進行了升級,稱爲NIO2.0,主要提供了AIO功能。
BIO
即傳統的Socket API,很多Java程序員的第一個網絡程序就是BIO。BIO的問題是數據的讀取會阻塞線程,提升性能的方式是引入多線程,而系統能夠啓動線程的數量有限,不可避免會引入線程池,而線程池又會造成併發處理請求不夠多,從而限制吞吐量。且線程切換費時費力,浪費CPU資源。因此在處理高併發網絡請求時BIO是不堪重任的。
const val PORT = 9090
fun main() {
val serverSocket = ServerSocket(PORT)
while (true) {
val socket = serverSocket.accept()
val inputStream = socket.getInputStream()
val outputStream = socket.getOutputStream()
val reader = BufferedReader(InputStreamReader(inputStream))
val writer = PrintWriter(OutputStreamWriter(outputStream))
val line = reader.readLine()
writer.println(line)
writer.flush()
writer.close()
socket.close()
}
}
NIO
Java NIO引入了多路選擇器Selector、通道Channel、緩存區ByteBuffer的概念。通過輪詢選擇器的方式獲取準備好的Channel,數據讀取均採用ByteBuffer。
在使用上我們只需要在多路選擇器上註冊感興趣的Channel,然後不斷輪訓該選擇器,每當通道就緒時,我們再處理對應的事件,可響應的事件如下
-
ACCEPT
服務端事件,表示有客戶端連接
-
CONNCET
客戶端時間,表示已經連接到服務端
-
READ
系統已將數據讀取到緩衝區,觸發該事件,我們從緩衝區讀取數據即可。
-
WRITE
系統檢查緩衝區是否可寫,如果可寫,觸發該事件,我們向緩衝區寫入即可。
典型的示例如下。與BIO相比,NIO爲我們節省了阻塞等待讀取數據的時間,數據不再是我們阻塞等待,而是交給系統讀取到執行位置(緩衝區),我們再直接從緩衝區讀取。
NIO的缺點是使用過於複雜,且存在空輪訓的bug。
注意NIO != 高性能,當連接數小於1000、併發程度不高時,NIO並沒有顯著的性能優勢。
/**
* 這裏使用了兩個Selector,其實也完全可以僅僅使用一個Selector進行輪訓
* JDK 的 NIO 底層由 epoll 實現,該實現飽受詬病的空輪詢 bug 會導致 cpu 飆升 100%
*/
fun main() {
// 用於輪詢是否有新的連接,當產生新的連接時,將新連接綁定在client selector上進行數據輪訓
val serverSelector = Selector.open()
// 輪訓連接是否有數據可讀
val clientSelector = Selector.open()
// 處理連接
Thread {
val serverChannel = ServerSocketChannel.open()
serverChannel.socket().bind(InetSocketAddress(9090))
serverChannel.configureBlocking(false)
serverChannel.register(serverSelector, SelectionKey.OP_ACCEPT)
while (true) {
if (serverSelector.select(1) > 0) {
val it = serverSelector.selectedKeys().iterator()
while (it.hasNext()) {
val key = it.next()
if (key.isAcceptable) {
val clientChannel = (key.channel() as ServerSocketChannel).accept()
clientChannel.configureBlocking(false)
clientChannel.register(clientSelector, SelectionKey.OP_READ)
}
it.remove()
}
}
}
}.start()
// 處理數據
Thread {
while (true) {
if (clientSelector.select(1) > 0) {
val it = clientSelector.selectedKeys().iterator()
while (it.hasNext()) {
val key = it.next()
if (key.isReadable) {
val clientChannel = key.channel() as SocketChannel
val byteBuffer = ByteBuffer.allocate(1024)
clientChannel.read(byteBuffer)
byteBuffer.flip()
clientChannel.write(byteBuffer)
byteBuffer.clear()
clientChannel.close()
}
it.remove()
}
}
}
}.start()
}
NIO2 (AIO)
NIO2是對NIO的一次升級,但因其主要引入了異步調用IO——AIO,因此也把NIO2稱爲AIO。對熟悉異步編程的同學,AIO的編程方式是非常自然的,它只需要開啓服務器並註冊數據處理器即可,系統負責讀取數據並調用處理器,整個過程異步。
fun main() {
val server = AsynchronousServerSocketChannel.open().bind(InetSocketAddress(9090))
val obj: CompletionHandler<AsynchronousSocketChannel, Any?> = object : CompletionHandler<AsynchronousSocketChannel, Any?> {
override fun completed(channel: AsynchronousSocketChannel, attachment: Any?) {
server.accept(null as Any?, this)
val buffer = ByteBuffer.allocate(1024)
channel.read(buffer).get(100, TimeUnit.MILLISECONDS)
buffer.flip()
channel.write(buffer)
channel.close()
}
override fun failed(exc: Throwable?, attachment: Any?) {
}
}
server.accept(null as Any?, obj)
Thread.sleep(1000000000)
}
疑問
如果只是學習Java的三種IO API,肯定會有很多疑問
- 爲什麼會有這三種API?
- 如果我們不阻塞等待數據,而讓系統做,那系統就不會阻塞CPU了嗎?還有,系統是誰?JVM還是宿主機(Linux、Window server)
這裏可以簡要回答
- 這三種API分別對應Linux的三種IO模型,Java的IO只是對操作系統IO模型的封裝。提供這三種API可以僅看成是提供了三種產品,供用戶選擇。起初Java只提供了BIO,因此在高併發網絡編程領域無法站住,提供了NIO後就提供了高併發網絡編程的支持。
- 系統不會阻塞CPU,因爲這是IO操作。以網絡IO爲例,數據讀取時,網卡負責接收信號並解析成數據,然後轉移到系統內核中,這個過程可以由硬件完成,而BIO讀取的等待,就是在等待網卡接收信號並將數據導入系統內核的時間;NIO將這一步等待從用戶程序中去除,但用戶依然負責從系統內核到用戶數據區的數據轉移;而AIO中,用戶連這一步轉義都不需要,系統直接將數據處理到一個內核和用戶數據共享的區域,通知用戶程序處理即可。
要進一步瞭解,就需要對Linux的網絡編程方式及常見IO模型進行了解
Linux的IO模型
基礎
Linux中網絡程序設計完全靠套接字接受和發送消息,Socket是一個接口,它在系統中的位置如下。
即意味着,只要在Linux系統上運行的網絡程序,其在操作系統層面的操作都是需要通過Socket進行的。通過scoket進行通訊的服務端和客戶端調用流程如下,注意其read()、write()等都是Linux提供的方法。
Linux/UNIX系統中,有如下五種IO模式
- 阻塞I/O
- 非阻塞I/O
- I/O多路複用
- 信號驅動I/O(SIGIO)
- 異步I/O
對於一個套接字的輸入操作,總的來說一般分爲兩步。這是大前提,五種IO模式都是在這上面做文章的。
- 等待數據從網絡到達本地,當數據到達後,系統將數據從網絡層拷貝到內核的緩存
- 將數據從內核的緩存拷貝到應用程序的數據區中
阻塞I/O
缺省模式,一個socket建立後自動處於阻塞I/O模式。如下圖,阻塞I/O大致流程爲
- 調用recvfrom發起數據接收
- 內核尚未收到數據,於是阻塞等待
- 內核收到數據(數據到了內核緩存),將數據從內核緩存拷貝到應用程序數據區
- 拷貝完成,recvfrom返回,應用程序處理數據
非阻塞I/O
設置爲此模式後,相當於告訴內核:當我請求的IO不能馬上返回時,不要讓我的進程進入休眠,而是返回一個錯誤給我。而爲了能夠及時收到數據,應用程序需要循環調用recevfrom來測試一個文件描述符(創建socket時生成)是否有數據可讀。這稱作polling。應用程序不停滴polling是一個浪費CPU的操作,因此這種模式不是很普遍。
- 調用recvfrom發起數據接收
- 內核尚未收到數據,響應應用程序EWOULDBLOCK錯誤,而內核自己則繼續等待數據
- 多次調用recvfrom詢問內核數據是否準備好。
- 當數據終於準備好時,內核將數據拷貝到應用程序數據區,返回recvfrom
- 應用程序處理數據
I/O多路複用
此模式下,在開始接收數據之前,我們不是調用recvfrom函數,而是調用select函數或poll函數,當他們有響應時,再調用recvfrom接收數據,調用select函數時也會阻塞,但它的優點在於,能夠同時等待多個文件描述符,只要有一個準備好了,select就會返回。I/O多路複用經常被使用。
-
調用select,阻塞等待直到有文件描述符的數據就緒
-
有就緒的文件描述符,select返回,應用程序調用recvfrom接收數據
-
內核將數據拷貝到應用程序數據區,返回recvfrom
-
應用程序處理數據
-
關於select
select函數可以同時監視多個套接字,它能夠告訴你哪一個套接字已經可以讀取數據、哪一個套接字已經可以寫入數據、哪一個套接字出現了錯誤等。
-
epoll
select和poll的使用有較大的侷限性,無法滿足併發量特別高的情況,epoll是對他們的增強。增強的原理這裏不深究。
信號驅動I/O
該模式將內核等待數據這段時間變主動爲被動,在數據就緒時使用SIGIO(或SIGPOLL)信號通知我們。
使用方法上,讓套接字工作在信號驅動I/O工作模式中,並安裝一個SIGIO處理函數。這樣在內核等待數據期間我們就是完全異步的情況了。只需要在SIGIO處理函數中接收數據處理即可。
- 創建套接字,允許工作在信號驅動模式,並註冊SIGIO信號處理函數
- 內核數據就緒後,響應SIGIO信號
- 事先註冊的SIGIO處理函數中調用recvfrom函數
- 內核將數據拷貝到應用程序數據區,返回recvfrom
- 應用程序處理數據
信號驅動I/O的編程有一個最大的難點是除了數據就緒外,還有很多觸發SIGIO信號的場景,區分這些場景是難點。
異步I/O
異步I/O模式下,我們只需要告訴內核我們要進行I/O操作,然後內核馬上返回,具體的I/O操作和數據拷貝全部由內核完成,完成後再通知我們的應用程序。與信號驅動I/O所不同的是,這次不僅在等待數據階段是異步的,連內核數據拷貝都是異步的。
- 創建套接字,工作在異步I/O模式,指定套接字文件描述符、數據需要拷貝到的緩衝區、回調函數等,不需要等待,馬上返回
- 內核負責等待數據病將數據從內核緩衝區拷貝到應用程序數據區
- 內核拷貝結束後,回調第一步註冊的函數,完成應用程序的數據處理
五種模式總結
前四種模式都有阻塞的地方——將數據從內核拷貝到應用程序數據區,只有第五種是完全異步的。
小結
Java的IO可以看做是對宿主系統的IO模型的封裝,BIO對應了阻塞IO模型、NIO對應IO多路複用、AIO則對應了異步IO模型。通過了解Linux的五種IO模型,我們學習了Java的IO從哪裏來及基本原理,算是解決了底層原理這個疑問。接下來我們看看Java IO的使用。
Netty和NIO
Netty是Java非常流行的網絡庫,基於Java NIO實現,Vertx本身包括衆多異步庫都基於Netty實現,這裏進行簡單瞭解。
爲什麼使用Netty
儘管Java已經提供了NIO類庫,但實際的網絡編程環境非常複雜,NIO並沒有完全屏蔽平臺差異,它仍然是基於各個操作系統的I/O系統實現的,差異仍然存在。使用NIO做網絡編程構建事件驅動模型並不容易,陷阱重重。要開發出一個穩定可用的網絡程序使用NIO的週期將會非常長,而Netty封裝了各種IO實現,提供簡單的API,工作穩定,適用於各種場景。
順便一提,Java的NIO庫存在空輪詢的bug:
原本select()方法應該是阻塞的,但JDK的select()方法在一些情況下會在沒有事件時返回。造成在死循環中空轉,使得CPU達到100%的情況。該bug到2013年才修復
Netty中的解決方式是在短時間內檢測到超過一定數量的select()調用,就判定爲空轉。通過創建新的Selector並將原Selector中的Channel註冊到新的Selector達到消除這個問題的目的。
爲什麼不用AIO
通過上面的模型講解,看起來AIO比NIO在模型上要更加先進,那爲什麼Netty不基於AIO實現呢?從Netty的這個Issue看,總結起來有幾點
- Netty主要注重Linux,而在Linux上,AIO的底層實現仍然採用EPOLL,性能上沒有明顯優勢,而且既然都採用EPOLL,還不如直接使用NIO方便自定義優化
- Netty的線程模型,使用AIO看起來會非常雜亂
- AIO在使用前必須預先分配緩衝區,高併發連接時不好優化
當然上面幾點只是我隨他人附和,並沒有真正理解,如有需要,可以再詳細瞭解並新開一篇博文描述。
Netty如何使用NIO
通過不同的配置,Netty能夠支持Reactor單線程模式、Reactor多線程模式、主從Reactor多線程模式
這裏暫時沒有必要去糾結單線程還是多線程,只需要關心Reactor模式。在Netty中,它正是基於NIO的多路複用實現的。
Netty的核心NioEventLoop就是基於NIO的多路複用實現的,除了NIO外,它還兼顧處理兩個任務
- 用戶通過NioEventLoop.execute方法註冊的事件放在消息隊列中,由I/O線程執行
- 用戶通過NioEventLoop.schedule方法註冊的定時任務
他們的簡單原理就是在一個死循環內反覆輪詢Selector、消息隊列、定時任務。即,Netty基於NIO構建了自己的EventLoop。
小結
這裏僅從概念上簡單介紹了Netty,沒有一點深入瞭解,其目的僅在於讓我們瞭解NIO和Netty是如何結合的。
EventLoop
Event Loop是一個程序結構,用於等待和發送消息和事件。
其實,簡單地理解EventLoop,就是一個反覆定時輪詢檢查事件隊列,並在事件發生時將事件分發到對應的handler中的一個工具。算是一種編程模型。
維基百科對EventLoop的講解可以說是非常清楚的:
事件循環是一種等待和分發程序中事件或消息的編程結構或設計模式。實際工作上,它通過向事件提供程序(如消息隊列、NIO的Selector)發出請求以獲取事件,然後調用對應的事件處理程序進行工作。因此它有時也被稱爲消息分發程序。
事件循環可以和Reactor模式相結合,這就是我們常用的NIO編程。
從原理上說,我們可以自己提供事件消息隊列,但一般來說這個消息隊列是由對應的運行環境(操作系統)提供的,比如Java的NIO,這樣可以將I/O放到系統中進行,避免阻塞工作線程。
事件循環是基於消息傳輸的系統的典型技術特徵。
EventLoop和NIO的關係
NIO的Selector對應EventLoop模型中的事件提供程序,即事件源,即可以基於NIO構建一個EventLoop程序。如Netty的EventLoop模型,其事件源包括了NIO Selector,也包括了自定義任務隊列和定時任務隊列。
Reactor Proactor
Reactor
Reactor是一種設計模式,是一種事件處理模式。IO多路複用就是Reactor模式的一種實現。
它要求存在一個處理結構,接收併發的多個輸入請求,並將這些請求同步分發到關聯的請求處理handler的情況。
一個反應器的基本結構如下
-
資源
可以是向系統發送消息的請求、也可以是從系統獲取消息的請求
-
同步的事件複用器。
用一個EventLoop阻塞地等待所有資源(即請求),比如I/O多路複用模型中的epoll,當資源準備好時EventLoop將資源發送給調度器
-
Dispatcher分發器
用於管理請求處理器的註冊和註銷。同時將從複用器中得到的資源分發給對應的請求處理器
-
請求處理器
定義了資源的處理邏輯
所有的Reacto系統在定義上將都是單線程的,但改良後的可以是多線程的,比如Netty中就對Reactor進行了多線程改進,使得能夠發揮最大性能。
實際實現中的Reactor模型常常被用來解決IO併發問題,最常見的就是I/O多路複用。死循環阻塞等待select()就是EventLoop,有事件時調用對應於分發器。
在I/O上,這樣能夠提高I/O高併發的效率;在編程模型上,它將事件的處理邏輯完全分開。
當然,Reactor並不是只能用於I/O,就像Netty在EventLoop加入用戶自定義的task和定時task、Vertx基於EventLoop構建自己的神經系統一樣,它也可以用來處理事件請求和處理分離的模式,在運行效率和編程效率上都有所提升。
Proactor
Proactor也是用於事件處理的設計模式,它可以被看做是同步的Reactor模式的變體。它將需要長時間運行的操作異步執行,在執行完後調用對應的處理器進行結果處理即可。異步I/O就是Proactor模式的一種實現。
模型上的結構和工作流程如下
在實際應用中,模型中的Asynchronous Operation Processor、Asynchronous Operation、Completion Dispatcher一般依賴於操作系統完成,我們負責發起異步調用和註冊完成處理函數。比如LInux的異步IO模型。我們能做的只是發起I/O請求、指明數據要存放的位置、註冊處理函數,待系統異步處理完I/O請求後,調用註冊的處理函數。
總結
本文以Java IO爲切入點,介紹了其在Linux上對應IO模型,使得大家瞭解了底層工作原理;再以NIO爲基礎,介紹了流行的網絡框架——Netty;由Netty中的一些概念,介紹了EventLoop、Reactor、Proactor三種編程模型和設計模式。使得大家對這一系列概念的聯繫有了較爲完整的認識。
需要注意的是,Java的IO針對不同的系統有不同的具體實現,實際使用過程中存在差異,本文僅介紹了其在Linux中對應的模型,在其它系統中可能會有所不同,這點需要了解。
最後,本文草稿可以在這裏找到,可以看到資料收集的過程。
不足之處,還請評論指出,謝謝。
參考資料
- Linux網絡編程 - 第六章(書籍)
- Netty權威指南 - 第一、二、十八章(書籍)
- Java NIO BIO AIO簡單總結 - 佚名
- Java NIO淺析 - 美團技術團隊
- 什麼是EventLoop - 阮一峯
- Reactor pattern - Wikipedia
- Proactor pattern - Wikipedia
博文預告
《Vertx Core源碼解析 - 1》