IO模型

前言

說到IO模型,都會牽扯到同步、異步、阻塞、非阻塞這幾個詞。從詞的表面上看,很多人都覺得很容易理解。但是細細一想,卻總會發現有點摸不着頭腦。自己也曾被這幾個詞弄的迷迷糊糊的,每次看相關資料弄明白了,然後很快又給搞混了。經歷過這麼幾次之後,發現這東西必須得有所總結提煉才不至於再次混爲一談。尤其是最近看到好幾篇講這個的文章,很多都有謬誤,很容易把本來就搞不清楚的人弄的更加迷糊。

最適合IO模型的例子應該是咱們平常生活中的去餐館吃飯這個場景,下文就結合這個來講解一下經典的幾個IO模型。在此之前,先需要說明以下幾點:

  • IO有內存IO、網絡IO和磁盤IO三種,通常我們說的IO指的是後兩者。

  • 阻塞和非阻塞,是函數/方法的實現方式,即在數據就緒之前是立刻返回還是等待,即發起IO請求是否會被阻塞。

  • 以文件IO爲例,一個IO讀過程是文件數據從磁盤→內核緩衝區→用戶內存的過程。同步與異步的區別主要在於數據從內核緩衝區→用戶內存這個過程需不需要用戶進程等待,即實際的IO讀寫是否阻塞請求進程。(網絡IO把磁盤換做網卡即可)

 

IO模型

同步阻塞

網絡編程中,讀取客戶端的數據需要調用recvfrom。在默認情況下,這個調用會一直阻塞直到數據接收完畢,就是一個同步阻塞的IO方式。這也是最簡單的IO模型,在通常fd較少、就緒很快的情況下使用

uploading.4e448015.gif轉存失敗重新上傳取消

read爲例:

(1)進程發起read,進行recvfrom系統調用;

(2)內核開始第一階段,準備數據(從磁盤拷貝到緩衝區),進程請求的數據並不是一下就能準備好;準備數據是要消耗時間的;

(3)與此同時,進程阻塞(進程是自己選擇阻塞與否),等待數據ing;

(4)直到數據從內核拷貝到了用戶空間,內核返回結果,進程解除阻塞。

也就是說,內核準備數據和數據從內核拷貝到進程內存地址這兩個過程都是阻塞的。

同步非阻塞

這種方式在編程中對socket設置O_NONBLOCK即可。但此方式僅僅針對網絡IO有效,對磁盤IO並沒有作用。因爲本地文件IO就沒有被認爲是阻塞,我們所說的網絡IO的阻塞是因爲網路IO有無限阻塞的可能,而本地文件除非是被鎖住,否則是不可能無限阻塞的,因此只有鎖這種情況下,O_NONBLOCK纔會有作用。而且,磁盤IO時要麼數據在內核緩衝區中直接可以返回,要麼需要調用物理設備去讀取,這時候進程的其他工作都需要等待。因此,後續的IO複用和信號驅動IO對文件IO也是沒有意義的。

uploading.4e448015.gif轉存失敗重新上傳取消

  (1)當用戶進程發出read操作時,如果kernel中的數據還沒有準備好;

  (2)那麼它並不會block用戶進程,而是立刻返回一個error,從用戶進程角度講 ,它發起一個read操作後,並不需要等待,而是馬上就得到了一個結果;

  (3)用戶進程判斷結果是一個error時,它就知道數據還沒有準備好,於是它可以再次發送read操作。一旦kernel中的數據準備好了,並且又再次收到了用戶進程的system call;

  (4)那麼它馬上就將數據拷貝到了用戶內存,然後返回。

  所以,nonblocking IO的特點是用戶進程在內核準備數據的階段需要不斷的主動詢問數據好了沒有。

IO複用

uploading.4e448015.gif轉存失敗重新上傳取消

  (1)當用戶進程調用了select,那麼整個進程會被block;

      (2)而同時,kernel會“監視”所有select負責的socket;

  (3)當任何一個socket中的數據準備好了,select就會返回;

  (4)這個時候用戶進程再調用read操作,將數據從kernel拷貝到用戶進程。

  所以,I/O 多路複用的特點是通過一種機制一個進程能同時等待多個文件描述符,而這些文件描述符(套接字描述符)其中的任意一個進入讀就緒狀態,select()函數就可以返回。

  這個圖和blocking IO的圖其實並沒有太大的不同,事實上,還更差一些。因爲這裏需要使用兩個system call (select 和 recvfrom),而blocking IO只調用了一個system call (recvfrom)。但是,用select的優勢在於它可以同時處理多個connection。

  所以,如果處理的連接數不是很高的話,使用select/epoll的web server不一定比使用多線程 + 阻塞 IO的web server性能更好,可能延遲還更大。

  select/epoll的優勢並不是對於單個連接能處理得更快,而是在於能處理更多的連接。)

IO複用的實現方式目前主要有select、poll和epoll。

select和poll的原理基本相同:

  • 註冊待偵聽的fd(這裏的fd創建時最好使用非阻塞)

  • 每次調用都去檢查這些fd的狀態,當有一個或者多個fd就緒的時候返回

  • 返回結果中包括已就緒和未就緒的fd

相比select,poll解決了單個進程能夠打開的文件描述符數量有限制這個問題:select受限於FD_SIZE的限制,如果修改則需要修改這個宏重新編譯內核;而poll通過一個pollfd數組向內核傳遞需要關注的事件,避開了文件描述符數量限制。

此外,select和poll共同具有的一個很大的缺點就是包含大量fd的數組被整體複製於用戶態和內核態地址空間之間,開銷會隨着fd數量增多而線性增大。

select和poll就類似於上面說的就餐方式。但當你每次都去詢問時,老闆會把所有你點的飯菜都輪詢一遍再告訴你情況,當大量飯菜很長時間都不能準備好的情況下是很低效的。於是,老闆有些不耐煩了,就讓廚師每做好一個菜就通知他。這樣每次你再去問的時候,他會直接把已經準備好的菜告訴你,你再去端。這就是事件驅動IO就緒通知的方式-epoll。

epoll的出現,解決了select、poll的缺點:

  • 基於事件驅動的方式,避免了每次都要把所有fd都掃描一遍。

  • epoll_wait只返回就緒的fd。

  • epoll使用nmap內存映射技術避免了內存複製的開銷。

  • epoll的fd數量上限是操作系統的最大文件句柄數目,這個數目一般和內存有關,通常遠大於1024。

目前,epoll是Linux2.6下最高效的IO複用方式,也是Nginx、Node的IO實現方式。而在freeBSD下,kqueue是另一種類似於epoll的IO複用方式。

此外,對於IO複用還有一個水平觸發和邊緣觸發的概念:

  • 水平觸發:當就緒的fd未被用戶進程處理後,下一次查詢依舊會返回,這是select和poll的觸發方式。

  • 邊緣觸發:無論就緒的fd是否被處理,下一次不再返回。理論上性能更高,但是實現相當複雜,並且任何意外的丟失事件都會造成請求處理錯誤。epoll默認使用水平觸發,通過相應選項可以使用邊緣觸發。

信號驅動

上文的就餐方式還是需要你每次都去問一下飯菜狀況。於是,你再次不耐煩了,就跟老闆說,哪個飯菜好了就通知我一聲吧。然後就自己坐在桌子那裏幹自己的事情。更甚者,你可以把手機號留給老闆,自己出門,等飯菜好了直接發條短信給你。這就類似信號驅動的IO模型。

uploading.4e448015.gif轉存失敗重新上傳取消

流程如下:

  • 開啓套接字信號驅動IO功能

  • 系統調用sigaction執行信號處理函數(非阻塞,立刻返回)

  • 數據就緒,生成sigio信號,通過信號回調通知應用來讀取數據。

此種io方式存在的一個很大的問題:Linux中信號隊列是有限制的,如果超過這個數字問題就無法讀取數據。

異步非阻塞

之前的就餐方式,到最後總是需要你自己去把飯菜端到餐桌。這下你也不耐煩了,於是就告訴老闆,能不能飯好了直接端到你的面前或者送到你的家裏(外賣)。這就是異步非阻塞IO了。

uploading.4e448015.gif轉存失敗重新上傳取消

對比信號驅動IO,異步IO的主要區別在於:信號驅動由內核告訴我們何時可以開始一個IO操作(數據在內核緩衝區中),而異步IO則由內核通知IO操作何時已經完成(數據已經在用戶空間中)。

異步IO又叫做事件驅動IO,在Unix中,POSIX1003.1標準爲異步方式訪問文件定義了一套庫函數,定義了AIO的一系列接口。使用aio_read或者aio_write發起異步IO操作,使用aio_error檢查正在運行的IO操作的狀態。但是其實現沒有通過內核而是使用了多線程阻塞。此外,還有Linux自己實現的Native AIO,依賴兩個函數:io_submit和io_getevents,雖然io是非阻塞的,但仍需要主動去獲取讀寫的狀態。

需要特別注意的是:AIO是I/O處理模式,是一種接口標準,各家操作系統可以實現也可以不實現。目前Linux中AIO的內核實現只對文件IO有效,如果要實現真正的AIO,需要用戶自己來實現。

網絡編程模型

上文講述了UNIX環境的五種IO模型。基於這五種模型,在Java中,隨着NIO和NIO2.0(AIO)的引入,一般具有以下幾種網絡編程模型:

  • BIO

  • NIO

  • AIO

BIO

BIO是一個典型的網絡編程模型,是通常我們實現一個服務端程序的過程,步驟如下:

  • 主線程accept請求阻塞

  • 請求到達,創建新的線程來處理這個套接字,完成對客戶端的響應。

  • 主線程繼續accept下一個請求

這種模型有一個很大的問題是:當客戶端連接增多時,服務端創建的線程也會暴漲,系統性能會急劇下降。因此,在此模型的基礎上,類似於 tomcat的bio connector,採用的是線程池來避免對於每一個客戶端都創建一個線程。有些地方把這種方式叫做僞異步IO(把請求拋到線程池中異步等待處理)。

NIO

JDK1.4開始引入了NIO類庫,這裏的NIO指的是Non-blcok IO,主要是使用Selector多路複用器來實現。Selector在Linux等主流操作系統上是通過epoll實現的。

NIO的實現流程,類似於select:

  • 創建ServerSocketChannel監聽客戶端連接並綁定監聽端口,設置爲非阻塞模式。

  • 創建Reactor線程,創建多路複用器(Selector)並啓動線程。

  • 將ServerSocketChannel註冊到Reactor線程的Selector上。監聽accept事件。

  • Selector在線程run方法中無線循環輪詢準備就緒的Key。

  • Selector監聽到新的客戶端接入,處理新的請求,完成tcp三次握手,建立物理連接。

  • 將新的客戶端連接註冊到Selector上,監聽讀操作。讀取客戶端發送的網絡消息。

  • 客戶端發送的數據就緒則讀取客戶端請求,進行處理。

相比BIO,NIO的編程非常複雜。

AIO

JDK1.7引入NIO2.0,提供了異步文件通道和異步套接字通道的實現。其底層在windows上是通過IOCP,在Linux上是通過epoll來實現的(LinuxAsynchronousChannelProvider.java,UnixAsynchronousServerSocketChannelImpl.java)。

  • 創建AsynchronousServerSocketChannel,綁定監聽端口

  • 調用AsynchronousServerSocketChannel的accpet方法,傳入自己實現的CompletionHandler。包括上一步,都是非阻塞的

  • 連接傳入,回調CompletionHandler的completed方法,在裏面,調用AsynchronousSocketChannel的read方法,傳入負責處理數據的CompletionHandler。

  • 數據就緒,觸發負責處理數據的CompletionHandler的completed方法。繼續做下一步處理即可。

  • 寫入操作類似,也需要傳入CompletionHandler。

其編程模型相比NIO有了不少的簡化。

對比

 

.

同步阻塞IO

僞異步IO

NIO

AIO

客戶端數目 :IO線程

1 : 1

m : n

m : 1

m : 0

IO模型

同步阻塞IO

同步阻塞IO

同步非阻塞IO

異步非阻塞IO

吞吐量

編程複雜度

簡單

簡單

非常複雜

複雜

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章