在引入IO模型前,先對io等待時某一段數據的"經歷"作一番解釋。如圖:html
當某個程序或已存在的進程/線程(後文將不加區分的只認爲是進程)須要某段數據時,它只能在用戶空間中屬於它本身的內存中訪問、修改,這段內存暫且稱之爲app buffer。假設須要的數據在磁盤上,那麼進程首先得發起相關係統調用,通知內核去加載磁盤上的文件。但正常狀況下,數據只能加載到內核的緩衝區,暫且稱之爲kernel buffer。數據加載到kernel buffer以後,還需將數據複製到app buffer。到了這裏,進程就能夠對數據進行訪問、修改了。web
如今有幾個須要說明的問題。安全
(1).爲何不能直接將數據加載到app buffer呢?網絡
其實是能夠的,有些程序或者硬件爲了提升效率和性能,能夠實現內核旁路的功能,避過內核的參與,直接在存儲設備和app buffer之間進行數據傳輸,例如RDMA技術就須要實現這樣的內核旁路功能。數據結構
可是,最普通也是絕大多數的狀況下,爲了安全和穩定性,數據必須先拷入內核空間的kernel buffer,再複製到app buffer,以防止進程串進內核空間進行破壞。併發
(2).上面提到的數據幾回拷貝過程,拷貝方式是同樣的嗎?app
不同。如今的存儲設備(包括網卡)基本上都支持DMA操做。什麼是DMA(direct memory access,直接內存訪問)?簡單地說,就是內存和設備之間的數據交互能夠直接傳輸,再也不須要計算機的CPU參與,而是經過硬件上的芯片(能夠簡單地認爲是一個小cpu)進行控制。異步
假設,存儲設備不支持DMA,那麼數據在內存和存儲設備之間的傳輸,必須經過計算機的CPU計算從哪一個地址中獲取數據、拷入到對方的哪些地址、拷入多少數據(多少個數據塊、數據塊在哪裏)等等,僅僅完成一次數據傳輸,CPU都要作不少事情。而DMA就釋放了計算機的CPU,讓它能夠去處理其餘任務。socket
再說kernel buffer和app buffer之間的複製方式,這是兩段內存空間的數據傳輸,只能由CPU來控制。async
因此,在加載硬盤數據到kernel buffer的過程是DMA拷貝方式,而從kernel buffer到app buffer的過程是CPU參與的拷貝方式。
(3).若是數據要經過TCP鏈接傳輸出去要怎麼辦?
例如,web服務對客戶端的響應數據,須要經過TCP鏈接傳輸給客戶端。
TCP/IP協議棧維護着兩個緩衝區:send buffer和recv buffer,它們合稱爲socket buffer。須要經過TCP鏈接傳輸出去的數據,須要先複製到send buffer,再複製給網卡經過網絡傳輸出去。若是經過TCP鏈接接收到數據,數據首先經過網卡進入recv buffer,再被複制到用戶空間的app buffer。
一樣,在數據複製到send buffer或從recv buffer複製到app buffer時,是CPU參與的拷貝。從send buffer複製到網卡或從網卡複製到recv buffer時,是DMA操做方式的拷貝。
以下圖所示,是經過TCP鏈接傳輸數據時的過程。
(4).網絡數據必定要從kernel buffer複製到app buffer再複製到send buffer嗎?
不是。若是進程不須要修改數據,就直接發送給TCP鏈接的另外一端,能夠不用從kernel buffer複製到app buffer,而是直接複製到send buffer。這就是零複製技術。
例如httpd不須要訪問和修改任何信息時,將數據原本來本地複製到app buffer再原本來本地複製到send buffer而後傳輸出去,但實際上覆制到app buffer的過程是能夠省略的。使用零複製技術,就能夠減小一次拷貝過程,提高效率。
固然,實現零複製技術的方法有多種,見個人另外一篇結束零複製的文章:零複製(zero copy)技術。
如下是以httpd進程處理文件類請求時比較完整的數據操做流程。
大體解釋下:客戶端發起對某個文件的請求,經過TCP鏈接,請求數據進入TCP 的recv buffer,再經過recv()函數將數據讀入到app buffer,此時httpd工做進程對數據進行一番解析,知道請求的是某個文件,因而發起某個系統調用(例如要讀取這個文件,發起read()),因而內核加載該文件,數據從磁盤複製到kernel buffer再複製到app buffer,此時httpd就要開始構建響應數據了,可能會對數據進行一番修改,例如在響應首部中加一個字段,最後將修改或未修改的數據複製(例如send()函數)到send buffer中,再經過TCP鏈接傳輸給客戶端。
所謂的IO模型,描述的是出現I/O等待時進程的狀態以及處理數據的方式。圍繞着進程的狀態、數據準備到kernel buffer再到app buffer的兩個階段展開。其中數據複製到kernel buffer的過程稱爲數據準備階段,數據從kernel buffer複製到app buffer的過程稱爲數據複製階段。請記住這兩個概念,後面描述I/O模型時會一直用這兩個概念。
本文以httpd進程的TCP鏈接方式處理本地文件爲例,請無視httpd是否真的實現瞭如此、那般的功能,也請無視TCP鏈接處理數據的細節,這裏僅僅只是做爲方便解釋的示例而已。另外,本文用本地文件做爲I/O模型的對象不是很適合,它的重頭戲是在套接字上,若是想要看處理TCP/UDP過程當中套接字的I/O模型,請看完此文後,再結合個人另外一篇文章"不可不知的socket和TCP鏈接過程"以從新認識I/O模型。
再次說明,從硬件設備到內存的數據傳輸過程是不須要CPU參與的,而內存間傳輸數據是須要CPU參與的。
如圖:
假設客戶端發起index.html的文件請求,httpd須要將index.html的數據從磁盤中加載到本身的httpd app buffer中,而後複製到send buffer中發送出去。
可是在httpd想要加載index.html時,它首先檢查本身的app buffer中是否有index.html對應的數據,沒有就發起系統調用讓內核去加載數據,例如read(),內核會先檢查本身的kernel buffer中是否有index.html對應的數據,若是沒有,則從磁盤中加載,而後將數據準備到kernel buffer,再複製到app buffer中,最後被httpd進程處理。
若是使用Blocking I/O模型:
(1).當設置爲blocking i/o模型,httpd從到都是被阻塞的。
(2).只有當數據複製到app buffer完成後,或者發生了錯誤,httpd才被喚醒處理它app buffer中的數據。
(3).cpu會通過兩次上下文切換:用戶空間到內核空間再到用戶空間。
(4).因爲階段的拷貝是不須要CPU參與的,因此在階段準備數據的過程當中,cpu能夠去處理其它進程的任務。
(5).階段的數據複製須要CPU參與,將httpd阻塞,在某種程度上來講,有助於提高它的拷貝速度。
(6).這是最省事、最簡單的IO模式。
以下圖:
(1).當設置爲non-blocking時,httpd第一次發起系統調用(如read())後,當即返回一個錯誤值EWOULDBLOCK(至於read()讀取一個普通文件時可否返回EWOULDBLOCK請無視,畢竟I/O模型主要是針對套接字文件的,就當read()是recv()好了),而不是讓httpd進入睡眠狀態。UNP中也正是這麼描述的。
When we set a socket to be nonblocking, we are telling the kernel "when an I/O operation that I request cannot be completed without putting the process to sleep, do not put the process to sleep, but return an error instead.
(2).雖然read()當即返回了,但httpd還要不斷地去發送read()檢查內核:數據是否已經成功拷貝到kernel buffer了?這稱爲輪詢(polling)。每次輪詢時,只要內核沒有把數據準備好,read()就返回錯誤信息EWOULDBLOCK。
(3).直到kernel buffer中數據準備完成,再去輪詢時再也不返回EWOULDBLOCK,而是將httpd阻塞,以等待數據複製到app buffer。
(4).httpd在到階段不被阻塞,可是會不斷去發送read()輪詢。在被阻塞,將cpu交給內核把數據copy到app buffer。
以下圖:
稱爲多路IO模型或IO複用,意思是能夠檢查多個IO等待的狀態。有三種IO複用模型:select、poll和epoll。其實它們都是一種函數,用於監控指定文件描述符的數據是否就緒,就緒指的是對某個系統調用再也不阻塞了,例如對於read()來講,就是數據準備好了就是就緒狀態。就緒種類包括是否可讀、是否可寫以及是否異常,其中可讀條件中就包括了數據是否準備好。當就緒以後,將通知進程,進程再發送對數據操做的系統調用,如read()。因此,這三個函數僅僅只是處理了數據是否準備好以及如何通知進程的問題。能夠將這幾個函數結合阻塞和非阻塞IO模式使用,例如設置爲非阻塞時,select()/poll()/epoll將不會阻塞在對應的描述符上,調用函數的進程/線程也就不會被阻塞。
select()和poll()差很少,它們的監控和通知手段是同樣的,只不過poll()要更聰明一點,因此此處僅以select()監控單個文件請求爲例簡單介紹IO複用,至於更具體的、監控多個文件以及epoll的方式,在本文的最後專門解釋。
(1).當想要加載某個文件時,假如httpd要發起read()系統調用,若是是阻塞或者非阻塞情形,那麼read()會根據數據是否準備好而決定是否返回,是否能夠主動去監控這個數據是否準備到了kernel buffer中呢,亦或者是否能夠監控send buffer中是否有新數據進入呢?這就是select()/poll()/epoll的做用。
(2).當使用select()時,httpd發起一個select調用,而後httpd進程被select()"阻塞"。因爲此處假設只監控了一個請求文件,因此select()會在數據準備到kernel buffer中時直接喚醒httpd進程。之因此阻塞要加上雙引號,是由於select()有時間間隔選項可用控制阻塞時長,若是該選項設置爲0,則select不阻塞,此時表示當即返回但一直輪詢檢查是否就緒,還能夠設置爲永久阻塞。
(3).當select()的監控對象就緒時,將通知(輪詢狀況)或喚醒(阻塞狀況)httpd進程,httpd再發起read()系統調用,此時數據會從kernel buffer複製到app buffer中並read()成功。
(4).httpd發起第二個系統調用(即read())後被阻塞,CPU所有交給內核用來複制數據到app buffer。
(5).對於httpd只處理一個鏈接的狀況下,IO複用模型還不如blocking I/O模型,由於它先後發起了兩個系統調用(即select()和read()),甚至在輪詢的狀況下會不斷消耗CPU。可是IO複用的優點就在於能同時監控多個文件描述符。
如圖:
更詳細的說明,見本文末。
即信號驅動IO模型。當開啓了信號驅動功能時,首先發起一個信號處理的系統調用,如sigaction(),這個系統調用會當即返回。但數據在準備好時,會發送SIGIO信號,進程收到這個信號就知道數據準備好了,因而發起操做數據的系統調用,如read()。
在發起信號處理的系統調用後,進程不會被阻塞,可是在read()將數據從kernel buffer複製到app buffer時,進程是被阻塞的。如圖:
即異步IO模型。當設置爲異步IO模型時,httpd首先發起異步系統調用(如aio_read(),aio_write()等),並當即返回。這個異步系統調用告訴內核,不只要準備好數據,還要把數據複製到app buffer中。
httpd從返回開始,直到數據複製到app buffer結束都不會被阻塞。當數據複製到app buffer結束,將發送一個信號通知httpd進程。
如圖:
看上去異步很好,可是注意,在複製kernel buffer數據到app buffer中時是須要CPU參與的,這意味着不受阻的httpd會和異步調用函數爭用CPU。若是併發量比較大,httpd接入的鏈接數可能就越多,CPU爭用狀況就越嚴重,異步函數返回成功信號的速度就越慢。若是不能很好地處理這個問題,異步IO模型也不必定就好。
阻塞、非阻塞、IO複用、信號驅動都是同步IO模型。由於在發起操做數據的系統調用(如本文的read())過程當中是被阻塞的。這裏要注意,雖然在加載數據到kernel buffer的數據準備過程當中可能阻塞、可能不阻塞,但kernel buffer纔是read()函數的操做對象,同步的意思是讓kernel buffer和app buffer數據同步。顯然,在保持kernel buffer和app buffer同步的過程當中,進程必須被阻塞,不然read()就變成異步的read()。
只有異步IO模型纔是異步的,由於發起的異步類的系統調用(如aio_read())已經無論kernel buffer什麼時候準備好數據了,就像後臺同樣read同樣,aio_read()能夠一直等待kernel buffer中的數據,在準備好了以後,aio_read()天然就能夠將其複製到app buffer。
如圖:
前面說了,這三個函數是文件描述符狀態監控的函數,它們能夠監控一系列文件的一系列事件,當出現知足條件的事件後,就認爲是就緒或者錯誤。事件大體分爲3類:可讀事件、可寫事件和異常事件。它們一般都放在循環結構中進行循環監控。
select()和poll()函數處理方式的本質相似,只不過poll()稍微先進一點,而epoll處理方式就比這兩個函數先進多了。固然,就算是先進分子,在某些狀況下性能也不必定就比老傢伙們強。
首先,經過FD_SET宏函數建立待監控的描述符集合,並將此描述符集合做爲select()函數的參數,能夠在指定select()函數阻塞時間間隔,因而select()就建立了一個監控對象。
除了普通文件描述符,還能夠監控套接字,由於套接字也是文件,因此select()也能夠監控套接字文件描述符,例如recv buffer中是否收到了數據,也即監控套接字的可讀性,send buffer中是否滿了,也即監控套接字的可寫性。select()默認最大可監控1024個文件描述符。而poll()則沒有此限制。
select()的時間間隔參數分3種:
(1).設置爲指定時間間隔內阻塞,除非以前有就緒事件發生。
(2).設置爲永久阻塞,除非有就緒事件發生。
(3).設置爲徹底不阻塞,即當即返回。但由於select()一般在循環結構中,因此這是輪詢監控的方式。
當建立了監控對象後,由內核監控這些描述符集合,於此同時調用select()的進程被阻塞(或輪詢)。當監控到知足就緒條件時(監控事件發生),select()將被喚醒(或暫停輪詢),因而select()返回知足就緒條件的描述符數量,之因此是數量而不只僅是一個,是由於多個文件描述符可能在同一時間知足就緒條件。因爲只是返回數量,並無返回哪個或哪幾個文件描述符,因此一般在使用select()以後,還會在循環結構中的if語句中使用宏函數FD_ISSET進行遍歷,直到找出全部的知足就緒條件的描述符。最後將描述符集合經過指定函數拷貝回用戶空間,以便被進程處理。
監聽描述符集合的大體過程以下圖所示,其中select()只是其中的一個環節:
大概描述下這個循環監控的過程:
(1).首先經過FD_ZERO宏函數初始化描述符集合。圖中每一個小方格表示一個文件描述符。
(2).經過FD_SET宏函數建立描述符集合,此時集合中的文件描述符都被打開,也就是稍後要被select()監控的對象。
(3).使用select()函數監控描述符集合。當某個文件描述符知足就緒條件時,select()函數返回集合中知足條件的數量。圖中標黃色的小方塊表示知足就緒條件的描述符。
(4).經過FD_ISSET宏函數遍歷整個描述符集合,並將知足就緒條件的描述符發送給進程。同時,使用FD_CLR宏函數將知足就緒條件的描述符從集合中移除。
(5).進入下一個循環,繼續使用FD_SET宏函數向描述符集合中添加新的待監控描述符。而後重複(3)、(4)兩個步驟。
若是使用簡單的僞代碼來描述:
FD_ZERO
for() {
FD_SET()
select()
if(){
FD_ISSET()
FD_CLR()
}
writen()
}
以上所說只是一種須要循環監控的示例,具體如何作倒是不必定的。不過從中也能看出這一系列的流程。
epoll比poll()、select()先進,考慮如下幾點,天然能看出它的優點所在:
(1).epoll_create()建立的epoll實例能夠隨時經過epoll_ctl()來新增和刪除感興趣的文件描述符,不用再和select()每一個循環後都要使用FD_SET更新描述符集合的數據結構。
(2).在epoll_create()建立epoll實例時,還建立了一個epoll就緒鏈表list。而epoll_ctl()每次向epoll實例添加描述符時,還會註冊該描述符的回調函數。當epoll實例中的描述符知足就緒條件時將觸發回調函數,被移入到就緒鏈表list中。
(3).當調用epoll_wait()進行監控時,它只需肯定就緒鏈表中是否有數據便可,若是有,將複製到用戶空間以被進程處理,若是沒有,它將被阻塞。固然,若是監控的對象設置爲非阻塞模式,它將不會被阻塞,而是不斷地去檢查。
也就是說,epoll的處理方式中,根本就無需遍歷描述符集合。