IO模型
-
IO模型介紹
-
阻塞IO(blocking IO)
-
非阻塞IO(non-blocking IO)
-
多路複用IO(IO multiplexing)
-
異步IO(Asynchronous I/O)
-
IO模型比較分析
-
selectors模塊
一 IO模型介紹
本文討論的背景是Linux環境下的network IO。html
在此背景下,有5類IO:linux
* blocking IO
* nonblocking IO
* IO multiplexing
* signal driven IO
* asynchronous IO
由signal driven IO(信號驅動IO)在實際中並不經常使用,因此主要介紹其他四種IO Model。程序員
在開始介紹四種IO模型以前,回顧一下同步、異步、阻塞、非阻塞。web
同步:數據庫
# 所謂同步,就是在發出一個功能調用時,在沒有獲得結果以前,該調用就不會返回。按照這個定義,其實絕大多數函數都是同步調用。可是通常而言,咱們在說同步、異步的時候,特指那些須要其餘部件協做或者須要必定時間完成的任務。 須要搞清楚同步和阻塞IO, 同步無論是IO仍是計算都會停在原地。 # 栗子 #1. multiprocessing.Pool下的apply #發起同步調用後,就在原地等着任務結束,根本不考慮任務是在計算仍是在io阻塞,總之就是一股腦地等任務結束 #2. concurrent.futures.ProcessPoolExecutor().submit(func,).result() #3. concurrent.futures.ThreadPoolExecutor().submit(func,).result()
異步:編程
# 異步的概念和同步相對。當一個異步功能調用發出後,調用者不能馬上獲得結果。當該異步功能完成後,經過狀態,通知或回調來通知調用者。若是異步功能用狀態來通知,那麼調用者就須要每隔必定時間檢查一次,效率很低(多路複用select)。若是是使用通知的方式,效率則很高,由於異步功能幾乎不須要作額外的操做。至於回調函數,其實和通知沒多大區別。 # 栗子 #1. multiprocessing.Pool().apply_async() #發起異步調用後,並不會等待任務結束才返回,相反,會當即獲取一個臨時結果(並非最終的結果,多是封裝好的一個對象)。 #2. concurrent.futures.ProcessPoolExecutor(3).submit(func,) #3. concurrent.futures.ThreadPoolExecutor(3).submit(func,)
阻塞:windows
# 阻塞調用是指調用結果返回以前,當前線程會被掛起(遇到IO,CPU被剝奪走)。函數只有在獲得結果以後纔會將阻塞的線程激活。跟同步調用對比,同步調用時的線程仍是激活的,只是從邏輯上函數沒有返回而已。 # 栗子 #1. 同步調用:apply一個累計1億次的任務,該調用會一直等待,直到任務返回結果爲止,但並未阻塞住(即使是被搶走cpu的執行權限,那也是處於就緒態); #2. 阻塞調用:當socket工做在阻塞模式的時候,若是沒有數據的狀況下調用recv函數,則當前線程就會被掛起,直到有數據爲止。
非阻塞:數組
# 非阻塞和阻塞的概念相反,指在不能馬上獲得結果以前也會馬上返回,同事該函數不會阻塞當前線程。
小結:緩存
#1. 同步與異步針對的是函數/任務的調用方式:同步就是當一個進程發起一個函數(任務)調用的時候,一直等到函數(任務)完成,而進程繼續處於激活狀態。而異步狀況下是當一個進程發起一個函數(任務)調用的時候,不會等函數返回,而是繼續往下執行當,函數返回的時候經過狀態、通知、事件等方式通知進程任務完成。 #2. 阻塞與非阻塞針對的是進程或線程:阻塞是當請求不能知足的時候就將進程掛起,而非阻塞則不會阻塞當前進程
對於一個network IO (這裏咱們以read舉例),它會涉及到兩個系統對象,一個是調用這個IO的process (or thread),另外一個就是系統內核(kernel)。當一個read操做發生時,該操做會經歷兩個階段:tomcat
#1 等待數據階段 (等待數據通過一些列網絡延遲以後發送到內核空間) #2 拷貝數據階段 (將數據從內核拷貝到進程中)
二 阻塞IO(blocking IO)
在linux中,默認狀況下全部的socket都是blocking,一個典型的讀操做流程大概是這樣:
當用戶進程調用了recvfrom這個系統調用,kernel就開始了IO的第一個階段:準備數據。對於network io來講,不少時候數據在一開始尚未到達(好比,尚未收到一個完整的UDP包),這個時候kernel就要等待足夠的數據到來。
而在用戶進程這邊,整個進程會被阻塞。當kernel一直等到數據準備好了,它就會將數據從kernel中拷貝到用戶內存,而後kernel返回結果,用戶進程才解除block的狀態,從新運行起來。
因此,blocking IO的特色就是在IO執行的兩個階段(等待數據和拷貝數據兩個階段)都被block了。
實際上,除非特別指定,幾乎全部的IO接口 ( 包括socket接口 ) 都是阻塞型的。這給網絡編程帶來了一個很大的問題,如在調用recv(1024)的同時,線程將被阻塞,在此期間,線程將沒法執行任何運算或響應任何的網絡請求 一個簡單的解決方案: #在服務器端使用多線程(或多進程)。多線程(或多進程)的目的是讓每一個鏈接都擁有獨立的線程(或進程),這樣任何一個鏈接的阻塞都不會影響其餘的鏈接。 該方案的問題是: #開啓多進程或都線程的方式,在遇到要同時響應成百上千路的鏈接請求,則不管多線程仍是多進程都會嚴重佔據系統資源,下降系統對外界響應效率,並且線程與進程自己也更容易進入假死狀態。 改進方案: #不少程序員可能會考慮使用「線程池」或「鏈接池」。「線程池」旨在減小建立和銷燬線程的頻率,其維持必定合理數量的線程,並讓空閒的線程從新承擔新的執行任務。「鏈接池」維持鏈接的緩存池,儘可能重用已有的鏈接、減小建立和關閉鏈接的頻率。這兩種技術均可以很好的下降系統開銷,都被普遍應用不少大型系統,如websphere、tomcat和各類數據庫等。 改進後方案其實也存在着問題: #「線程池」和「鏈接池」技術也只是在必定程度上緩解了頻繁調用IO接口帶來的資源佔用。並且,所謂「池」始終有其上限,當請求大大超過上限時,「池」構成的系統對外界的響應並不比沒有池的時候效果好多少。因此使用「池」必須考慮其面臨的響應規模,並根據響應規模調整「池」的大小。
對應上例中的所面臨的可能同時出現的上千甚至上萬次的客戶端請求,「線程池」或「鏈接池」或許能夠緩解部分壓力,可是不能解決全部問題。總之,多線程模型能夠方便高效的解決小規模的服務請求,但面對大規模的服務請求,多線程模型也會遇到瓶頸,能夠用非阻塞接口來嘗試解決這個問題。
三 非阻塞IO(non-blocking IO)
Linux下,能夠經過設置socket使其變爲non-blocking。當對一個non-blocking socket執行讀操做時,流程是這個樣子:
從圖中能夠看出,當用戶進程發出read操做時,若是kernel中的數據尚未準備好,那麼它並不會block用戶進程,而是馬上返回一個error。從用戶進程角度講 ,它發起一個read操做後,並不須要等待,而是立刻就獲得了一個結果。用戶進程判斷結果是一個error時,它就知道數據尚未準備好,因而用戶就能夠在本次到下次再發起read詢問的時間間隔內作其餘事情,或者直接再次發送read操做。一旦kernel中的數據準備好了,而且又再次收到了用戶進程的system call,那麼它立刻就將數據拷貝到了用戶內存(這一階段仍然是阻塞的),而後返回。
也就是說非阻塞的recvform系統調用調用以後,進程並無被阻塞,內核立刻返回給進程,若是數據還沒準備好,此時會返回一個error。進程在返回以後,能夠乾點別的事情,而後再發起recvform系統調用。重複上面的過程,循環往復的進行recvform系統調用。這個過程一般被稱之爲輪詢。輪詢檢查內核數據,直到數據準備好,再拷貝數據到進程,進行數據處理。須要注意,拷貝數據整個過程,進程仍然是屬於阻塞的狀態。
因此,在非阻塞式IO中,用戶進程實際上是須要不斷的主動詢問kernel數據準備好了沒有。
# 服務端 import socket import time server=socket.socket() server.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) server.bind(('127.0.0.1',8083)) server.listen(5) server.setblocking(False) r_list=[] w_list={} while 1: try: conn,addr=server.accept() r_list.append(conn) except BlockingIOError: # 強調強調強調:!!!非阻塞IO的精髓在於徹底沒有阻塞!!! # time.sleep(0.5) # 打開該行註釋純屬爲了方便查看效果 print('在作其餘的事情') print('rlist: ',len(r_list)) print('wlist: ',len(w_list)) # 遍歷讀列表,依次取出套接字讀取內容 del_rlist=[] for conn in r_list: try: data=conn.recv(1024) if not data: conn.close() del_rlist.append(conn) continue w_list[conn]=data.upper() except BlockingIOError: # 沒有收成功,則繼續檢索下一個套接字的接收 continue except ConnectionResetError: # 當前套接字出異常,則關閉,而後加入刪除列表,等待被清除 conn.close() del_rlist.append(conn) # 遍歷寫列表,依次取出套接字發送內容 del_wlist=[] for conn,data in w_list.items(): try: conn.send(data) del_wlist.append(conn) except BlockingIOError: continue # 清理無用的套接字,無需再監聽它們的IO操做 for conn in del_rlist: r_list.remove(conn) for conn in del_wlist: w_list.pop(conn) #客戶端 import socket import os client=socket.socket() client.connect(('127.0.0.1',8083)) while 1: res=('%s hello' %os.getpid()).encode('utf-8') client.send(res) data=client.recv(1024) print(data.decode('utf-8')) 非阻塞IO示例
可是非阻塞IO模型毫不被推薦。
咱們不可否則其優勢:可以在等待任務完成的時間裏幹其餘活了(包括提交其餘任務,也就是 「後臺」 能夠有多個任務在「」同時「」執行)。
可是也難掩其缺點:
#1. 循環調用recv()將大幅度推高CPU佔用率;這也是咱們在代碼中留一句time.sleep(2)的緣由,不然在低配主機下極容易出現卡機狀況 #2. 任務完成的響應延遲增大了,由於每過一段時間纔去輪詢一次read操做,而任務可能在兩次輪詢之間的任意時間完成。這會致使總體數據吞吐量的下降。
四 多路複用IO(IO multiplexing)
IO multiplexing這個詞可能有點陌生,可是若是我說select/epoll,大概就都能明白了。有些地方也稱這種IO方式爲事件驅動IO(event driven 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的優點在於能夠處理多個鏈接,不適用於單個鏈接
#服務端 from socket import * import select server = socket(AF_INET, SOCK_STREAM) server.bind(('127.0.0.1',8093)) server.listen(5) server.setblocking(False) print('starting...') rlist=[server,] wlist=[] wdata={} while True: rl,wl,xl=select.select(rlist,wlist,[],0.5) print(wl) for sock in rl: if sock == server: conn,addr=sock.accept() rlist.append(conn) else: try: data=sock.recv(1024) if not data: sock.close() rlist.remove(sock) continue wlist.append(sock) wdata[sock]=data.upper() except Exception: sock.close() rlist.remove(sock) for sock in wl: sock.send(wdata[sock]) wlist.remove(sock) wdata.pop(sock) #客戶端 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() select網絡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的接口實現具備較好跨平臺能力的服務器會比較困難。 #其次,該模型將事件探測和事件響應夾雜在一塊兒,一旦事件響應的執行體龐大,則對整個模型是災難性的。
五 異步IO(Asynchronous I/O)
Linux下的asynchronous IO其實用得很少,從內核2.6版本纔開始引入。先看一下它的流程:
用戶進程發起read操做以後,馬上就能夠開始去作其它的事。而另外一方面,從kernel的角度,當它受到一個asynchronous read以後,首先它會馬上返回,因此不會對用戶進程產生任何block。而後,kernel會等待數據準備完成,而後將數據拷貝到用戶內存,當這一切都完成以後,kernel會給用戶進程發送一個signal,告訴它read操做完成了。
PS:異步IO對服務端的開銷比較大,由於不少數據都是服務端主動去響應呢:客戶端發送了一個請求,服務端返回一個響應碼,而後客戶端就能夠去幹其餘事情了;數據到達內核空間後,內核還會把數據放到用戶的內存空間,而後通知客戶端數據已經送到了。
IO模型比較分析
到目前爲止,已經將四個IO Model都介紹完了。如今回過頭來回答最初的那幾個問題:blocking和non-blocking的區別在哪,synchronous IO和asynchronous IO的區別在哪。
先回答最簡單的這個:blocking vs non-blocking。
前面的介紹中其實已經很明確的說明了這二者的區別。調用blocking IO會一直block住對應的進程直到操做完成,而non-blocking IO在kernel等待數據階段的狀況下會馬上返回。
再說明synchronous IO和asynchronous IO的區別以前,須要先給出二者的定義。
同步I/O操做會致使請求進程被阻塞,直到I/O操做完成爲止;
異步I/O操做不會致使請求進程被阻止
二者的區別就在於synchronous IO作」IO operation」的時候會將process阻塞。按照這個定義,四個IO模型能夠分爲兩大類,以前所述的blocking IO,non-blocking IO,IO multiplexing都屬於synchronous IO這一類,而 asynchronous I/O後一類 。 有人可能會說,non-blocking IO並無被block啊。這裏有個很是「狡猾」的地方,在non-blocking數據準備階段的時候,kernel的數據沒準備好,不會block進程。可是當kernel的數據準備好以後,會進入數據拷貝階段(從kernel空間拷貝到用戶空間),這段時間內,進程是被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操做的狀態,也不須要主動的去拷貝數據。
selectors模塊
IO複用: 爲了解釋這個名字,首先來理解一下複用這個概念,複用也就是公用的意思,這樣理解可能會有點抽象,因此,仍是先來理解下複用在通訊領域的使用,在通訊領域中爲了充分利用網絡接連的物理介質,每每在同一條網絡鏈路上採用時分複用和頻分複用的技術使其在同一鏈路上傳輸多路信號,到這裏咱們就基本上理解了複用的含義,即公用某個介質來儘量作多的同一類(性質)的事,那IO複用的'介質'是什麼呢?爲此咱們首先來看看服務器編程的模型,客戶端發來請求的時候服務端會產生一個進程來對其進行服務,每當來一個客戶請求就產生一個進程來服務,然而進程不可能無限制的產生,爲了解決大量客戶端訪問的問題,引入了IO複用技術,即:一個進程能夠同時對多個客戶端請求進行服務。也就是說IO複用的'介質'是進程(準確的說複用的是select和poll, 由於進程也是調用select和poll來實現的),複用一個進程(select和poll)來對多個IO進行服務,雖然客戶端發來的IO是併發的可是IO所需的讀寫數據多狀況下是沒有準備好的,由於就能夠利用一個函數(select和epoll)來監聽IO所需的這些數據狀態,一旦IO有數據能夠進行讀寫了,進程就對這樣的IO進行服務。 理解完IO複用後,咱們在來看下實現IO複用中的三個API(select、poll和epoll)的區別和聯繫 select, poll, epoll都是IO多路複用的機制, I/O多路複用就是經過一種機制,能夠監視多個描述符,一旦某個描述符(通常是讀就緒或者寫就緒),可以通知應用程序進行相應的讀寫操做。但select,poll,epoll本質上都是同步IO,由於他們都須要在讀寫事件(數據準備階段)就緒後本身負責進行讀寫(數據拷貝階段),整個讀寫過程是阻塞的,而異步I/O則無需本身負責進行讀寫,異步I/O的實現會負責把數據從內核拷貝到用戶空間。 select: select最先於1983年出如今4.2BSD中,他經過一個select()系統調用來監視多個文件描述符的數組,當select()返回後,該數組中就緒的文件描述符便會被內核修改標誌位,使得進程能夠得到這些文件描述符從而進行後續的讀寫操做。 select目前幾乎在全部的平臺上支持,其良好跨平臺支持是他的一個優勢,事實上從如今看來,這也是他所剩很少的優勢之一。 select的一個缺點在於單個進程可以監視的文件描述符的數量存在最大限制,在Linux上通常爲1024,不過能夠經過修改宏定義或者從新編譯內核的方式突破這一限制。 另外,select()所維護的存儲大量文件描述符的數據結構,隨着文件描述符數量的增大,其複製的開銷也線性增加。同時,因爲網絡響應時間的延遲使得大量TCP鏈接處於很是活躍狀態,但調用select()會對全部socket進行一次線性掃描,因此這也形成了必定的開銷。 poll: poll在1986年誕生於System V Release 3,他和socket在本質上沒有多大差異,可是poll沒有最大文件描述符數量的限制。 poll和select一樣存在的一個缺點是,包含大量文件描述符的數組被總體複製於用戶態和內核的地址空間之間,而不論這些文件描述符是否就緒,他的開銷隨着文件描述符數量的增長而線性增大。 另外,select()和epoll()將就緒的文件描述符告訴進程後,若是進程沒有對其進行IO操做,那麼下次調用select()和poll()的時候將再次報告這個文件描述符,因此他們通常不會丟失就緒信息,這種方式成爲水平觸發(Level Triggered) epoll: 直到linux2.6纔出現了由內核直接支持的實現方法,那就是epoll,它幾乎具有了以前全部所說的一切優勢,被公認爲Linux2.6下性能最好的多路I/O就緒通知方法。 epoll能夠同時支持水平觸發和邊緣觸發(Edge Triggered,只告訴進程哪些描述符剛剛變成就緒狀態,它只說一遍,若是咱們沒有采起行動,那麼它將不會再次告知,這種方法稱爲邊緣觸發),理論上邊緣觸發的性能要高一些,可是代碼實現至關複雜。 epoll一樣只告知那些就緒的文件描述符,並且當咱們調用epoll_wait()得到就緒文件描述符的時候,返回的不是實際的描述符,而是一個表明就緒描述數量的值,你只須要去epoll指定的一個數組中依次取得相應數量的文件描述符便可,這裏也使用了內存映射(mmap)技術,這樣便完全省掉了這些文件描述符在系統調用時複製的開銷。 另外一個本質的改進在於epoll採用基於時間的就緒通知方法。在select、poll中,進程只有在調用相關的方法後,內核纔對全部監視的文件進行掃描,而epoll是先經過epoll_ctl()來註冊一個文件描述符,一旦基於某個文件描述符就緒時,內核會採用相似callback的回調機制,迅速激活這個文件描述符,當進程調用epoll_wait()時便獲得通知。
這三種IO多路複用模型在不一樣的平臺有着不一樣的支持,而epoll在windows下就不支持,好在咱們有selectors模塊,幫咱們默認選擇當前平臺下最合適的
#服務端 from socket import * import selectors sel=selectors.DefaultSelector() def accept(server_fileobj,mask): conn,addr=server_fileobj.accept() sel.register(conn,selectors.EVENT_READ,read) def read(conn,mask): try: data=conn.recv(1024) if not data: print('closing',conn) sel.unregister(conn) conn.close() return conn.send(data.upper()+b'_SB') except Exception: print('closing', conn) sel.unregister(conn) conn.close() server_fileobj=socket(AF_INET,SOCK_STREAM) server_fileobj.setsockopt(SOL_SOCKET,SO_REUSEADDR,1) server_fileobj.bind(('127.0.0.1',8088)) server_fileobj.listen(5) server_fileobj.setblocking(False) #設置socket的接口爲非阻塞 sel.register(server_fileobj,selectors.EVENT_READ,accept) #至關於網select的讀列表裏append了一個文件句柄server_fileobj,而且綁定了一個回調函數accept while True: events=sel.select() #檢測全部的fileobj,是否有完成wait data的 for sel_obj,mask in events: callback=sel_obj.data #callback=accpet callback(sel_obj.fileobj,mask) #accpet(server_fileobj,1) #客戶端 from socket import * c=socket(AF_INET,SOCK_STREAM) c.connect(('127.0.0.1',8088)) while True: msg=input('>>: ') if not msg:continue c.send(msg.encode('utf-8')) data=c.recv(1024) print(data.decode('utf-8')) 基於selectors模塊實現聊天