目錄html
爲了更好地瞭解I/O模型,咱們須要事先回顧下:同步、異步、阻塞、非阻塞python
同步(synchronous) I/O和異步(asynchronous) I/O,阻塞(blocking) I/O和非阻塞(non-blocking)I/O分別是什麼,到底有什麼區別?這個問題其實不一樣的人給出的答案均可能不一樣,好比wiki,就認爲asynchronous I/O和non-blocking I/O是一個東西。這實際上是由於不一樣的人的知識背景不一樣,而且在討論這個問題的時候上下文(context)也不相同。因此,爲了更好的回答這個問題,我先限定一下本文的上下文。linux
本文討論的背景是Linux環境下的network I/O。本文最重要的參考文獻是Richard Stevens的「UNIX® Network Programming Volume 1, Third EditI/On: The Sockets Networking 」,6.2節「I/O Models 」,Stevens在這節中詳細說明了各類I/O的特色和區別,若是英文夠好的話,推薦直接閱讀。Stevens的文風是有名的深刻淺出,因此不用擔憂看不懂。本文中的流程圖也是截取自參考文獻。程序員
Stevens在文章中一共比較了五種I/O Model:web
英文 | 中文 |
---|---|
blocking I/O | 阻塞I/O |
nonblocking I/O | 非阻塞I/O |
I/O multiplexing | I/O多路複用 |
signal driven I/O | 信號驅動I/O |
asynchronous I/O | 異步I/O |
再說一下I/O發生時涉及的對象和步驟。對於一個network I/O (這裏咱們以read舉例),它會涉及到兩個系統對象,一個是調用這個I/O的process (or thread),另外一個就是系統內核(kernel)。當一個read操做發生時,該操做會經歷兩個階段:數據庫
記住這兩點很重要,由於這些I/O模型的區別就是在兩個階段上各有不一樣的狀況。編程
在網絡環境下,再通俗的講,將I/O分爲兩步:數組
若是要想提升I/O效率,須要將等的時間下降。緩存
五種I/O模型包括:阻塞I/O、非阻塞I/O、信號驅動I/O、I/O多路轉接、異步I/O。其中,前四個被稱爲同步I/O。tomcat
在介紹五種I/O模型時,我會舉生活中老王買車票的例子,加深理解。
以買票的例子舉例,該模型小結爲:
# 老王去火車站買票,排隊三天買到一張退票。 # 耗費:在車站吃喝拉撒睡 3天,其餘事一件沒幹。
在linux中,默認狀況下全部的socket都是blocking,一個典型的讀操做流程大概是這樣:
當用戶進程調用了recvfrom這個系統調用,kernel就開始了I/O的第一個階段:準備數據。對於network I/O來講,不少時候數據在一開始尚未到達(好比,尚未收到一個完整的UDP包),這個時候kernel就要等待足夠的數據到來。
而在用戶進程這邊,整個進程會被阻塞。當kernel一直等到數據準備好了,它就會將數據從kernel中拷貝到用戶內存,而後kernel返回結果,用戶進程才解除block的狀態,從新運行起來。因此,blocking I/O的特色就是在I/O執行的兩個階段(等待數據和拷貝數據兩個階段)都被block了。
幾乎全部的程序員第一次接觸到的網絡編程都是從listen()、send()、recv() 等接口開始的,使用這些接口能夠很方便的構建服務器/客戶機的模型。然而大部分的socket接口都是阻塞型的。以下圖
ps:所謂阻塞型接口是指系統調用(通常是I/O接口)不返回調用結果並讓當前線程一直阻塞,只有當該系統調用得到結果或者超時出錯時才返回。
實際上,除非特別指定,幾乎全部的I/O接口 ( 包括socket接口 ) 都是阻塞型的。這給網絡編程帶來了一個很大的問題,如在調用recv(1024)的同時,線程將被阻塞,在此期間,線程將沒法執行任何運算或響應任何的網絡請求。
在服務器端使用多線程(或多進程)。多線程(或多進程)的目的是讓每一個鏈接都擁有獨立的線程(或進程),這樣任何一個鏈接的阻塞都不會影響其餘的鏈接。
開啓多進程或都線程的方式,在遇到要同時響應成百上千路的鏈接請求,則不管多線程仍是多進程都會嚴重佔據系統資源,下降系統對外界響應效率,並且線程與進程自己也更容易進入假死狀態。
不少程序員可能會考慮使用「線程池」或「鏈接池」。「線程池」旨在減小建立和銷燬線程的頻率,其維持必定合理數量的線程,並讓空閒的線程從新承擔新的執行任務。「鏈接池」維持鏈接的緩存池,儘可能重用已有的鏈接、減小建立和關閉鏈接的頻率。這兩種技術均可以很好的下降系統開銷,都被普遍應用不少大型系統,如websphere、tomcat和各類數據庫等。
「線程池」和「鏈接池」技術也只是在必定程度上緩解了頻繁調用I/O接口帶來的資源佔用。並且,所謂「池」始終有其上限,當請求大大超過上限時,「池」構成的系統對外界的響應並不比沒有池的時候效果好多少。因此使用「池」必須考慮其面臨的響應規模,並根據響應規模調整「池」的大小。
對應上例中的所面臨的可能同時出現的上千甚至上萬次的客戶端請求,「線程池」或「鏈接池」或許能夠緩解部分壓力,可是不能解決全部問題。總之,多線程模型能夠方便高效的解決小規模的服務請求,但面對大規模的服務請求,多線程模型也會遇到瓶頸,能夠用非阻塞接口來嘗試解決這個問題。
以買票的例子舉例,該模型小結爲
# 老王去火車站買票,隔12小時去火車站問有沒有退票,三天後買到一張票。 # 耗費:往返車站6次,路上6小時,其餘時間作了好多事。
Linux下,能夠經過設置socket使其變爲non-blocking。當對一個non-blocking socket執行讀操做時,流程是這個樣子:
從圖中能夠看出,當用戶進程發出read操做時,若是kernel中的數據尚未準備好,那麼它並不會block用戶進程,而是馬上返回一個error。從用戶進程角度講 ,它發起一個read操做後,並不須要等待,而是立刻就獲得了一個結果。用戶進程判斷結果是一個error時,它就知道數據尚未準備好,因而用戶就能夠在本次到下次再發起read詢問的時間間隔內作其餘事情,或者直接再次發送read操做。一旦kernel中的數據準備好了,而且又再次收到了用戶進程的system call,那麼它立刻就將數據拷貝到了用戶內存(這一階段仍然是阻塞的),而後返回。
也就是說非阻塞的recvform系統調用調用以後,進程並無被阻塞,內核立刻返回給進程,若是數據還沒準備好,此時會返回一個error。進程在返回以後,能夠乾點別的事情,而後再發起recvform系統調用。重複上面的過程,循環往復的進行recvform系統調用。這個過程一般被稱之爲輪詢。輪詢檢查內核數據,直到數據準備好,再拷貝數據到進程,進行數據處理。須要注意,拷貝數據整個過程,進程仍然是屬於阻塞的狀態。因此,在非阻塞式I/O中,用戶進程實際上是須要不斷的主動詢問kernel數據準備好了沒有。
#服務端 from socket import * import time s=socket(AF_INET,SOCK_STREAM) s.bind(('127.0.0.1',8080)) s.listen(5) s.setblocking(False) #設置socket的接口爲非阻塞 conn_l=[] del_l=[] while True: try: conn,addr=s.accept() conn_l.append(conn) except BlockingI/OError: print(conn_l) for conn in conn_l: try: data=conn.recv(1024) if not data: del_l.append(conn) continue conn.send(data.upper()) except BlockingI/OError: pass except ConnectI/OnResetError: del_l.append(conn) for conn in del_l: conn_l.remove(conn) conn.close() del_l=[] #客戶端 from socket import * c=socket(AF_INET,SOCK_STREAM) c.connect(('127.0.0.1',8080)) while True: msg=input('>>: ') if not msg:continue c.send(msg.encode('utf-8')) data=c.recv(1024) print(data.decode('utf-8'))
可是非阻塞I/O模型毫不被推薦。
咱們不可否則其優勢:可以在等待任務完成的時間裏幹其餘活了(包括提交其餘任務,也就是 「後臺」 能夠有多個任務在「」同時「」執行)。
可是也難掩其缺點:
此外,在這個方案中recv()更多的是起到檢測「操做是否完成」的做用,實際操做系統提供了更爲高效的檢測「操做是否完成「做用的接口,例如select()多路複用模式,能夠一次檢測多個鏈接是否活躍。
以買票的例子舉例,select/poll模型小結爲:
1. # 老王去火車站買票,委託黃牛,而後每隔6小時電話黃牛詢問,黃牛三天內買到票,而後老王去火車站交錢領票。 # 耗費:往返車站2次,路上2小時,黃牛手續費100元,打電話17次
I/O multiplexing這個詞可能有點陌生,可是若是我說select/poll,大概就都能明白了。有些地方也稱這種I/O方式爲事件驅動I/O(event driven I/O)。咱們都知道,select/poll的好處就在於單個process就能夠同時處理多個網絡鏈接的I/O。它的基本原理就是select/poll這個functI/On會不斷的輪詢所負責的全部socket,當某個socket有數據到達了,就通知用戶進程。它的流程如圖:
當用戶進程調用了select,那麼整個進程會被block,而同時,kernel會「監視」全部select負責的socket,當任何一個socket中的數據準備好了,select就會返回。這個時候用戶進程再調用read操做,將數據從kernel拷貝到用戶進程。
這個圖和blocking I/O的圖其實並無太大的不一樣,事實上還更差一些。由於這裏須要使用兩個系統調用(select和recvfrom),而blocking I/O只調用了一個系統調用(recvfrom)。可是,用select的優點在於它能夠同時處理多個connectI/On。
強調:
在多路複用模型中,對於每個socket,通常都設置成爲non-blocking,可是,如上圖所示,整個用戶的process實際上是一直被block的。只不過process是被select這個函數block,而不是被socket I/O給block。
結論: select的優點在於能夠處理多個鏈接,不適用於單個鏈接
#服務端 from socket import * import select s=socket(AF_INET,SOCK_STREAM) s.setsockopt(SOL_SOCKET,SO_REUSEADDR,1) s.bind(('127.0.0.1',8081)) s.listen(5) s.setblocking(False) #設置socket的接口爲非阻塞 read_l=[s,] while True: r_l,w_l,x_l=select.select(read_l,[],[]) print(r_l) for ready_obj in r_l: if ready_obj == s: conn,addr=ready_obj.accept() #此時的ready_obj等於s read_l.append(conn) else: try: data=ready_obj.recv(1024) #此時的ready_obj等於conn if not data: ready_obj.close() read_l.remove(ready_obj) continue ready_obj.send(data.upper()) except ConnectI/OnResetError: ready_obj.close() read_l.remove(ready_obj) #客戶端 from socket import * c=socket(AF_INET,SOCK_STREAM) c.connect(('127.0.0.1',8081)) while True: msg=input('>>: ') if not msg:continue c.send(msg.encode('utf-8')) data=c.recv(1024) print(data.decode('utf-8'))
用戶進程建立socket對象,拷貝監聽的fd到內核空間,每個fd會對應一張系統文件表,內核空間的fd響應到數據後,就會發送信號給用戶進程數據已到;
用戶進程再發送系統調用,好比(accept)將內核空間的數據copy到用戶空間,同時做爲接受數據端內核空間的數據清除,這樣從新監聽時fd再有新的數據又能夠響應到了(發送端由於基於TCP協議因此須要收到應答後纔會清除)。
相比其餘模型,使用select() 的事件驅動模型只用單線程(進程)執行,佔用資源少,不消耗太多 CPU,同時可以爲多客戶端提供服務。若是試圖創建一個簡單的事件驅動的服務器程序,這個模型有必定的參考價值。
以買票的例子舉例,epoll模型小結爲:
# 老王去火車站買票,委託黃牛,黃牛買到後即通知老王去領,而後老王去火車站交錢領票。 # 耗費:往返車站2次,路上2小時,黃牛手續費100元,無需打電話
I/O多路複用這個概念被提出來之後, select是第一個實現 (1983 左右在BSD裏面實現)。可是,select模型有着一個很大的問題,那就是它所支持的fd的數量是有限制的,從linux源碼中所看:
它所須要的fd_set類型實際上是一個__FD_SETSIZE長度bit的數組。也就是說經過FD_SET宏來對它進行操做的時候,須要注意socket不能過大,不然可能出現數組寫越界的錯誤。
而linux在2.6 內核中引入的epoll模型,就完全解決了這個問題,因爲目前epoll模型只能在linux中使用,所以咱們開發僅作了解便可。
epoll模型提供了三個接口:
其使用流程基本與select一致,大體以下:
epoll_create
函數會爲要監聽的fd分配內存。
epoll_ctl
函數將要監聽的fd拷貝到內核空間,從而避免每次等待事件都要進行內存拷貝。同時,註冊一個回調函數到 fd的設備等待隊列中,這樣,當設備就緒的時候,驅動程序能夠直接調用回調函數進行處理,從而避免了對全部監聽fd的輪循。
epoll_wait
函數會檢查是否已經有fd就緒了,若是有則直接返回,若是沒有,則進入休眠狀態,直到被上述的回調函數喚醒或者超時時間到達。
以買票的例子舉例,該模型小結爲:
# 老王去火車站買票,給售票員留下電話,有票後,售票員電話通知老王,而後老王去火車站交錢領票。 # 耗費:往返車站2次,路上2小時,免黃牛費100元,無需打電話
因爲信號驅動I/O在實際中並不經常使用,因此咱們只作簡單瞭解。
信號驅動I/O模型,應用進程告訴內核:當數據報準備好的時候,給我發送一個信號,對SIGI/O信號進行捕捉,而且調用個人信號處理函數來獲取數據報。
以買票的例子舉例,該模型小結爲:
# 老王去火車站買票,給售票員留下電話,有票後,售票員電話通知老王並快遞送票上門。 # 耗費:往返車站1次,路上1小時,免黃牛費100元,無需打電話
Linux下的asynchronous I/O其實用得很少,從內核2.6版本纔開始引入。先看一下它的流程:
用戶進程發起read操做以後,馬上就能夠開始去作其它的事。而另外一方面,從kernel的角度,當它受到一個asynchronous read以後,首先它會馬上返回,因此不會對用戶進程產生任何block。而後,kernel會等待數據準備完成,而後將數據拷貝到用戶內存,當這一切都完成以後,kernel會給用戶進程發送一個signal,告訴它read操做完成了。
到目前爲止,已經將四個 I/O Model都介紹完了。如今回過頭來回答最初的那幾個問題:阻塞和非阻塞的區別在哪,同步I/O 和 異步I/O 的區別在哪。
先回答最簡單的這個:阻塞 vs 非阻塞。前面的介紹中其實已經很明確的說明了這二者的區別。調用 阻塞I/O 會一直block住對應的進程直到操做完成,而 非阻塞I/O 在kernel還準備數據的狀況下會馬上返回。
再說明同步I/O 和 異步I/O 的區別以前,須要先給出二者的定義。Stevens給出的定義(實際上是POSIX的定義)是這樣子的:
A synchronous I/O operatI/O n causes the requesting process to be blocked until that I/O operatI/O ncompletes;
An asynchronous I/O operatI/O n does not cause the requesting process to be blocked;
二者的區別就在於 同步I/O 作」I/O operatI/O n」的時候會將process阻塞。按照這個定義,四個I/O模型能夠分爲兩大類,以前所述的 阻塞I/O , 非阻塞I/O ,I/O多路複用 都屬於 同步I/O 這一類,而 異步I/O 屬於後一類。
有人可能會說, 非阻塞I/O 並無被block啊。這裏有個很是「狡猾」的地方,定義中所指的」I/O operatI/O n」是指真實的I/O操做,就是例子中的recvfrom這個system call。 非阻塞I/O 在執行recvfrom這個system call的時候,若是kernel的數據沒有準備好,這時候不會block進程。可是,當kernel中數據準備好的時候,recvfrom會將數據從kernel拷貝到用戶內存中,這個時候進程是被block了,在這段時間內,進程是被block的。而 異步I/O 則不同,當進程發起I/O操做以後,就直接返回不再理睬了,直到kernel發送一個信號,告訴進程說I/O完成。在這整個過程當中,進程徹底沒有被block。
各個I/O Model的比較如圖所示:
通過上面的介紹,會發現 非阻塞I/O 和 異步I/O 的區別仍是很明顯的。在非阻塞I/O 中,雖然進程大部分時間都不會被block,可是它仍然要求進程去主動的check,而且當數據準備完成之後,也須要進程主動的再次調用recvfrom來將數據拷貝到用戶內存。而 異步I/O 則徹底不一樣。它就像是用戶進程將整個I/O操做交給了他人(kernel)完成,而後他人作完後發信號通知。在此期間,用戶進程不須要去檢查I/O操做的狀態,也不須要主動的去拷貝數據。
能夠看出,以上五個模型的阻塞程度由低到高爲:阻塞I/O>非阻塞I/O>多路轉接I/O>信號驅動I/O>異步I/O,所以他們的效率是由低到高的。