IO模型
用一幅圖表示所支持的I/O模型

縱向維度是“阻塞(Blocking)”、“非阻塞(Non-blocking)”;橫向維度是“同步”、“異步”??偨Y起來是四種模型同步阻塞、同步非阻塞;異步阻塞、異步非阻塞?!禪nix網絡編程》中劃分出了“第五種”模型——“信號驅動式IO”其實屬于異步阻塞類型,這種模型的通知方式有多種多樣后面展開說明。
同步/異步、阻塞/非阻塞
從內核角度看I/O操作分為兩步:用戶層API調用;內核層完成系統調用(發起I/O請求)。所以“異步/同步”的是指API調用;“阻塞/非阻塞”是指內核完成I/O調用的模式。用一幅圖表示更加明顯

同步是指函數完成之前會一直等待;阻塞是指系統調用的時候進程會被設置為Sleep狀態直到等待的事件發生(比如有新的數據)。明白這一點之后再看這五種模型相信就會清晰很多,我們挨個分析:
同步阻塞
這種模型最為常見,用戶空間調用API(read
、write
)會轉化成一個I/O請求,一直等到I/O請求完成API調用才會完成。這意味著:在API調用期間用戶程序是同步的的;這個API調用會導致系統以阻塞的模式執行I/O,如果此時沒有數據則一直“等待”(放棄CPU主動掛起——Sleep狀態)(注意,對于硬盤來說是不會出現阻塞的,無論是什么時候讀它總是有數據。常見的阻塞設備是終端、網卡之類的)。

以read
為例子,它由三個參數組成,第一個函數是文件描述符;第二個是應用緩沖;第三個參數是需要讀取的字節數。經過系統調用會以阻塞模式執行I/O,I/O模塊讀取數據后會放入到PageCache中;最后一步是把數據從PageCache復制到應用緩沖。如果I/O請求無法得到滿足——沒有數據,則主動讓出CPU直到有數據(注意,即便系統調用讓出CPU也未必真的就讓出。read函數是同步的,所以CPU還是會被用戶空間代碼占用)。
同步非阻塞
這種模式通過調用read
、write
的時候指定O_NONBLOCK
參數。和“同步阻塞”模式的區別在于系統調用的時候它是以非阻塞的方式執行,無論是否有數據都會立即返回。以read
為例,如果成功讀取到數據它返回讀取到的字節數;如果此時沒有數據則返回-1,同時設置errno為EAGAIN(或者EWOULDBLOCK,二者相同)。所以這種模式下我們一般會用一個“循環”不停的嘗試讀取數據,處理數據。
異步阻塞
同步模型最主要的問題是占用CPU,阻塞I/O會主動讓出CPU但是用戶空間的系統調用還是不會返回依然耗費CPU;非阻塞I/O必須不停的“輪詢”不斷嘗試讀取數據(會耗費更多CPU更加低效)。如果仔細分析同步模型霸占CPU的原因不難得出結論——都是在等待數據到來。異步模式正是意識到這一點所以把I/O讀取細化為訂閱I/O事件,實際I/O讀寫,在“訂閱I/O事件”事件部分會主動讓出CPU直到事件發生。異步模式下的I/O函數和同步模式下的I/O函數是一樣的(都是read
、write
)唯一的區別是異步模式“讀”必有數據而同步模式則未必。常見的異步阻塞函數包括select
,poll
,epoll
,這些函數的用法需要花費相當大的篇幅介紹而這篇文章我想集中精力介紹“I/O模型”。以select
為例我們看一下大致原理

異步模式下我們的API調用分為兩步,第一步是通過select
訂閱讀寫事件這個函數會主動讓出CPU直到事件發生(設置為Sleep狀態,等待事件發生);select一旦返回就證明可以開始讀了所以第二部是通過read
讀取數據(“讀”必有數據)。
異步阻塞模型之信號驅動
“完美主義者”看了上面的select
之后會有點不爽——我還要“等待”讀寫事件(即便select
會主動讓出CPU),能不能有讀寫事件的時候主動通知我?。?。借助“信號”機制我們可以實現這個,但是這并不完美而且有點弄巧成拙的意思。具體用法:通過fcntl
函數設置一個F_GETFL|O_ASYNC
( 曾經信號驅動I/O也叫“異步I/O”所以才有O_ASYNC
的說法),當有I/O時間的時候操作系統會觸發SIGIO
信號。在程序里只需要綁定SIGIO
信號的處理函數就可以了。但是這里有個問題——信號處理函數由哪個進程執行呢?,答案是:“屬主”進程。操作系統只負責參數信號而實際的信號處理函數必須由用戶空間的進程實現。(這就是設置F_SETOWN
為當前進程PID的原因)信號驅動性能要比select
、poll
高(避免文件描述符的復制)但是缺點是致命的——*linux中信號隊列是有限制的如果操過這個數字問題就完全無法讀取數據。
異步非阻塞
這種模型是最“省事”的模型,系統調用完成之后就只要坐等數據就可以了。是不是特別爽?其實不然,問題出在實現上。Linux上的AIO兩個實現版本,POSIX的實現最爛(藍色巨人的鍋)性能很差而且是基于“事件驅動”還會出現“信號隊列不足”的問題(所以它就偷偷的創建線程,導致線程也不可控了);一個是Linux自己實現的(redhat貢獻)Native AIO。Native AIO主要涉及到的兩個函數io_submit
設置需要I/O動作(讀、寫,數據大小,應用緩沖區等);io_getevents
等待I/O動作完成。沒錯,即便你的整個I/O行為是非阻塞的還是需要有一個辦法知道數據是否讀取/寫入成功。

注意圖中,內核不再為I/O分配PageCache,所有的數據必須有用戶自己讀取到應用緩沖中維護。所以AIO一定是和“直接I/O”配合使用。AIO針對網卡設備的意義不大,首先它的實現本質上和epoll差不多;其次它在Linux中的作用更多的是用于磁盤I/O(異步非阻塞可以不用多線程就造成大量的I/O請求便于I/O模塊“合并”優化會提高整體I/O的吞吐率——而且對CPU開銷比較少)。在Nginx中用了一個技巧,可以實現AIO和epoll聯動,AIO讀取到數據后觸發epoll發送數據。(這個特性是非常尷尬的,如果是磁盤文件完全可以用sendfile搞定)。
Direct I/O和Buffered I/O
Linux在進行I/O操作的時候會先把數據放到PageCache中然后通過“內存映射”的方式返回給應用程序,這樣做的好處是可以預讀數據也能在多個進程讀取相同數據的時候起到Cache的作用。應用程序不能直接使用PageCache中的數據,通常是復制到一塊“用戶空間”的內存中再使用。
Direct I/O是指數據不落在PageCache,直接從設備讀取到數據后放到用戶空間中
Buffered I/O是指數據競購PageCache
同步I/O只能使用Buffered I/O;異步阻塞I/O可以Buffered I/O也可以使用Direct I/O;異步非阻塞I/O只能使用Direct I/O
Zero Copy
考慮從磁盤讀取文件經過網卡發送出去,會有四次內存復制:1. DMA會復制磁盤數據到內核空間,2. 應用程序復制內核空間的數據到用戶空間;3. 應用程序用戶空間的數據復制到Socket緩沖(內核空間);4. 協議棧把數據復制到網卡的中發送。簡單來說Zero Copy就是節省這個過程中的內存復制次數。有幾種做法:
Direct I/O直接把磁盤數據復制到內核空間;但是Direct I/O沒有辦法直接把數據放到網卡中——必須要經過協議棧。所以可以節省一次內存復制;
sendfile,磁盤數據通過DMA讀取到內核空間后直接交給TCP/ip協議棧;真正的不需要內存復制;
除此之外還可以利用splice
、mmap
做一些優化,根據不同的設備需要采用不同的方式此處不再展開。