也谈IO模型
前言
說到IO模型,都會牽扯到同步、異步、阻塞、非阻塞這幾個詞。從詞的表面上看,很多人都覺得很容易理解。但是細細一想,卻總會發(fā)現(xiàn)有點摸不著頭腦。自己也曾被這幾個詞弄的迷迷糊糊的,每次看相關(guān)資料弄明白了,然后很快又給搞混了。經(jīng)歷過這么幾次之后,發(fā)現(xiàn)這東西必須得有所總結(jié)提煉才不至于再次混為一談。尤其是最近看到好幾篇講這個的文章,很多都有謬誤,很容易把本來就搞不清楚的人弄的更加迷糊。
最適合IO模型的例子應(yīng)該是咱們平常生活中的去餐館吃飯這個場景,下文就結(jié)合這個來講解一下經(jīng)典的幾個IO模型。在此之前,先需要說明以下幾點:
- IO有內(nèi)存IO、網(wǎng)絡(luò)IO和磁盤IO三種,通常我們說的IO指的是后兩者。
- 阻塞和非阻塞,是函數(shù)/方法的實現(xiàn)方式,即在數(shù)據(jù)就緒之前是立刻返回還是等待。
- 以文件IO為例,一個IO讀過程是文件數(shù)據(jù)從磁盤→內(nèi)核緩沖區(qū)→用戶內(nèi)存的過程。同步與異步的區(qū)別主要在于數(shù)據(jù)從內(nèi)核緩沖區(qū)→用戶內(nèi)存這個過程需不需要用戶進程等待。(網(wǎng)絡(luò)IO把磁盤換做網(wǎng)卡即可)
IO模型
同步阻塞
去餐館吃飯,點一個自己最愛吃的蓋澆飯,然后在原地等著一直到蓋澆飯做好,自己端到餐桌就餐。這就是典型的同步阻塞。當(dāng)廚師給你做飯的時候,你需要一直在那里等著。
網(wǎng)絡(luò)編程中,讀取客戶端的數(shù)據(jù)需要調(diào)用recvfrom。在默認情況下,這個調(diào)用會一直阻塞直到數(shù)據(jù)接收完畢,就是一個同步阻塞的IO方式。這也是最簡單的IO模型,在通常fd較少、就緒很快的情況下使用是沒有問題的。
同步非阻塞
接著上面的例子,你每次點完飯就在那里等著,突然有一天你發(fā)現(xiàn)自己真傻。于是,你點完之后,就回桌子那里坐著,然后估計差不多了,就問老板飯好了沒,如果好了就去端,沒好的話就等一會再去問,依次循環(huán)直到飯做好。這就是同步非阻塞。
這種方式在編程中對socket設(shè)置O_NONBLOCK即可。但此方式僅僅針對網(wǎng)絡(luò)IO有效,對磁盤IO并沒有作用。因為本地文件IO就沒有被認為是阻塞,我們所說的網(wǎng)絡(luò)IO的阻塞是因為網(wǎng)路IO有無限阻塞的可能,而本地文件除非是被鎖住,否則是不可能無限阻塞的,因此只有鎖這種情況下,O_NONBLOCK才會有作用。而且,磁盤IO時要么數(shù)據(jù)在內(nèi)核緩沖區(qū)中直接可以返回,要么需要調(diào)用物理設(shè)備去讀取,這時候進程的其他工作都需要等待。因此,后續(xù)的IO復(fù)用和信號驅(qū)動IO對文件IO也是沒有意義的。
此外,需要說明的一點是nginx和node中對于本地文件的IO是用線程的方式模擬非阻塞的效果的,而對于靜態(tài)文件的io,使用zero copy(例如sendfile)的效率是非常高的。
IO復(fù)用
接著上面的列子,你點一份飯然后循環(huán)的去問好沒好顯然有點得不償失,還不如就等在那里直到準(zhǔn)備好,但是當(dāng)你點了好幾樣飯菜的時候,你每次都去問一下所有飯菜的狀態(tài)(未做好/已做好)肯定比你每次阻塞在那里等著好多了。當(dāng)然,你問的時候是需要阻塞的,一直到有準(zhǔn)備好的飯菜或者你等的不耐煩(超時)。這就引出了IO復(fù)用,也叫多路IO就緒通知。這是一種進程預(yù)先告知內(nèi)核的能力,讓內(nèi)核發(fā)現(xiàn)進程指定的一個或多個IO條件就緒了,就通知進程。使得一個進程能在一連串的事件上等待。
IO復(fù)用的實現(xiàn)方式目前主要有select、poll和epoll。
select和poll的原理基本相同:
- 注冊待偵聽的fd(這里的fd創(chuàng)建時最好使用非阻塞)
- 每次調(diào)用都去檢查這些fd的狀態(tài),當(dāng)有一個或者多個fd就緒的時候返回
- 返回結(jié)果中包括已就緒和未就緒的fd
相比select,poll解決了單個進程能夠打開的文件描述符數(shù)量有限制這個問題:select受限于FD_SIZE的限制,如果修改則需要修改這個宏重新編譯內(nèi)核;而poll通過一個pollfd數(shù)組向內(nèi)核傳遞需要關(guān)注的事件,避開了文件描述符數(shù)量限制。
此外,select和poll共同具有的一個很大的缺點就是包含大量fd的數(shù)組被整體復(fù)制于用戶態(tài)和內(nèi)核態(tài)地址空間之間,開銷會隨著fd數(shù)量增多而線性增大。
select和poll就類似于上面說的就餐方式。但當(dāng)你每次都去詢問時,老板會把所有你點的飯菜都輪詢一遍再告訴你情況,當(dāng)大量飯菜很長時間都不能準(zhǔn)備好的情況下是很低效的。于是,老板有些不耐煩了,就讓廚師每做好一個菜就通知他。這樣每次你再去問的時候,他會直接把已經(jīng)準(zhǔn)備好的菜告訴你,你再去端。這就是事件驅(qū)動IO就緒通知的方式-epoll。
epoll的出現(xiàn),解決了select、poll的缺點:
- 基于事件驅(qū)動的方式,避免了每次都要把所有fd都掃描一遍。
- epoll_wait只返回就緒的fd。
- epoll使用nmap內(nèi)存映射技術(shù)避免了內(nèi)存復(fù)制的開銷。
- epoll的fd數(shù)量上限是操作系統(tǒng)的最大文件句柄數(shù)目,這個數(shù)目一般和內(nèi)存有關(guān),通常遠大于1024。
目前,epoll是Linux2.6下最高效的IO復(fù)用方式,也是Nginx、Node的IO實現(xiàn)方式。而在freeBSD下,kqueue是另一種類似于epoll的IO復(fù)用方式。
此外,對于IO復(fù)用還有一個水平觸發(fā)和邊緣觸發(fā)的概念:
- 水平觸發(fā):當(dāng)就緒的fd未被用戶進程處理后,下一次查詢依舊會返回,這是select和poll的觸發(fā)方式。
- 邊緣觸發(fā):無論就緒的fd是否被處理,下一次不再返回。理論上性能更高,但是實現(xiàn)相當(dāng)復(fù)雜,并且任何意外的丟失事件都會造成請求處理錯誤。epoll默認使用水平觸發(fā),通過相應(yīng)選項可以使用邊緣觸發(fā)。
信號驅(qū)動
上文的就餐方式還是需要你每次都去問一下飯菜狀況。于是,你再次不耐煩了,就跟老板說,哪個飯菜好了就通知我一聲吧。然后就自己坐在桌子那里干自己的事情。更甚者,你可以把手機號留給老板,自己出門,等飯菜好了直接發(fā)條短信給你。這就類似信號驅(qū)動的IO模型。
流程如下:
- 開啟套接字信號驅(qū)動IO功能
- 系統(tǒng)調(diào)用sigaction執(zhí)行信號處理函數(shù)(非阻塞,立刻返回)
- 數(shù)據(jù)就緒,生成sigio信號,通過信號回調(diào)通知應(yīng)用來讀取數(shù)據(jù)。
異步非阻塞
之前的就餐方式,到最后總是需要你自己去把飯菜端到餐桌。這下你也不耐煩了,于是就告訴老板,能不能飯好了直接端到你的面前或者送到你的家里(外賣)。這就是異步非阻塞IO了。
對比信號驅(qū)動IO,異步IO的主要區(qū)別在于:信號驅(qū)動由內(nèi)核告訴我們何時可以開始一個IO操作(數(shù)據(jù)在內(nèi)核緩沖區(qū)中),而異步IO則由內(nèi)核通知IO操作何時已經(jīng)完成(數(shù)據(jù)已經(jīng)在用戶空間中)。
異步IO又叫做事件驅(qū)動IO,在Unix中,POSIX1003.1標(biāo)準(zhǔn)為異步方式訪問文件定義了一套庫函數(shù),定義了AIO的一系列接口。使用aio_read或者aio_write發(fā)起異步IO操作。使用aio_error檢查正在運行的IO操作的狀態(tài)。
網(wǎng)絡(luò)編程模型
上文講述了UNIX環(huán)境的五種IO模型。基于這五種模型,在Java中,隨著NIO和NIO2.0(AIO)的引入,一般具有以下幾種網(wǎng)絡(luò)編程模型:
- BIO
- NIO
- AIO
BIO
BIO是一個典型的網(wǎng)絡(luò)編程模型,是通常我們實現(xiàn)一個服務(wù)端程序的過程,步驟如下:
- 主線程accept請求阻塞
- 請求到達,創(chuàng)建新的線程來處理這個套接字,完成對客戶端的響應(yīng)。
- 主線程繼續(xù)accept下一個請求
這種模型有一個很大的問題是:當(dāng)客戶端連接增多時,服務(wù)端創(chuàng)建的線程也會暴漲,系統(tǒng)性能會急劇下降。因此,在此模型的基礎(chǔ)上,類似于 tomcat的bio connector,采用的是線程池來避免對于每一個客戶端都創(chuàng)建一個線程。有些地方把這種方式叫做偽異步IO(把請求拋到線程池中異步等待處理)。
NIO
JDK1.4開始引入了NIO類庫,這里的NIO指的是Non-blcok IO,主要是使用Selector多路復(fù)用器來實現(xiàn)。Selector在Linux等主流操作系統(tǒng)上是通過epoll實現(xiàn)的。
NIO的實現(xiàn)流程,類似于select:
- 創(chuàng)建ServerSocketChannel監(jiān)聽客戶端連接并綁定監(jiān)聽端口,設(shè)置為非阻塞模式。
- 創(chuàng)建Reactor線程,創(chuàng)建多路復(fù)用器(Selector)并啟動線程。
- 將ServerSocketChannel注冊到Reactor線程的Selector上。監(jiān)聽accept事件。
- Selector在線程run方法中無線循環(huán)輪詢準(zhǔn)備就緒的Key。
- Selector監(jiān)聽到新的客戶端接入,處理新的請求,完成tcp三次握手,建立物理連接。
- 將新的客戶端連接注冊到Selector上,監(jiān)聽讀操作。讀取客戶端發(fā)送的網(wǎng)絡(luò)消息。
- 客戶端發(fā)送的數(shù)據(jù)就緒則讀取客戶端請求,進行處理。
相比BIO,NIO的編程非常復(fù)雜。
AIO
JDK1.7引入NIO2.0,提供了異步文件通道和異步套接字通道的實現(xiàn),是真正的異步非阻塞IO, 對應(yīng)于Unix中的異步IO。
- 創(chuàng)建AsynchronousServerSocketChannel,綁定監(jiān)聽端口
- 調(diào)用AsynchronousServerSocketChannel的accpet方法,傳入自己實現(xiàn)的CompletionHandler。包括上一步,都是非阻塞的
- 連接傳入,回調(diào)CompletionHandler的completed方法,在里面,調(diào)用AsynchronousSocketChannel的read方法,傳入負責(zé)處理數(shù)據(jù)的CompletionHandler。
- 數(shù)據(jù)就緒,觸發(fā)負責(zé)處理數(shù)據(jù)的CompletionHandler的completed方法。繼續(xù)做下一步處理即可。
- 寫入操作類似,也需要傳入CompletionHandler。
其編程模型相比NIO有了不少的簡化。
對比
| 客戶端數(shù)目 :IO線程 | 1 : 1 | m : n | m : 1 | m : 0 |
| IO模型 | 同步阻塞IO | 同步阻塞IO | 同步非阻塞IO | 異步非阻塞IO |
| 吞吐量 | 低 | 中 | 高 | 高 |
| 編程復(fù)雜度 | 簡單 | 簡單 | 非常復(fù)雜 | 復(fù)雜 |
參考資料
- 構(gòu)建高性能Web站點
- Netty權(quán)威指南
總結(jié)
- 上一篇: 每个程序员都必读的10篇文章
- 下一篇: Mybatis的ResultMap的使用