前提,也是重點是,linux
當接收收據、或者讀取數據時,分兩步git
1 等待數據準備好。程序員
2 從內核拷貝數據到進程。github
對於一個network IO 即 socket(這裏咱們以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 Model的區別就是在兩個階段上各有不一樣的狀況。web
共有5種IO模型。數據庫
blocking I/O 阻塞IO
nonblocking I/O 非阻塞IO
I/O multiplexing (select and poll) IO複用
signal driven I/O (SIGIO) 信號驅動IO
asynchronous I/O (the POSIX aio_functions) 異步IO編程
1 blocking IO
在linux中,默認狀況下全部的socket都是blocking,一個典型的讀操做流程大概是這樣:緩存
當用戶進程調用了recvfrom這個系統調用,kernel就開始了IO的第一個階段:準備數據。對於network io來講,不少時候數據在一開始尚未到達(好比,尚未收到一個完整的UDP包),這個時候kernel就要等待足夠的數據到來。而在用戶進程這邊,整個進程會被阻塞。當kernel一直等到數據準備好了,它就會將數據從kernel中拷貝到用戶內存,而後kernel返回結果,用戶進程才解除block的狀態,從新運行起來。
因此,blocking IO的特色就是在IO執行的兩個階段都被block。tomcat
另外,recvfrom知道數據準備好,且從kernel拷貝到了進程,或者是出錯才返回。常見錯誤時系統調用被信號中斷,即recvfrom是慢系統調用。安全
幾乎全部的程序員第一次接觸到的網絡編程都是從 listen()、send()、recv() 等接口開始的。使用這些接口能夠很方便的構建服務器 / 客戶機的模型。
咱們假設但願創建一個簡單的服務器程序,實現向單個客戶機提供相似於「一問一答」的內容服務。
咱們注意到,大部分的 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 non-blocking IO
linux下,能夠經過設置socket使其變爲non-blocking。當對一個non-blocking socket執行讀操做時,流程是這個樣子:
從圖中能夠看出,當用戶進程發出read操做時,若是kernel中的數據尚未準備好,那麼它並不會block用戶進程,而是馬上返回一個error。從用戶進程角度講 ,它發起一個read操做後,並不須要等待,而是立刻就獲得了一個結果。用戶進程判斷結果是一個error時,它就知道數據尚未準備好,因而它能夠再次發送read操做。一旦kernel中的數據準備好了,而且又再次收到了用戶進程的system call,那麼它立刻就將數據拷貝到了用戶內存,而後返回。
因此,用戶進程實際上是須要不斷的主動詢問kernel數據好了沒有。就是輪訓(Polling),這對cpu是很大的浪費。
因此,non-blocking IO的特色就是在等待數據部阻塞,拷貝阻塞。
以上面臨的不少問題,必定程度是 IO 接口的阻塞特性致使的。多線程是一個解決方案,還一個方案就是使用非阻塞的接口。
非阻塞的接口相比於阻塞型接口的顯著差別在於,在被調用以後當即返回。使用以下的函數能夠將某句柄 fd 設爲非阻塞狀態。
fcntl( fd, F_SETFL, O_NONBLOCK );
下面將給出只用一個線程,但可以同時從多個鏈接中檢測數據是否送達,而且接受數據。
在非阻塞狀態下,recv() 接口在被調用後當即返回,返回值表明了不一樣的含義。如在本例中,
能夠看到服務器線程能夠經過循環調用 recv() 接口,能夠在單個線程內實現對全部鏈接的數據接收工做。
可是上述模型毫不被推薦。由於,循環調用 recv() 將大幅度推高 CPU 佔用率;此外,在這個方案中,recv() 更多的是起到檢測「操做是否完成」的做用,實際操做系統提供了更爲高效的檢測「操做是否完成「做用的接口,例如 select()。
3 IO multiplexing
它的基本原理就是select/epoll這個function會不斷的輪詢所負責的全部socket,當某個socket有數據到達了,就通知用戶進程。它的流程如圖:
當用戶進程調用了select,那麼整個進程會被block,而同時,kernel會「監視」全部select負責的socket,當任何一個socket中的數據準備好了,select就會返回。這個時候用戶進程再調用read操做,將數據從kernel拷貝到用戶進程。這個圖和blocking IO的圖其實並無太大的不一樣,事實上,還更差一些。由於這裏須要使用兩個system call (select 和 recvfrom),而blocking IO只調用了一個system call (recvfrom)。可是,用select的優點在於它能夠同時處理多個connection。(多說一句。因此,若是處理的鏈接數不是很高的話,使用select/epoll的web server不必定比使用multi-threading + blocking IO的web server性能更好,可能延遲還更大。select/epoll的優點並非對於單個鏈接能處理得更快,而是在於能處理更多的鏈接。)
在IO multiplexing Model中,實際中,對於每個socket,通常都設置成爲non-blocking,可是,如上圖所示,整個用戶的process實際上是一直被block的。只不過process是被select這個函數block,而不是被socket IO給block。
因此,IO multiplexing Model的特色就是兩個階段都阻塞,可是等待數據阻塞在select上,拷貝數據阻塞在recfrom上。
在多路複用模型中,對於每個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。
4 signal driven I/O (SIGIO)
We can also use signals, telling the kernel to notify us with the SIGIO signal when the
descriptor is ready. We call this signal-driven I/O and show a summary of it in Figure 6.4.
We first enable the socket for signal-driven I/O (as we will describe in Section 25.2) and
install a signal handler using the sigaction system call. The return from this system call is
immediate and our process continues; it is not blocked. When the datagram is ready to be
read, the SIGIO signal is generated for our process. We can either read the datagram from
the signal handler by calling recvfrom and then notify the main loop that the data is ready
to be processed (this is what we will do in Section 25.3), or we can notify the main loop
and let it read the datagram.
Regardless of how we handle the signal, the advantage to this model is that we are not
blocked while waiting for the datagram to arrive. The main loop can continue executing and
just wait to be notified by the signal handler that either the data is ready to process or the
datagram is ready to be read.
因此,signal driven I/O 的特色就是第一個過程沒有阻塞,數據準備好的時候會經過SIGIO通知進程,拷貝數據阻塞在recfrom上,優勢是進程能夠繼續執行。
注: signal driven I/O 和 IO multiplexing Model 很像,只是一個阻塞,被動等待,一個會獲得通知。
5 Asynchronous I/O
linux下的asynchronous IO其實用得不多。先看一下它的流程:
用戶進程發起read操做以後,馬上就能夠開始去作其它的事。而另外一方面,從kernel的角度,當它受到一個asynchronous read以後,首先它會馬上返回,因此不會對用戶進程產生任何block。而後,kernel會等待數據準備完成,而後將數據拷貝到用戶內存,當這一切都完成以後,kernel會給用戶進程發送一個signal,告訴它read操做完成了。
blocking和non-blocking的區別
調用blocking IO會一直block住對應的進程直到操做完成,而non-blocking IO在kernel還準備數據的狀況下會馬上返回。但二者在從kernel拷貝數據到應用程序的時候都是阻塞的。、
synchronous IO和asynchronous IO的區別
在說明synchronous IO和asynchronous IO的區別以前,須要先給出二者的定義。Stevens給出的定義(實際上是POSIX的定義)是這樣子的:
A synchronous I/O operation causes the requesting process to be blocked until that I/O operationcompletes;
An asynchronous I/O operation does not cause the requesting process to be blocked;
二者的區別就在於synchronous IO作」IO operation」的時候會將process阻塞,IO operation 包括兩個過程:等待數據+數據拷貝。blocking和noblocking的數據拷貝都要阻塞,按照這個定義,以前所述的blocking IO,non-blocking IO,IO multiplexing都屬於synchronous IO。有人可能會說,non-blocking IO並無被block啊。這裏有個很是「狡猾」的地方,定義中所指的」IO operation」是指真實的IO操做,就是例子中的recvfrom這個system call。non-blocking IO在執行recvfrom這個system call的時候,若是kernel的數據沒有準備好,這時候不會block進程。可是,當kernel中數據準備好的時候,recvfrom會將數據從kernel拷貝到用戶內存中,這個時候進程是被block了,在這段時間內,進程是被block的。而asynchronous IO則不同,當進程發起IO 操做以後,就直接返回不再理睬了,直到kernel發送一個信號,告訴進程說IO完成。在這整個過程當中,進程徹底沒有被block。
各個IO Model的比較如圖所示:
通過上面的介紹,會發現non-blocking IO和asynchronous IO的區別仍是很明顯的。在non-blocking IO中,雖然進程大部分時間都不會被block,可是它仍然要求進程去主動的check,而且當數據準備完成之後,也須要進程主動的再次調用recvfrom來將數據拷貝到用戶內存。而asynchronous IO則徹底不一樣。它就像是用戶進程將整個IO操做交給了他人(kernel)完成,而後他人作完後發信號通知。在此期間,用戶進程不須要去檢查IO操做的狀態,也不須要主動的去拷貝數據。
參考文獻:
IO - 同步,異步,阻塞,非阻塞:http://blog.csdn.net/historyasamirror/article/details/5778378
使用事件驅動模型實現高效穩定的網絡服務器程序:http://www.ibm.com/developerworks/cn/linux/l-cn-edntwk/
select函數
1)函數做用:
容許進程指示內核等待多個事件中的一個發生, 並只在有一個或多個事件發生,或經過定時喚醒它。(前面說的同時處理socket描述符和等待用戶輸入就符合這個狀況)(Berkeley的實現容許任何描述符的I/O複用)
2)函數定義
#include <sys/time.h> #include <sys/select.h> int select (int maxfdp1, fd_set *readset, fd_set *writeset, fd_set exceptset, const struct timeval *timeout); struct timeval { long tv_sec; long tv_usec; }
注意select的返回值,是全部文件描述符中準備好的文件描述符的個數。
參數介紹:
timeout:告知內核等待所指定的描述符中任何一個就緒的時間。其有三種可能:
exceptset:目前支持的異常條件只有兩個
readset,writeset:咱們要讓內核讀和寫的描述符;
maxfdp1: 指定待測描述符的個數,具體值從0開始到maxfdp1-1(FD_SETSIZE常值爲fd_set描述符的總數)。
返回值:如有就緒描述符則爲其個數,超時返回0,出錯爲-1.
select能夠做爲定時器,此時中間三個描述符集設置爲NULL,這個定時器比sleep還有精確,sleep以秒爲單位,其以微妙爲單位。
3)fd_set類型的相關操做
fd_set rset; //注意新定義變量必定要用FD_ZERO初始化,其自動分配的值不可意料,會致使不可意料的後果。 void FD_ZERO(fd_set *fdset); //initialize the set: all bits off void FD_SET(int fd, fd_set *fdset); //turn on the bit for fd in fdset void FD_CLR(int fd, fd_set *fdset); //turn off the bits for fd in fdset int FD_ISSET(int fd, fd_set *fdset); //is the bit for fd on in fdset
4)套接字準備好的條件
a)該套接字接受緩衝區中的數據字節數大於等於套接字接受緩衝區低水位標記的當前大小。對這樣的套接字執行讀操做不會阻塞並將返回一個大於0的值(也
就是返回準備好讀入的數據)。咱們可使用SO_RCVLOWAT套接字選項設置該套接字的低水位標記。對於tcp和udp套接字而言,其默認值爲1。
b)該套接字的讀半部關閉(也就是接受了FIN的tcp鏈接)。對這樣的套接字的讀操做將不阻塞並返回0.(也就是返回EOF)
c)該套接字是一個監聽套接字(就是該套接字掉用過listen,在調用listen函數以後,一個套接字會從主動鏈接的套接字變身爲一個監聽套接字,默認是主動套接字)且已完成的鏈接數不爲0。對這樣的套接字的accept一般不阻塞。就是上面描述的「select() 也能探測來自客戶端的 connect() 行爲」(後邊可會發文介紹阻塞accept的一種時序條件)
d)其上有一個套接字錯誤待處理。對這樣的套接字的讀操做將不阻塞並返回-1(也就是返回一個錯誤),同時把errno設置成確切的錯誤條件。這樣待處理錯誤(pending error)也能夠經過指定SO_ERROR套接字選項調用getsockopt獲取並清除。
a)該套接字發送緩衝區中的可用空間字節數大於等於套接字發送緩衝區低水位標記的當前大小,而且或者該套接字已鏈接,或者該套接字不須要鏈接(如udp套接
字)。這意味着若是咱們把這樣的套接字設置成非阻塞,寫操做將不阻塞並返回一個正值(例如由傳輸層接受的字節數)。咱們可使用SO_SNDLOWAT套接字選項來設
置該套接字的低水位標記。對於tcp和udp而言,其默認值一般爲2048。
b)該鏈接的寫半部關閉。對這樣的套接字的寫操做將產生SIGPIPE信號。(鏈接創建,若某一端關閉鏈接,而另外一端仍然向它寫數據,第一次寫數據後會收到對端的RST響應,此後再寫數據,內核將向進程發出SIGPIPE信號,通知進程此鏈接已經斷開。而SIGPIPE信號的默認處理是終止程序,)
c) 使用非阻塞connect的套接字已創建鏈接,或者connect已經以失敗了結。(對於阻塞式套接字,調用connect函數將激發TCP的三次握手過程,並且僅在鏈接創建成功或者出錯時才返回;對於非阻塞式套接字,若是調用connect函數會之間返回-1(表示出錯),且錯誤爲EINPROGRESS,表示鏈接創建,創建啓動可是還沒有完成;若是返回0,則表示鏈接已經創建,這一般是在服務器和客戶在同一臺主機上時發生)
d) 其上有一個套接字錯誤待處理。對這樣的套接字的寫操做將不阻塞並返回-1(也就是返回一個錯誤),同時把errno設置成確切的錯誤條件。這些待處理的錯
誤也能夠經過指定SO_ERROR套接字選項調用getsockopt獲取並清除。
注意:當某個套接字上發生錯誤時,它將select標記爲便可讀又可寫。
select函數的例子,https://github.com/juniperdiego/Unix-network-programming-of-mine/tree/master/tcpserv03
void str_cli(FILE *fp, int sockfd) { char sendline[MAXLINE], recvline[MAXLINE]; while (Fgets(sendline, MAXLINE, fp) != NULL) { Writen(sockfd, sendline, strlen(sendline)); if (Readline(sockfd, recvline, MAXLINE) == 0) err_quit("str_cli: server terminated prematurely"); Fputs(recvline, stdout); } }
對於客戶端使用select 代替原來的機制(也就是上述代碼),使之可以檢測更多的文件描述符。
有三個條件經過套接口處理:
一、若是對方TCP發送數據,套接口就變爲可讀且read返回大於0的值(即數據的字節數)
二、若是對方TCP發送一個FIN(對方進程終止),套接口就變成爲刻度切read返回0(文件結束)
三、若是對方TCP發送一個RST(對方主機崩潰並從新啓動),套接口就變爲了可讀且read返回-1
參看下圖
固然,這裏只是講述了select函數的簡單實用,沒有考慮詳細的使用方法,好比,若是在輸入文件的時候爲批量輸入,也就是在輸入端保持着一直輸入的情況,在最後一個請求發送的時候,還會有接受沒有完成應答。輸入文件已經結束,可是輸入的文件結束符並不意味着咱們已經完成了從套接口的讀入,可能仍有請求在去往服務器的路上,或是在去往客戶的路上仍有應答。
咱們須要一種方法關閉TCP鏈接的一半,也就是說,咱們想給服務器發一個FIN,告訴咱們已完成了數據發送,但仍爲讀而開放套接口描述字。其實這個任務能夠由shutdown函數完成。關於這個函數的使用在這裏再也不講述。關閉網絡鏈接的方法爲close,可是有的限制能夠由shutdown來避免。