1、IO模型介紹
爲了更好地瞭解IO模型,咱們須要事先回顧下:同步、異步、阻塞、非阻塞html
同步(synchronous) IO和異步(asynchronous) IO,阻塞(blocking) IO和非阻塞(non-blocking)IO分別是什麼,到底有什麼區別?這個問題其實不一樣的人給出的答案均可能不一樣,好比wiki,就認爲asynchronous IO和non-blocking IO是一個東西。linux
本文討論的背景是Linux環境下的network IO。
本文最重要的參考文獻是Richard Stevens的「UNIX® Network Programming Volume 1, Third Edition: The Sockets Networking 」,6.2節「I/O Models 」,Stevens在這節中詳細說明了各類IO的特色和區別,若是英文夠好的話,推薦直接閱讀。Stevens的文風是有名的深刻淺出,因此不用擔憂看不懂。本文中的流程圖也是截取自參考文獻。程序員
Stevens在文章中一共比較了五種IO Model: web
- blocking IO 阻塞I/O
- nonblocking IO 非阻塞I/O
- IO multiplexing I/O多路複用
- signal driven IO 信號驅動I/O
- asynchronous IO 異步I/O
由signal driven IO(信號驅動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模型的區別就是在兩個階段上各有不一樣的狀況。tomcat
補充:服務器
一、輸入操做:read、readv、recv、recvfrom、recvmsg共5和函數,若是會阻塞狀態,則會經歷wait data和copy data兩個階段,若是設置爲非阻塞則在wait不到data時拋出異常。
二、輸出操做:write、writev、send、sendto、sendmsg共5個函數,在發送緩衝區滿了會阻塞在原地,若是設置爲非阻塞,則會拋出異常。
三、接收外來連接:accept,與輸入操做相似
四、發起外出連接:connect,與輸出操做相似
2、阻塞IO(blocking IO)
在linux中,默認狀況下全部的socket都是blocking,一個典型的讀操做流程大概是這樣:

當用戶進程調用了recvfrom這個系統調用,kernel就開始了IO的第一個階段:準備數據。對於network io來講,不少時候數據在一開始尚未到達(好比,尚未收到一個完整的UDP包),這個時候kernel就要等待足夠的數據到來。
而在用戶進程這邊,整個進程會被阻塞。當kernel一直等到數據準備好了,它就會將數據從kernel中拷貝到用戶內存,而後kernel返回結果,用戶進程才解除block的狀態,從新運行起來。
因此,blocking IO的特色就是在IO執行的兩個階段(等待數據和拷貝數據兩個階段)都被block了。
from socket import *
server = socket(AF_INET, SOCK_STREAM)
server.bind(('127.0.0.1', 8093))
server.listen(5)
while True: # 連接循環
print("starting....")
conn, addr = server.accept() # 等對方來連————阻塞(操做系統會將cpu拿走)
print(addr)
while True: # 通信循環
try:
data = conn.recv(1024) # 等待收消息————阻塞
if not data: break
conn.send(data.upper())
except ConnectionResetError:
break
conn.close()
server.close()
服務端
from socket import *
client = socket(AF_INET, SOCK_STREAM)
client.connect(("127.0.0.1", 8093))
while True:
msg = input(">>").strip()
if not msg:continue
client.send(msg.encode("utf-8"))
data = client.recv(1024)
print(data.decode("utf-8"))
client.close()
客戶端
以前學習的網絡編程都是從listen、send、recv等接口開始的,使用這些接口能夠很方便地構建服務器\客戶機模型。可是大部分的socket接口都是阻塞型的。

所謂阻塞型接口是指系統調用(通常是IO接口)不返回調用結果並讓當前線程一直阻塞,只有當該系統調用得到結果或超時出錯時才返回。
爲了能在調用recv(),線程被阻塞的同時,仍能響應其餘的網絡請求,可使用以前學習到的多線程(或多進程)來解決。
from socket import *
from threading import Thread
def communicate(conn):
while True: # 通信循環
try:
data = conn.recv(1024) # 等待收消息————阻塞
if not data: break
conn.send(data.upper())
except ConnectionResetError:
break
conn.close()
server = socket(AF_INET, SOCK_STREAM)
server.bind(('127.0.0.1', 8093))
server.listen(5)
while True: # 連接循環
print("starting....")
conn, addr = server.accept() # 等對方來連————阻塞(操做系統會將cpu拿走)
print(addr)
t=Thread(target=communicate, args=(conn,))
t.start()
server.close()
多線程服務端
上面這個方案存在的問題:開啓多進程或都線程的方式,在遇到要同時響應成百上千路的鏈接請求,則不管多線程仍是多進程都會嚴重佔據系統資源,下降系統對外界響應效率,並且線程與進程自己也更容易進入假死狀態。
進一步的改進方案:不少程序員會考慮使用「線程池」或「鏈接池」。
「線程池」旨在減小建立和銷燬線程的頻率,其維持必定合理數量的線程,並讓空閒的線程從新承擔新的執行任務。
「鏈接池」維持鏈接的緩存池,儘可能重用已有的鏈接、減小建立和關閉鏈接的頻率。
這兩種技術均可以很好的下降系統開銷,都被普遍應用不少大型系統,如websphere、tomcat和各類數據庫等。
新改進方案存在的問題:
「線程池」和「鏈接池」技術也只是在必定程度上緩解了頻繁調用IO接口帶來的資源佔用。並且,所謂「池」始終有其上限,當請求大大超過上限時,「池」構成的系統對外界的響應並不比沒有池的時候效果好多少。因此使用「池」必須考慮其面臨的響應規模,並根據響應規模調整「池」的大小。(規模過大,反而會下降效率)
總之,多線程模型能夠方便高效的解決小規模的服務請求,但面對大規模的服務請求,多線程模型也會遇到瓶頸,能夠用非阻塞接口來嘗試解決這個問題。
3、非阻塞IO(non-blocking IO)
Linux下,能夠經過設置socket使其變爲non-blocking。當對一個non-blocking socket執行讀操做時,流程是這個樣子:

當用戶進程發出read操做時,若是kernel中的數據尚未準備好,那麼它並不會block用戶進程,而是馬上返回一個error。從用戶進程角度講 ,它發起一個read操做後,並不須要等待,而是立刻就獲得了一個結果。用戶進程判斷結果是一個error時,它就知道數據尚未準備好,因而用戶就能夠在本次到下次再發起read詢問的時間間隔內作其餘事情,或者直接再次發送read操做。一旦kernel中的數據準備好了,而且又再次收到了用戶進程的system call,那麼它立刻就將數據拷貝到了用戶內存(這一階段仍然是阻塞的),而後返回。
能夠看出wait data這個階段就被利用上了,copy data是一個本地的操做,時間也比較短,效率獲得了很大提升。
也就是說非阻塞的recvform系統調用調用以後,進程並無被阻塞,內核立刻返回給進程,若是數據還沒準備好, 此時會返回一個error。進程在返回以後,能夠乾點別的事情,而後再發起recvform系統調用。重複上面的過程, 循環往復的進行recvform系統調用。這個過程一般被稱之爲輪詢。輪詢檢查內核數據,直到數據準備好,再拷貝數據到進程, 進行數據處理。須要注意,拷貝數據整個過程,進程仍然是屬於阻塞的狀態。
因此,在非阻塞式IO中,用戶進程實際上是須要不斷的主動詢問kernel數據準備好了沒有。
非阻塞IO示例:
server.setblocking(False) # 默認是True:阻塞,改成False:非阻塞,這行運行後,後面全部的IO操做變爲非阻塞。
from socket import *
server = socket(AF_INET, SOCK_STREAM)
server.bind(('127.0.0.1', 8093))
server.listen(5)
server.setblocking(False) # 默認是True:阻塞,改成False:非阻塞,這行運行後,後面全部的IO操做變爲非阻塞
print("starting....")
rlist = []
wlist = []
while True: # 連接循環
try:
conn, addr = server.accept() # 問操做系統有沒有來連接
rlist.append(conn)
print(rlist)
except BlockingIOError: # 沒有連接捕捉異常
# print("幹其餘活")
"""收消息"""
del_rlist = []
for conn in rlist:
try:
data = conn.recv(1024) # 收消息
if not data:
del_rlist.append(conn)
continue
# conn.send(data.upper()) # 傳輸的內容不少時,send也會有阻塞
wlist.append((conn, data.upper())) # 存放套接字及套接字準備發送的內容
except BlockingIOError: # 捕捉異常,跳過阻塞異常
continue
except Exception:
conn.close()
del_rlist.append(conn) # 要刪除對象加入空列表del_rlist
"""發消息"""
del_wlist = []
for item in wlist:
try:
conn = item[0]
data = item[1]
conn.send(data)
del_wlist.append(conn) # 正常,走到這一步
except BlockingIOError: # 沒讓發拋出異常
pass
for item in del_wlist:
wlist.remove(item)
for conn in del_rlist:
rlist.remove(conn)
server.close()
非阻塞IO-服務端
from socket import *
client = socket(AF_INET, SOCK_STREAM)
client.connect(("127.0.0.1", 8093))
while True:
msg = input(">>").strip()
if not msg:continue
client.send(msg.encode("utf-8"))
data = client.recv(1024)
print(data.decode("utf-8"))
client.close()
非阻塞IO-客戶端
總結非阻塞IO模型:
優勢:可以在等待任務完成的時間裏幹其餘活了(包括提交其餘任務,也就是 「後臺」 能夠有多個任務在「」同時「」執行)。
缺點:
一、循環調用recv()將大幅度推高CPU佔用率;這也是咱們在代碼中留一句time.sleep(2)的緣由,不然在低配主機下極容易出現卡機狀況
二、任務完成的響應延遲增大了,由於每過一段時間纔去輪詢一次read操做,而任務可能在兩次輪詢之間的任意時間完成。 這會致使總體數據吞吐量的下降。
三、此外,在這個方案中recv()更多的是起到檢測「操做是否完成」的做用,實際操做系統提供了更爲高效的檢測「操做是否完成「做用的接口,例如select()多路複用模式,能夠一次檢測多個鏈接是否活躍。
所以非阻塞IO模型不被推薦使用。
4、多路複用IO(IO multiplexing)
多路複用IO模型也有人稱爲IO多路複用,還有人稱爲事件驅動IO(event driven IO)。
select是多路複用IO模型的一種。select/epoll的好處就在於單個process就能夠同時處理多個網絡鏈接的IO。
它的基本原理就是select/epoll這個function會不斷的輪詢所負責的全部socket,當某個socket有數據到達了,就通知用戶進程。它的流程如圖:

當用戶進程調用了select,那麼整個進程會被block,而同時,kernel會「監視」全部select負責的socket, 當任何一個socket中的數據準備好了,select就會返回。這個時候用戶進程再調用read操做,將數據從kernel拷貝到用戶進程。 這個圖和blocking IO的圖其實並無太大的不一樣,事實上還更差一些。由於這裏須要使用兩個系統調用\(select和recvfrom\), 而blocking IO只調用了一個系統調用\(recvfrom\)。可是,用select的優點在於它能夠同時處理多個connection。
強調:
1. 若是處理的鏈接數不是很高的話,使用select/epoll的web server不必定比使用multi-threading + blocking IO的web server性能更好,可能延遲還更大。select/epoll的優點並非對於單個鏈接能處理得更快,而是在於能處理更多的鏈接。
2. 在多路複用模型中,對於每個socket,通常都設置成爲non-blocking,可是,如上圖所示,整個用戶的process實際上是一直被block的。只不過process是被select這個函數block,而不是被socket IO給block。
結論: select的優點在於能夠處理多個鏈接,不適用於單個鏈接
多路複用IO-服務端
多路複用IO-客戶端
select監聽fd變化過程分析:
用戶進程建立socket對象,拷貝監聽的fd到內核空間,每個fd會對應一張系統文件表,內核空間的fd響應到數據後, 就會發送信號給用戶進程數據已到; 用戶進程再發送系統調用,好比(accept)將內核空間的數據copy到用戶空間,同時做爲接受數據端內核空間的數據清除, 這樣從新監聽時fd再有新的數據又能夠響應到了(發送端由於基於TCP協議因此須要收到應答後纔會清除)。
模型優勢:
相比其餘模型,使用select() 的事件驅動模型只用單線程(進程)執行,佔用資源少,不消耗太多 CPU,同時可以爲多客戶端提供服務。 若是試圖創建一個簡單的事件驅動的服務器程序,這個模型有必定的參考價值。
模型缺點:
首先select()接口並非實現「事件驅動」的最好選擇。由於當須要探測的句柄值較大時,select()接口自己須要消耗大量時間去輪詢各個句柄。 不少操做系統提供了更爲高效的接口,如linux提供了epoll,BSD提供了kqueue,Solaris提供了/dev/poll,…。 若是須要實現更高效的服務器程序,相似epoll這樣的接口更被推薦。遺憾的是不一樣的操做系統特供的epoll接口有很大差別, 因此使用相似於epoll的接口實現具備較好跨平臺能力的服務器會比較困難。
其次,該模型將事件探測和事件響應夾雜在一塊兒,一旦事件響應的執行體龐大,則對整個模型是災難性的。
一、select、poll、epoll 模型區別
select,poll,epoll都是IO多路複用的機制。I/O多路複用就經過一種機制,能夠監視多個描述符,一旦某個描述符就緒(通常是讀就緒或者寫就緒),可以通知程序進行相應的讀寫操做。但select,poll,epoll本質上都是同步I/O,由於他們都須要在讀寫事件就緒後本身負責進行讀寫,也就是說這個讀寫過程是阻塞的,而異步I/O則無需本身負責進行讀寫,異步I/O的實現會負責把數據從內核拷貝到用戶空間。
(1)select的缺點:
1)單個進程可以監視的文件描述符的數量存在最大限制,一般是1024,固然能夠更改數量,但因爲select採用輪詢的方式掃描文件描述符,文件描述符數量越多,性能越差;
2)內核/用戶空間內存拷貝問題,select須要複製大量的句柄數據結構,產生巨大的開銷
3)select返回的是含有整個句柄的數組,應用程序須要遍歷整個數組才能發現哪些句柄發生了事件;
4)select的觸發方式是水平觸發,應用程序若是沒有完成對一個已經就緒的文件描述符進行IO,那麼以後再次select調用仍是會將這些文件描述符通知進程。
(2)poll的優劣:
相比於select模型,poll使用鏈表保存文件描述符,所以沒有了監視文件數量的限制,但其餘三個缺點依然存在。
(3)epoll特性:
epoll的實現機制與select/poll機制徹底不一樣,上面所說的select的缺點在epoll上不復存在。 epoll的設計和實現select徹底不一樣。
epoll經過在linux內核中申請一個簡易的文件系統(文件系統通常用什麼數據結構實現?B+樹)。把原先的select/poll調用分紅了3個部分:
1)調用epoll_create()創建一個epoll對象(在epoll文件系統中爲這個句柄對象分配資源)
2)調用epoll_ctl向epoll對象中添加這100萬個鏈接的套接字
3)調用epoll_wait收集發生的事件的鏈接 如此一來,要實現上面說的場景,只須要在進程啓動時創建一個epoll對象,而後在須要的時候向這個epoll對象中添加或者刪除鏈接。
5、異步IO(Asynchronous I/O)
Linux下的asynchronous IO其實用得很少,從內核2.6版本纔開始引入。先看一下它的流程:

用戶進程發起read操做以後,馬上就能夠開始去作其它的事。而另外一方面,從kernel的角度,當它受到一個asynchronous read以後,首先它會馬上返回,因此不會對用戶進程產生任何block。而後,kernel會等待數據準備完成,而後將數據拷貝到用戶內存,當這一切都完成以後(wait data和copy data都完成後),kernel會給用戶進程發送一個signal,告訴它read操做完成了。
異步IO具體實如今後面爬蟲項目中講解。
6、IO模型比較分析
一、blocking和non-blocking的區別在哪?
調用blocking IO會一直block住對應的進程直到操做完成,而non-blocking IO在kernel還準備數據的狀況下會馬上返回。
二、synchronous IO(同步IO)和asynchronous IO(異步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模型能夠分爲兩大類, 以前所述的blocking IO,non-blocking IO,IO multiplexing都屬於synchronous IO這一類,而 asynchronous I/O後一類 。
三、non-blocking IO和asynchronous 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 模型比較如圖所示:

通過上面的介紹,會發現non-blocking IO和asynchronous IO的區別仍是很明顯的。在non-blocking IO中,雖然進程大部分時間都不會被block,可是它仍然要求進程去主動的check,而且當數據準備完成之後,也須要進程主動的再次調用recvfrom來將數據拷貝到用戶內存。而asynchronous IO則徹底不一樣。它就像是用戶進程將整個IO操做交給了他人(kernel)完成,而後他人作完後發信號通知。在此期間,用戶進程不須要去檢查IO操做的狀態,也不須要主動的去拷貝數據。