1. 程式人生 > >I/O模型:同步I/O和非同步I/O,阻塞I/O和非阻塞I/O

I/O模型:同步I/O和非同步I/O,阻塞I/O和非阻塞I/O

同步(synchronous) IO和非同步(asynchronous) IO,阻塞(blocking) IO和非阻塞(non-blocking)IO分別是什麼,到底有什麼區別?

這個問題其實不同的人給出的答案都可能不同,在大部分的博文中(包括WIKI在內),我們很可能看見這樣的回答:同步I/O等價於阻塞I/O,非同步I/O等價於非阻塞I/O。產生這樣的答案主要是因為環境下有著不同的知識背景,導致在討論這一問題的時候上下文(context)也不相同。所以,為了更好的討論這一問題,需要先限定一下本文的上下文。

本文討論的背景是Linux環境下的NetWork I/O。本文最重要的參考文獻是Richard Stevens的“UNIX? Network Programming Volume 1, Third Edition: The Sockets Networking ”,6.2節“I/O Models ”,Stevens在這節中詳細說明了各種I/O的特點和區別,如果英文夠好的話,推薦直接閱讀。Stevens的文風是有名的深入淺出,所以不用擔心看不懂。本文中的流程圖也是擷取自參考文獻。

    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模型的區別就是在兩個階段上各有不同的情況。
1、阻塞IO(blocking IO

    在linux中,預設情況下所有的socket都是blocking,一個典型的讀操作流程大概是這樣:

                                                                圖1 阻塞IO

    當用戶程序呼叫了recvfrom這個系統呼叫,kernel就開始了IO的第一個階段:準備資料。對於network io來說,很多時候資料在一開始還沒有到達(比如,還沒有收到一個完整的UDP包),這個時候kernel就要等待足夠的資料到來。而在使用者程序這邊,整個程序會被阻塞。當kernel一直等到資料準備好了,它就會將資料從kernel中拷貝到使用者記憶體,然後kernel返回結果,使用者程序才解除block的狀態,重新執行起來。
    所以,blocking IO的特點就是在IO執行的兩個階段(等待資料和拷貝資料兩個階段)都被block了

    幾乎所有的程式設計師第一次接觸到的網路程式設計都是從listen()、send()、recv() 等介面開始的,這些介面都是阻塞型的。使用這些介面可以很方便的構建伺服器/客戶機的模型。下面是一個簡單地“一問一答”的伺服器。

            圖2 簡單的一問一答的伺服器/客戶機模型

    我們注意到,大部分的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介面帶來的資源佔用。而且,所謂“池”始終有其上限,當請求大大超過上限時,“池”構成的系統對外界的響應並不比沒有池的時候效果好多少。所以使用“池”必須考慮其面臨的響應規模,並根據響應規模調整“池”的大小。
    對應上例中的所面臨的可能同時出現的上千甚至上萬次的客戶端請求,“執行緒池”或“連線池”或許可以緩解部分壓力,但是不能解決所有問題。總之,多執行緒模型可以方便高效的解決小規模的服務請求,但面對大規模的服務請求,多執行緒模型也會遇到瓶頸,可以用非阻塞介面來嘗試解決這個問題。

    2、非阻塞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()多路複用模式,可以一次檢測多個連線是否活躍。
3、多路複用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) 等技術以支援非同步響應,這使得這些庫成為構建事件驅動模型的不二選擇。

    實際上,Linux核心從2.6開始,也引入了支援非同步響應的IO操作,如aio_read, aio_write,這就是非同步IO。

    4、非同步IO(Asynchronous I/O)
    Linux下的asynchronous IO其實用得不多,從核心2.6版本才開始引入。先看一下它的流程:

                                                                圖11 非同步IO

   使用者程序發起read操作之後,立刻就可以開始去做其它的事。而另一方面,從kernel的角度,當它受到一個asynchronous read之後,首先它會立刻返回,所以不會對使用者程序產生任何block。然後,kernel會等待資料準備完成,然後將資料拷貝到使用者記憶體,當這一切都完成之後,kernel會給使用者程序傳送一個signal,告訴它read操作完成了。

    非同步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和非阻塞IO的區別!!!

非同步IO就是把IO操作提交給系統,讓系統幫我們完成相關操作,操作完成後系統在以某種方式通知我們操作已經完成。非阻塞IO就是我們要通過某種不定時方式向系統詢問我們能夠開始執行某個IO操作,當得到許可指令後,具體的操作還是需要我們自己動手來完成的!

最後,再舉幾個不是很恰當的例子來說明這四個IO Model:
有A,B,C,D四個人在釣魚:
A用的是最老式的魚竿,所以呢,得一直守著,等到魚上鉤了再拉桿;
B的魚竿有個功能,能夠顯示是否有魚上鉤,所以呢,B就和旁邊的MM聊天,隔會再看看有沒有魚上鉤,有的話就迅速拉桿;
C用的魚竿和B差不多,但他想了一個好辦法,就是同時放好幾根魚竿,然後守在旁邊,一旦有顯示說魚上鉤了,它就將對應的魚竿拉起來;
D是個有錢人,乾脆僱了一個人幫他釣魚,一旦那個人把魚釣上來了,就給D發個簡訊。