同步(synchronous) IO和異步(asynchronous) IO,阻塞(blocking) IO和非阻塞(non-blocking)IO分別是什麼,到底有什麼區別?這個問題其實不一樣的人給出的答案均可能不一樣,好比wiki,就認爲asynchronous IO和non-blocking IO是一個東西。這實際上是由於不一樣的人的知識背景不一樣,而且在討論這個問題的時候上下文(context)也不相同。因此,爲了更好的回答這個問題,我先限定一下本文的上下文。 html
本文討論的背景是Linux環境下的network IO。本文最重要的參考文獻是Richard Stevens的「UNIX® Network Programming Volume 1, Third Edition: The Sockets Networking 」,6.2節「I/O Models 」,Stevens在這節中詳細說明了各類IO的特色和區別,若是英文夠好的話,推薦直接閱讀。Stevens的文風是有名的深刻淺出,因此不用擔憂看不懂。本文中的流程圖也是截取自參考文獻。 java
Stevens在文章中一共比較了五種IO Model:
* blocking IO
* nonblocking IO
* IO multiplexing
* signal driven IO
* asynchronous IO
由signal driven IO在實際中並不經常使用,因此主要介紹其他四種IO Model。
再說一下IO發生時涉及的對象和步驟。對於一個network IO (這裏咱們以read舉例),它會涉及到兩個系統對象,一個是調用這個IO的process (or thread),另外一個就是系統內核(kernel)。當一個read操做發生時,它會經歷兩個階段:
1)等待數據準備 (Waiting for the data to be ready)
2)將數據從內核拷貝到進程中(Copying the data from the kernel to the process)
記住這兩點很重要,由於這些IO模型的區別就是在兩個階段上各有不一樣的狀況。
一、阻塞IO(blocking IO) linux
在linux中,默認狀況下全部的socket都是blocking,一個典型的讀操做流程大概是這樣: 程序員
圖1 阻塞IO web
當用戶進程調用了recvfrom這個系統調用,kernel就開始了IO的第一個階段:準備數據。對於network io來講,不少時候數據在一開始尚未到達(好比,尚未收到一個完整的UDP包),這個時候kernel就要等待足夠的數據到來。而在用戶進程這邊,整個進程會被阻塞。當kernel一直等到數據準備好了,它就會將數據從kernel中拷貝到用戶內存,而後kernel返回結果,用戶進程才解除block的狀態,從新運行起來。
因此,blocking IO的特色就是在IO執行的兩個階段(等待數據和拷貝數據兩個階段)都被block了。 數據庫
幾乎全部的程序員第一次接觸到的網絡編程都是從listen()、send()、recv() 等接口開始的,這些接口都是阻塞型的。使用這些接口能夠很方便的構建服務器/客戶機的模型。下面是一個簡單地「一問一答」的服務器。 編程
圖2 簡單的一問一答的服務器/客戶機模型 windows
咱們注意到,大部分的socket接口都是阻塞型的。所謂阻塞型接口是指系統調用(通常是IO接口)不返回調用結果並讓當前線程一直阻塞,只有當該系統調用得到結果或者超時出錯時才返回。
實際上,除非特別指定,幾乎全部的IO接口 ( 包括socket接口 ) 都是阻塞型的。這給網絡編程帶來了一個很大的問題,如在調用send()的同時,線程將被阻塞,在此期間,線程將沒法執行任何運算或響應任何的網絡請求。 設計模式
一個簡單的改進方案是在服務器端使用多線程(或多進程)。多線程(或多進程)的目的是讓每一個鏈接都擁有獨立的線程(或進程),這樣任何一個鏈接的阻塞都不會影響其餘的鏈接。具體使用多進程仍是多線程,並無一個特定的模式。傳統意義上,進程的開銷要遠遠大於線程,因此若是須要同時爲較多的客戶機提供服務,則不推薦使用多進程;若是單個服務執行體須要消耗較多的CPU資源,譬如須要進行大規模或長時間的數據運算或文件訪問,則進程較爲安全。一般,使用pthread_create ()建立新線程,fork()建立新進程。
咱們假設對上述的服務器 / 客戶機模型,提出更高的要求,即讓服務器同時爲多個客戶機提供一問一答的服務。因而有了以下的模型。 緩存
圖3 多線程的服務器模型
在上述的線程 / 時間圖例中,主線程持續等待客戶端的鏈接請求,若是有鏈接,則建立新線程,並在新線程中提供爲前例一樣的問答服務。
不少初學者可能不明白爲什麼一個socket能夠accept屢次。實際上socket的設計者可能特地爲多客戶機的狀況留下了伏筆,讓accept()可以返回一個新的socket。下面是 accept 接口的原型:
int accept(int s, struct sockaddr *addr, socklen_t *addrlen);
輸入參數s是從socket(),bind()和listen()中沿用下來的socket句柄值。執行完bind()和listen()後,操做系統已經開始在指定的端口處監聽全部的鏈接請求,若是有請求,則將該鏈接請求加入請求隊列。調用accept()接口正是從 socket s 的請求隊列抽取第一個鏈接信息,建立一個與s同類的新的socket返回句柄。新的socket句柄便是後續read()和recv()的輸入參數。若是請求隊列當前沒有請求,則accept() 將進入阻塞狀態直到有請求進入隊列。
上述多線程的服務器模型彷佛完美的解決了爲多個客戶機提供問答服務的要求,但其實並不盡然。若是要同時響應成百上千路的鏈接請求,則不管多線程仍是多進程都會嚴重佔據系統資源,下降系統對外界響應效率,而線程與進程自己也更容易進入假死狀態。
不少程序員可能會考慮使用「線程池」或「鏈接池」。「線程池」旨在減小建立和銷燬線程的頻率,其維持必定合理數量的線程,並讓空閒的線程從新承擔新的執行任務。「鏈接池」維持鏈接的緩存池,儘可能重用已有的鏈接、減小建立和關閉鏈接的頻率。這兩種技術均可以很好的下降系統開銷,都被普遍應用不少大型系統,如websphere、tomcat和各類數據庫等。可是,「線程池」和「鏈接池」技術也只是在必定程度上緩解了頻繁調用IO接口帶來的資源佔用。並且,所謂「池」始終有其上限,當請求大大超過上限時,「池」構成的系統對外界的響應並不比沒有池的時候效果好多少。因此使用「池」必須考慮其面臨的響應規模,並根據響應規模調整「池」的大小。
對應上例中的所面臨的可能同時出現的上千甚至上萬次的客戶端請求,「線程池」或「鏈接池」或許能夠緩解部分壓力,可是不能解決全部問題。總之,多線程模型能夠方便高效的解決小規模的服務請求,但面對大規模的服務請求,多線程模型也會遇到瓶頸,能夠用非阻塞接口來嘗試解決這個問題。
二、非阻塞IO(non-blocking IO)
Linux下,能夠經過設置socket使其變爲non-blocking。當對一個non-blocking socket執行讀操做時,流程是這個樣子:
圖4 非阻塞IO
從圖中能夠看出,當用戶進程發出read操做時,若是kernel中的數據尚未準備好,那麼它並不會block用戶進程,而是馬上返回一個error。從用戶進程角度講 ,它發起一個read操做後,並不須要等待,而是立刻就獲得了一個結果。用戶進程判斷結果是一個error時,它就知道數據尚未準備好,因而它能夠再次發送read操做。一旦kernel中的數據準備好了,而且又再次收到了用戶進程的system call,那麼它立刻就將數據拷貝到了用戶內存,而後返回。
因此,在非阻塞式IO中,用戶進程實際上是須要不斷的主動詢問kernel數據準備好了沒有。
非阻塞的接口相比於阻塞型接口的顯著差別在於,在被調用以後當即返回。使用以下的函數能夠將某句柄fd設爲非阻塞狀態。
fcntl( fd, F_SETFL, O_NONBLOCK );
下面將給出只用一個線程,但可以同時從多個鏈接中檢測數據是否送達,而且接受數據的模型。
圖5 使用非阻塞的接收數據模型
在非阻塞狀態下,recv() 接口在被調用後當即返回,返回值表明了不一樣的含義。如在本例中,
* recv() 返回值大於 0,表示接受數據完畢,返回值便是接受到的字節數;
* recv() 返回 0,表示鏈接已經正常斷開;
* recv() 返回 -1,且 errno 等於 EAGAIN,表示 recv 操做還沒執行完成;
* recv() 返回 -1,且 errno 不等於 EAGAIN,表示 recv 操做遇到系統錯誤 errno。
能夠看到服務器線程能夠經過循環調用recv()接口,能夠在單個線程內實現對全部鏈接的數據接收工做。可是上述模型毫不被推薦。由於,循環調用recv()將大幅度推高CPU 佔用率;此外,在這個方案中recv()更多的是起到檢測「操做是否完成」的做用,實際操做系統提供了更爲高效的檢測「操做是否完成「做用的接口,例如select()多路複用模式,能夠一次檢測多個鏈接是否活躍。
三、多路複用IO(IO multiplexing)
IO multiplexing這個詞可能有點陌生,可是若是我說select/epoll,大概就都能明白了。有些地方也稱這種IO方式爲事件驅動IO(event driven IO)。咱們都知道,select/epoll的好處就在於單個process就能夠同時處理多個網絡鏈接的IO。它的基本原理就是select/epoll這個function會不斷的輪詢所負責的全部socket,當某個socket有數據到達了,就通知用戶進程。它的流程如圖:
圖6 多路複用IO
當用戶進程調用了select,那麼整個進程會被block,而同時,kernel會「監視」全部select負責的socket,當任何一個socket中的數據準備好了,select就會返回。這個時候用戶進程再調用read操做,將數據從kernel拷貝到用戶進程。
這個圖和blocking IO的圖其實並無太大的不一樣,事實上還更差一些。由於這裏須要使用兩個系統調用(select和recvfrom),而blocking IO只調用了一個系統調用(recvfrom)。可是,用select的優點在於它能夠同時處理多個connection。(多說一句:因此,若是處理的鏈接數不是很高的話,使用select/epoll的web server不必定比使用multi-threading + blocking IO的web server性能更好,可能延遲還更大。select/epoll的優點並非對於單個鏈接能處理得更快,而是在於能處理更多的鏈接。)
在多路複用模型中,對於每個socket,通常都設置成爲non-blocking,可是,如上圖所示,整個用戶的process實際上是一直被block的。只不過process是被select這個函數block,而不是被socket IO給block。所以select()與非阻塞IO相似。
大部分Unix/Linux都支持select函數,該函數用於探測多個文件句柄的狀態變化。下面給出select接口的原型:
FD_ZERO(int fd, fd_set* fds)
FD_SET(int fd, fd_set* fds)
FD_ISSET(int fd, fd_set* fds)
FD_CLR(int fd, fd_set* fds)
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds,
struct timeval *timeout)
這裏,fd_set 類型能夠簡單的理解爲按 bit 位標記句柄的隊列,例如要在某 fd_set 中標記一個值爲16的句柄,則該fd_set的第16個bit位被標記爲1。具體的置位、驗證可以使用 FD_SET、FD_ISSET等宏實現。在select()函數中,readfds、writefds和exceptfds同時做爲輸入參數和輸出參數。若是輸入的readfds標記了16號句柄,則select()將檢測16號句柄是否可讀。在select()返回後,能夠經過檢查readfds有否標記16號句柄,來判斷該「可讀」事件是否發生。另外,用戶能夠設置timeout時間。
下面將從新模擬上例中從多個客戶端接收數據的模型。
圖7 使用select()的接收數據模型
述模型只是描述了使用select()接口同時從多個客戶端接收數據的過程;因爲select()接口能夠同時對多個句柄進行讀狀態、寫狀態和錯誤狀態的探測,因此能夠很容易構建爲多個客戶端提供獨立問答服務的服務器系統。以下圖。
圖8 使用select()接口的基於事件驅動的服務器模型
這裏須要指出的是,客戶端的一個 connect() 操做,將在服務器端激發一個「可讀事件」,因此 select() 也能探測來自客戶端的 connect() 行爲。
上述模型中,最關鍵的地方是如何動態維護select()的三個參數readfds、writefds和exceptfds。做爲輸入參數,readfds應該標記全部的須要探測的「可讀事件」的句柄,其中永遠包括那個探測 connect() 的那個「母」句柄;同時,writefds 和 exceptfds 應該標記全部須要探測的「可寫事件」和「錯誤事件」的句柄 ( 使用 FD_SET() 標記 )。
做爲輸出參數,readfds、writefds和exceptfds中的保存了 select() 捕捉到的全部事件的句柄值。程序員須要檢查的全部的標記位 ( 使用FD_ISSET()檢查 ),以肯定到底哪些句柄發生了事件。
上述模型主要模擬的是「一問一答」的服務流程,因此若是select()發現某句柄捕捉到了「可讀事件」,服務器程序應及時作recv()操做,並根據接收到的數據準備好待發送數據,並將對應的句柄值加入writefds,準備下一次的「可寫事件」的select()探測。一樣,若是select()發現某句柄捕捉到「可寫事件」,則程序應及時作send()操做,並準備好下一次的「可讀事件」探測準備。下圖描述的是上述模型中的一個執行週期。
圖9 多路複用模型的一個執行週期
這種模型的特徵在於每個執行週期都會探測一次或一組事件,一個特定的事件會觸發某個特定的響應。咱們能夠將這種模型歸類爲「事件驅動模型」。
相比其餘模型,使用select() 的事件驅動模型只用單線程(進程)執行,佔用資源少,不消耗太多 CPU,同時可以爲多客戶端提供服務。若是試圖創建一個簡單的事件驅動的服務器程序,這個模型有必定的參考價值。
但這個模型依舊有着不少問題。首先select()接口並非實現「事件驅動」的最好選擇。由於當須要探測的句柄值較大時,select()接口自己須要消耗大量時間去輪詢各個句柄。不少操做系統提供了更爲高效的接口,如linux提供了epoll,BSD提供了kqueue,Solaris提供了/dev/poll,…。若是須要實現更高效的服務器程序,相似epoll這樣的接口更被推薦。遺憾的是不一樣的操做系統特供的epoll接口有很大差別,因此使用相似於epoll的接口實現具備較好跨平臺能力的服務器會比較困難。
其次,該模型將事件探測和事件響應夾雜在一塊兒,一旦事件響應的執行體龐大,則對整個模型是災難性的。以下例,龐大的執行體1的將直接致使響應事件2的執行體遲遲得不到執行,並在很大程度上下降了事件探測的及時性。
圖10 龐大的執行體對使用select()的事件驅動模型的影響
幸運的是,有不少高效的事件驅動庫能夠屏蔽上述的困難,常見的事件驅動庫有libevent庫,還有做爲libevent替代者的libev庫。這些庫會根據操做系統的特色選擇最合適的事件探測接口,而且加入了信號(signal) 等技術以支持異步響應,這使得這些庫成爲構建事件驅動模型的不二選擇。下章將介紹如何使用libev庫替換select或epoll接口,實現高效穩定的服務器模型。
實際上,Linux內核從2.6開始,也引入了支持異步響應的IO操做,如aio_read, aio_write,這就是異步IO。
四、異步IO(Asynchronous I/O)
Linux下的asynchronous IO其實用得很少,從內核2.6版本纔開始引入。先看一下它的流程:
圖11 異步IO
用戶進程發起read操做以後,馬上就能夠開始去作其它的事。而另外一方面,從kernel的角度,當它受到一個asynchronous read以後,首先它會馬上返回,因此不會對用戶進程產生任何block。而後,kernel會等待數據準備完成,而後將數據拷貝到用戶內存,當這一切都完成以後,kernel會給用戶進程發送一個signal,告訴它read操做完成了。
用異步IO實現的服務器這裏就不舉例了,之後有時間另開文章來說述。異步IO是真正非阻塞的,它不會對請求進程產生任何的阻塞,所以對高併發的網絡服務器實現相當重要。
到目前爲止,已經將四個IO模型都介紹完了。如今回過頭來回答最初的那幾個問題:blocking和non-blocking的區別在哪,synchronous IO和asynchronous IO的區別在哪。
先回答最簡單的這個:blocking與non-blocking。前面的介紹中其實已經很明確的說明了這二者的區別。調用blocking IO會一直block住對應的進程直到操做完成,而non-blocking IO在kernel還在準備數據的狀況下會馬上返回。
在說明synchronous IO和asynchronous IO的區別以前,須要先給出二者的定義。Stevens給出的定義(實際上是POSIX的定義)是這樣子的:
* A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
* An asynchronous I/O operation does not cause the requesting process to be blocked;
二者的區別就在於synchronous IO作」IO operation」的時候會將process阻塞。按照這個定義,以前所述的blocking IO,non-blocking IO,IO multiplexing都屬於synchronous IO。有人可能會說,non-blocking IO並無被block啊。這裏有個很是「狡猾」的地方,定義中所指的」IO operation」是指真實的IO操做,就是例子中的recvfrom這個系統調用。non-blocking IO在執行recvfrom這個系統調用的時候,若是kernel的數據沒有準備好,這時候不會block進程。可是當kernel中數據準備好的時候,recvfrom會將數據從kernel拷貝到用戶內存中,這個時候進程是被block了,在這段時間內進程是被block的。而asynchronous IO則不同,當進程發起IO操做以後,就直接返回不再理睬了,直到kernel發送一個信號,告訴進程說IO完成。在這整個過程當中,進程徹底沒有被block。
還有一種不經常使用的signal driven IO,即信號驅動IO。總的來講,UNP中總結的IO模型有5種之多:阻塞IO,非阻塞IO,IO複用,信號驅動IO,異步IO。前四種都屬於同步IO。阻塞IO沒必要說了。非阻塞IO ,IO請求時加上O_NONBLOCK一類的標誌位,馬上返回,IO沒有就緒會返回錯誤,須要請求進程主動輪詢不斷髮IO請求直到返回正確。IO複用同非阻塞IO本質同樣,不過利用了新的select系統調用,由內核來負責原本是請求進程該作的輪詢操做。看似比非阻塞IO還多了一個系統調用開銷,不過由於能夠支持多路IO,纔算提升了效率。信號驅動IO,調用sigaltion系統調用,當內核中IO數據就緒時以SIGIO信號通知請求進程,請求進程再把數據從內核讀入到用戶空間,這一步是阻塞的。
異步IO,如定義所說,不會由於IO操做阻塞,IO操做所有完成才通知請求進程。
各個IO Model的比較如圖所示:
圖12 各類IO模型的比較
通過上面的介紹,會發現non-blocking IO和asynchronous IO的區別仍是很明顯的。在non-blocking IO中,雖然進程大部分時間都不會被block,可是它仍然要求進程去主動的check,而且當數據準備完成之後,也須要進程主動的再次調用recvfrom來將數據拷貝到用戶內存。而asynchronous IO則徹底不一樣。它就像是用戶進程將整個IO操做交給了他人(kernel)完成,而後他人作完後發信號通知。在此期間,用戶進程不須要去檢查IO操做的狀態,也不須要主動的去拷貝數據。
最後,再舉幾個不是很恰當的例子來講明這四個IO Model:
有A,B,C,D四我的在釣魚:
A用的是最老式的魚竿,因此呢,得一直守着,等到魚上鉤了再拉桿;
B的魚竿有個功能,可以顯示是否有魚上鉤,因此呢,B就和旁邊的MM聊天,隔會再看看有沒有魚上鉤,有的話就迅速拉桿;
C用的魚竿和B差很少,但他想了一個好辦法,就是同時放好幾根魚竿,而後守在旁邊,一旦有顯示說魚上鉤了,它就將對應的魚竿拉起來;
D是個有錢人,乾脆僱了一我的幫他釣魚,一旦那我的把魚釣上來了,就給D發個短信。
下面能夠把select,epoll,iocp,kqueue按號入座。
select和iocp分別對應第3種與第5種模型,那麼epoll與kqueue呢?其實也於select屬於同一種模型,只是更高級一些,能夠看做有了第4種模型的某些特性,如callback機制。
那麼,爲何epoll,kqueue比select高級?
答案是,他們無輪詢。由於他們用callback取代了。想一想看,當套接字比較多的時候,每次select()都要經過遍歷FD_SETSIZE個Socket來完成調度,無論哪一個Socket是活躍的,都遍歷一遍。這會浪費不少CPU時間。若是能給套接字註冊某個回調函數,當他們活躍時,自動完成相關操做,那就避免了輪詢,這正是epoll與kqueue作的。
windows or *nix (IOCP or kqueue/epoll)?
誠然,Windows的IOCP很是出色,目前不多有支持asynchronous I/O的系統,可是因爲其系統自己的侷限性,大型服務器仍是在UNIX下。並且正如上面所述,kqueue/epoll 與 IOCP相比,就是多了一層從內核copy數據到應用層的阻塞,從而不能算做asynchronous I/O類。可是,這層小小的阻塞無足輕重,kqueue與epoll已經作得很優秀了。
提供一致的接口,IO Design Patterns
實際上,無論是哪一種模型,均可以抽象一層出來,提供一致的接口,廣爲人知的有ACE,Libevent這些,他們都是跨平臺的,並且他們自動選擇最優的I/O複用機制,用戶只需調用接口便可。說到這裏又得說說2個設計模式,Reactor and Proactor。有一篇經典文章http://www.artima.com/articles/io_design_patterns.html值得閱讀,Libevent是Reactor模型,ACE提供Proactor模型。實際都是對各類I/O複用機制的封裝。
Java nio包是什麼I/O機制?
我曾天真的認爲java nio封裝的是IOCP。。如今能夠肯定,目前的java本質是select()模型,能夠檢查/jre/bin/nio.dll得知。至於java服務器爲何效率還不錯。。我也不得而知,多是設計得比較好吧。。-_-。
=====================分割線==================================
總結一些重點:
只有IOCP是asynchronous I/O,其餘機制或多或少都會有一點阻塞。 select低效是由於每次它都須要輪詢。但低效也是相對的,視狀況而定,也可經過良好的設計改善 epoll, kqueue是Reacor模式,IOCP是Proactor模式。 java nio包是select模型。。