一文帶你徹底吃透 IO 模型,告別一知半解
在計算機(jī)編程的江湖中,IO 模型堪稱一位深藏不露卻又掌控全局的武林高手。如果把計算機(jī)程序比作一個龐大的江湖門派,那么 IO 操作就是門派中最基礎(chǔ)也最關(guān)鍵的 “內(nèi)功心法”,而 IO 模型則是修煉這門 “內(nèi)功” 的不同秘籍 。它看似低調(diào),卻在幕后默默地決定著程序的性能、效率和穩(wěn)定性,深刻影響著程序與外部世界(如文件系統(tǒng)、網(wǎng)絡(luò)等)交互的方式。
對于程序員來說,理解 IO 模型,就如同武林高手領(lǐng)悟上乘武功心法,是提升編程境界、突破技術(shù)瓶頸的必經(jīng)之路。它不僅能幫助我們優(yōu)化代碼,使其運行得更加高效流暢,還能讓我們在面對復(fù)雜的系統(tǒng)設(shè)計和性能調(diào)優(yōu)問題時,有更清晰的思路和更強(qiáng)大的解決能力。今天,就讓我們一起深入探索 IO 模型的神秘世界,揭開它那層神秘的面紗。
一、IO模型簡介
1.1什么是 IO
在計算機(jī)世界里,IO(Input/Output)即輸入 / 輸出,堪稱計算機(jī)與外部世界溝通的橋梁,是數(shù)據(jù)在計算機(jī)內(nèi)部與外部設(shè)備(如磁盤、網(wǎng)絡(luò)、鍵盤、顯示器等)之間的流動過程 ,就像人體的血脈,源源不斷地輸送著養(yǎng)分(數(shù)據(jù))。從本質(zhì)上講,IO 實現(xiàn)了數(shù)據(jù)在不同存儲介質(zhì)或設(shè)備之間的遷移,讓計算機(jī)能夠獲取外部信息并輸出處理結(jié)果。
以磁盤 IO 為例,當(dāng)我們運行一個程序時,如果程序所需的數(shù)據(jù)不在內(nèi)存中,就會觸發(fā)磁盤 IO 操作,將數(shù)據(jù)從磁盤讀取到內(nèi)存中。這一過程如同從倉庫(磁盤)中取出貨物(數(shù)據(jù))并搬到工作區(qū)(內(nèi)存)。而網(wǎng)絡(luò) IO 則是在網(wǎng)絡(luò)通信中,數(shù)據(jù)在計算機(jī)與其他網(wǎng)絡(luò)節(jié)點之間的傳輸,比如我們?yōu)g覽網(wǎng)頁時,計算機(jī)通過網(wǎng)絡(luò) IO 從服務(wù)器獲取網(wǎng)頁數(shù)據(jù),就像是從遠(yuǎn)方的供應(yīng)商那里接收貨物。
常見的IO模型包括阻塞IO、非阻塞IO、多路復(fù)用IO和異步IO:
- 阻塞IO(Blocking IO):在執(zhí)行一個IO操作時,如果數(shù)據(jù)沒有準(zhǔn)備好,程序會一直等待,直到數(shù)據(jù)準(zhǔn)備就緒才會返回結(jié)果。這種模型下,應(yīng)用程序需要等待數(shù)據(jù)傳輸完成,期間無法進(jìn)行其他任務(wù)。
- 非阻塞IO(Non-blocking IO):在執(zhí)行一個IO操作時,如果數(shù)據(jù)沒有準(zhǔn)備好,程序不會等待而是立即返回。通過不斷輪詢來檢查是否有數(shù)據(jù)準(zhǔn)備好,然后再進(jìn)行讀寫操作。這樣可以避免長時間的阻塞等待,但仍然需要主動檢查狀態(tài)。
- 多路復(fù)用IO(Multiplexing IO):使用select、poll或epoll等系統(tǒng)調(diào)用,在一個線程中同時監(jiān)聽多個文件描述符(sockets),當(dāng)任意一個文件描述符有就緒事件發(fā)生時,再去進(jìn)行相應(yīng)的讀寫操作。這樣可以同時處理多個連接,并且避免了頻繁地輪詢。
- 異步IO(Asynchronous IO):在執(zhí)行一個IO操作時,程序發(fā)起請求后就可以繼續(xù)做其他事情,并且在IO操作完成后,系統(tǒng)會通知應(yīng)用程序進(jìn)行數(shù)據(jù)處理。這種模型下,IO的讀寫操作和結(jié)果處理是分離的,應(yīng)用程序無需等待IO操作完成。
1.2操作系統(tǒng)中的 IO “魔法”
在操作系統(tǒng)層面,IO 操作涉及用戶空間和內(nèi)核空間的交互。用戶空間是應(yīng)用程序運行的區(qū)域,而內(nèi)核空間則負(fù)責(zé)管理硬件資源和提供系統(tǒng)服務(wù)。當(dāng)應(yīng)用程序發(fā)起 IO 請求時,比如調(diào)用read或write函數(shù),這一請求會從用戶空間傳遞到內(nèi)核空間。
具體來說,IO 調(diào)用首先由應(yīng)用程序發(fā)起,發(fā)起后程序會等待系統(tǒng)內(nèi)核完成實際的 IO 操作。以讀取網(wǎng)絡(luò)數(shù)據(jù)為例,內(nèi)核先等待數(shù)據(jù)到達(dá)網(wǎng)卡,然后將數(shù)據(jù)拷貝至內(nèi)核緩沖區(qū),這是等待數(shù)據(jù)準(zhǔn)備階段;接著,內(nèi)核將數(shù)據(jù)從內(nèi)核緩沖區(qū)拷貝到用戶空間,供應(yīng)用程序處理,此為從內(nèi)核向進(jìn)程拷貝數(shù)據(jù)階段。這兩個階段構(gòu)成了 IO 執(zhí)行的全過程 ,而阻塞、非阻塞、異步、同步等概念,正是基于 IO 執(zhí)行階段的不同狀態(tài)和處理機(jī)制而產(chǎn)生的。
二、五種IO模型詳解
2.1IO模型分析方法
分析IO模型需要了解2個問題:
問題1:發(fā)送IO請求,IO請求可以理解為用戶空間和內(nèi)核空間數(shù)據(jù)同步,根據(jù)發(fā)起者不同分為以下兩種情況:
- 由用戶程序發(fā)起(同步IO)。
- 由內(nèi)核發(fā)起(異步IO)。
問題2:等待數(shù)據(jù)到來,等待數(shù)據(jù)到來的方式有以下幾種:
- 阻塞(阻塞IO)。
- 輪詢(非阻塞IO)。
- 信號通知(信號驅(qū)動IO)。
圖片
(內(nèi)核空間和用戶空間數(shù)據(jù)同步由誰發(fā)起是分析Linux IO模型最核心問題)
2.2 同步阻塞 IO:最基礎(chǔ)的 “老實人” 模型
同步阻塞 IO(Blocking IO)是最基礎(chǔ)、最容易理解的 IO 模型,堪稱 IO 世界里的 “老實人”。在這種模型下,當(dāng)應(yīng)用程序發(fā)起 IO 請求時,比如調(diào)用read函數(shù)讀取數(shù)據(jù),程序會被阻塞,就像一個人在等待快遞送達(dá),期間只能干巴巴地等著,什么也做不了 ,直到內(nèi)核將數(shù)據(jù)準(zhǔn)備好并拷貝到用戶空間,IO 操作完成,程序才會繼續(xù)執(zhí)行后續(xù)代碼。
在 linux中,默認(rèn)情況下所有的 socket都是blocking,一個典型的讀操作流程大概是這樣:
圖片
當(dāng)用戶進(jìn)程調(diào)用了 recvfrom這個系統(tǒng)調(diào)用,kernel就開始了IO的第一個階段:準(zhǔn)備數(shù)據(jù)。
對于 network io來說,很多時候數(shù)據(jù)在一開始還沒有到達(dá)(比如,還沒有收到一個完整的UDP包),這個時候 kernel就要等待足夠的數(shù)據(jù)到來。
而在用戶進(jìn)程這邊,整個進(jìn)程會被阻塞。當(dāng) kernel一直等到數(shù)據(jù)準(zhǔn)備好了,它就會將數(shù)據(jù)從 kernel中拷貝到用戶內(nèi)存,然后 kernel返回結(jié)果,用戶進(jìn)程才解除 block的狀態(tài),重新運行起來;所以,blocking IO的特點就是在IO執(zhí)行的兩個階段(等待數(shù)據(jù)和拷貝數(shù)據(jù)兩個階段)都被 block了。
幾乎所有的程序員第一次接觸到的網(wǎng)絡(luò)編程都是從 listen(),send(),recv(),等接口開始的,使用這些接口可以很方便的構(gòu)建服務(wù)器/客戶機(jī)的模型。然而大部分的 socket接口都是阻塞型的,如下圖:
Tip:所謂阻塞型接口是指系統(tǒng)調(diào)用(一般是IO接口)不返回調(diào)用結(jié)果并讓當(dāng)前線程一直阻塞,只有當(dāng)該系統(tǒng)調(diào)用獲得結(jié)果或者超時出錯時才返回。
圖片
實際上,除非特別指定,幾乎所有的 IO接口 ( 包括socket接口 ) 都是阻塞型的。這給網(wǎng)絡(luò)編程帶來了一個很大的問題,如在調(diào)用 recv(1024)的同時,線程將被阻塞,在此期間,線程將無法執(zhí)行任何運算或響應(yīng)任何的網(wǎng)絡(luò)請求。
一個簡單地解決方案:
在服務(wù)器端使用多線程(或多進(jìn)程)。
多線程(或多進(jìn)程)的目的是讓每個連接都擁有獨立的線程(或進(jìn)程),這樣任何一個連接的阻塞都不會影響其他的連接。
該方案的問題是:
開啟多進(jìn)程或都線程的方式,在遇到要同時響應(yīng)成百上千路的連接請求,則無論多線程還是多進(jìn)程都會嚴(yán)重占據(jù)系統(tǒng)資源,
降低系統(tǒng)對外界響應(yīng)效率,而且線程與進(jìn)程本身也更容易進(jìn)入假死狀態(tài)。
改進(jìn)方案:
很多程序員可能會考慮使用“線程池”或“連接池”。
“線程池”旨在減少創(chuàng)建和銷毀線程的頻率,其維持一定合理數(shù)量的線程,并讓空閑的線程重新承擔(dān)新的執(zhí)行任務(wù)。
“連接池”維持連接的緩存池,盡量重用已有的連接、減少創(chuàng)建和關(guān)閉連接的頻率。
這兩種技術(shù)都可以很好的降低系統(tǒng)開銷,都被廣泛應(yīng)用很多大型系統(tǒng),如websphere、tomcat和各種數(shù)據(jù)庫等。
改進(jìn)后方案其實也存在著問題:
“線程池”和“連接池”技術(shù)也只是在一定程度上緩解了頻繁調(diào)用IO接口帶來的資源占用。
而且,所謂“池”始終有其上限,當(dāng)請求大大超過上限時,“池”構(gòu)成的系統(tǒng)對外界的響應(yīng)并不比沒有池的時候效果好多少。
所以使用“池”必須考慮其面臨的響應(yīng)規(guī)模,并根據(jù)響應(yīng)規(guī)模調(diào)整“池”的大小。
對應(yīng)上例中的所面臨的可能同時出現(xiàn)的上千甚至上萬次的客戶端請求,“線程池”或“連接池”或許可以緩解部分壓力,但是不能解決所有問題。總之,多線程模型可以方便高效的解決小規(guī)模的服務(wù)請求,但面對大規(guī)模的服務(wù)請求,多線程模型也會遇到瓶頸,可以用非阻塞接口來嘗試解決這個問題。
2.3 同步非阻塞 IO:忙碌的 “輪詢者”
同步非阻塞 IO(Non-blocking IO)在同步阻塞 IO 的基礎(chǔ)上,將 socket 設(shè)置為非阻塞模式 ,就像一個忙碌的 “輪詢者”,不再傻傻地等待,而是不斷地詢問事情是否完成。當(dāng)應(yīng)用程序發(fā)起 IO 請求時,如果數(shù)據(jù)尚未準(zhǔn)備好,函數(shù)會立即返回,而不會阻塞線程。但這并不意味著數(shù)據(jù)已經(jīng)讀取成功,應(yīng)用程序需要不斷地輪詢,再次發(fā)起 IO 請求,直到數(shù)據(jù)準(zhǔn)備好并成功讀取 。
Linux下,可以通過設(shè)置socket使其變?yōu)閚on-blocking。當(dāng)對一個non-blocking socket執(zhí)行讀操作時,流程是這個樣子:
圖片
從圖中可以看出,當(dāng)用戶進(jìn)程發(fā)出 read操作時,如果 kernel中的數(shù)據(jù)還沒有準(zhǔn)備好,那么它并不會 block用戶進(jìn)程,而是立刻返回一個 error。
從用戶進(jìn)程角度講 ,它發(fā)起一個 read操作后,并不需要等待,而是馬上就得到了一個結(jié)果。
用戶進(jìn)程判斷結(jié)果是一個 error時,它就知道數(shù)據(jù)還沒有準(zhǔn)備好,于是用戶就可以在本次到下次再發(fā)起 read詢問的時間間隔內(nèi)做其他事情,或者直接再次發(fā)送 read操作。
一旦 kernel中的數(shù)據(jù)準(zhǔn)備好了,并且又再次收到了用戶進(jìn)程的 system call,那么它馬上就將數(shù)據(jù)拷貝到了用戶內(nèi)存(這一階段仍然是阻塞的),然后返回。
也就是說非阻塞的 recvform系統(tǒng)調(diào)用之后,進(jìn)程并沒有被阻塞,內(nèi)核馬上返回給進(jìn)程,如果數(shù)據(jù)還沒準(zhǔn)備好,此時會返回一個 error。
進(jìn)程在返回之后,可以干點別的事情,然后再發(fā)起 recvform系統(tǒng)調(diào)用。
重復(fù)上面的過程,循環(huán)往復(fù)的進(jìn)行 recvform系統(tǒng)調(diào)用。這個過程通常被稱之為輪詢。
輪詢檢查內(nèi)核數(shù)據(jù),指導(dǎo)數(shù)據(jù)準(zhǔn)備好,再拷貝數(shù)據(jù)到進(jìn)程,進(jìn)行數(shù)據(jù)處理。
需要注意,拷貝數(shù)據(jù)的整個過程,進(jìn)程仍然是屬于阻塞的狀態(tài)。
所以,在非阻塞式 IO中,用戶進(jìn)程其實是需要不斷的主動詢問 kernel數(shù)據(jù)準(zhǔn)備好了沒有。
非阻塞 IO示例:
(1)服務(wù)端
# 實現(xiàn)自己監(jiān)測IO,遇到IO,就切到我單個線程的其他用戶去運行了,實現(xiàn)單線程下的并發(fā),并把單線程的效率提到了最高。
from socket import *
server = socket(AF_INET, SOCK_STREAM)
server.bind(("192.168.2.209",8800))
server.listen(5)
server.setblocking(False) # 默認(rèn)是 True(阻塞),改成 False(非阻塞)
print("starting...")
rlist = []
wlist = []
while True:
try:
conn, addr = server.accept()
rlist.append(conn)
print(rlist)
except BlockingIOError:
# 收消息
del_rlist = [] # 要刪除的鏈接
for conn in rlist:
try:
data = conn.recv(1024)
if not data:
del_rlist.append(conn)
continue
# conn.send(data.upper())
wlist.append((conn,data.upper()))
except BlockingIOError:
continue
except Exception:
conn.close() # 服務(wù)端單方面斷開,這個鏈接就可以回收掉了
del_rlist.append(conn)
# 發(fā)消息
del_wlist = []
for item in wlist:
try:
conn = item[0]
data = item[1]
# 如果這里拋異常,那么下行代碼運行不了,如果沒拋異常,發(fā)成功了,就把鏈接加到刪除的列表
conn.send(data)
del_wlist.append(item)
except BlockingIOError:
pass
for item in del_wlist:
wlist.remove(item)
for conn in del_rlist:
rlist.remove(conn)
server.close()
(2)客戶端
from socket import *
client = socket(AF_INET,SOCK_STREAM)
client.connect(("192.168.2.209",8800))
while True:
msg = input(">>:").strip()
if not msg:continue
client.send(msg.encode("utf-8"))
data = client.recv(1024)
print("收到的數(shù)據(jù):%s" % data.decode("utf-8"))
client.close()
但是非阻塞 IO模型絕不被推薦。
我們不能否定其優(yōu)點:能夠在等待任務(wù)完成的時間里干其他活了(包括提交其他任務(wù),也就是 “后臺” 可以有多個任務(wù)在“”同時“”執(zhí)行)。
但是也難掩其缺點:
循環(huán)調(diào)用 recv()將大幅度推高 CPU占用率;這也是我們在代碼中留一句time.sleep(2)的原因,
否則在低配主機(jī)下極容易出現(xiàn)卡機(jī)情況。
任務(wù)完成的響應(yīng)延遲增大了,因為每過一段時間才去輪詢一次 read操作,
而任務(wù)可能在兩次輪詢之間的任意時間完成。這會導(dǎo)致整體數(shù)據(jù)吞吐量的降低。
此外,在這個方案中 recv()更多的是起到檢測“操作是否完成”的作用,實際操作系統(tǒng)提供了更為高效的檢測“操作是否完成“作用的接口,例如 select()多路復(fù)用模式,可以一次檢測多個連接是否活躍。
2.4IO 多路復(fù)用:高效的 “事件監(jiān)聽者”
IO 多路復(fù)用(IO Multiplexing)模型是一種高效的 IO 模型,它就像是一個聰明的 “事件監(jiān)聽者”,能夠同時監(jiān)聽多個文件描述符(如 socket)的事件,當(dāng)其中任何一個文件描述符就緒(有數(shù)據(jù)可讀、可寫或有異常)時,就會通知應(yīng)用程序進(jìn)行相應(yīng)的處理 。這使得一個線程可以處理多個 IO 請求,大大提高了程序的并發(fā)處理能力。
IO 多路復(fù)用的實現(xiàn)方式主要有select、poll和epoll。select函數(shù)通過監(jiān)聽文件描述符集合,當(dāng)有描述符就緒時返回,通知應(yīng)用程序進(jìn)行處理 。但它有一些局限性,比如能監(jiān)聽的文件描述符數(shù)量有限(通常為 1024 個),每次調(diào)用select都需要將文件描述符集合從用戶空間拷貝到內(nèi)核空間,并且返回時需要遍歷整個集合來判斷哪些描述符就緒,效率較低 。poll函數(shù)與select類似,但它使用鏈表來存儲文件描述符,突破了文件描述符數(shù)量的限制,但在處理大量文件描述符時,性能仍然會隨著描述符數(shù)量的增加而下降 。
epoll是select和poll的增強(qiáng)版,它采用事件驅(qū)動的方式,在內(nèi)核中維護(hù)一個事件表,當(dāng)有事件發(fā)生時,直接將事件通知給應(yīng)用程序,而不需要遍歷整個文件描述符集合 。epoll還支持水平觸發(fā)(Level Triggered,LT)和邊緣觸發(fā)(Edge Triggered,ET)兩種模式,其中邊緣觸發(fā)模式的效率更高,適用于高并發(fā)場景 。
它的基本原理就是 select/epoll這個 function會不斷的輪詢所負(fù)責(zé)的所有 socket,當(dāng)某個socket有數(shù)據(jù)到達(dá)了,就通知用戶進(jìn)程。它的流程如圖:
圖片
當(dāng)用戶進(jìn)程調(diào)用了 select,那么整個進(jìn)程會被 block,而同時,kernel會“監(jiān)視”所有 select負(fù)責(zé)的 socket,當(dāng)任何一個 socket中的數(shù)據(jù)準(zhǔn)備好了,select就會返回。這個時候用戶進(jìn)程再調(diào)用 read操作,將數(shù)據(jù)從 kernel拷貝到用戶進(jìn)程。這個圖和 blocking IO的圖其實并沒有太大的不同,事實上還更差一些。因為這里需要使用兩個系統(tǒng)調(diào)用(select和recvfrom),而 blocking IO只調(diào)用了一個系統(tǒng)調(diào)用(recvfrom)。但是,用 select的優(yōu)勢在于它可以同時處理多個 connection。
強(qiáng)調(diào):
1,如果處理的連接數(shù)不是很高的話,使用 select/epoll的 web server不一定比使用 multi-threading + blocking IO的 web server性能更好,可能延遲還更大。select/epoll的優(yōu)勢并不是對于單個連接能處理得更快,而是在于能處理更多的連接。
2,在多路復(fù)用模型中,對于每一個 socket,一般都設(shè)置成為 non-blocking,但是,如上圖所示,整個用戶的 process其實是一直被 block的。只不過 process是被 select這個函數(shù) block,而不是被 socket IO給 block。
結(jié)論: select的優(yōu)勢在于可以處理多個連接,不適用于單個連接。
select網(wǎng)絡(luò)IO模型示例:
from socket import *
import select
server = socket(AF_INET, SOCK_STREAM)
server.bind(("192.168.2.209",9900))
server.listen(5)
server.setblocking(False)
rlist = [server,] # 收,存一堆conn,和server
wlist = [] # 發(fā),存一堆conn,一旦緩存區(qū)沒滿,證明可以發(fā)了
wdata = {}
while True:
rl,wl,xl = select.select(rlist,wlist,[],1) # 后面的列表是出異常的列表,1是超時時間,每隔1s問一遍操作系統(tǒng)
print("rl",rl) # 收的套接字
print("wl",wl) # 發(fā)的套接字
for sock in rl:
if sock == server:
conn,addr = sock.accept()
rlist.append(conn)
else:
try: # recv的時候,客戶端單方面的把鏈接斷開,會拋異常,在這里捕獲下
data = sock.recv(1024)
if not data: # 客戶端沒發(fā)數(shù)據(jù),跳過
sock.close()
rlist.remove(sock)
continue
wlist.append(sock)
wdata[sock] = data.upper()
except Exception:
sock.close()
rlist.remove(sock)
for sock in wl:
data = wdata[sock]
sock.send(data)
wlist.remove(sock)
wdata.pop(sock)
server.close()
select監(jiān)聽fd變化的過程分析:
用戶進(jìn)程創(chuàng)建socket對象,拷貝監(jiān)聽的fd到內(nèi)核空間,每一個fd會對應(yīng)一張系統(tǒng)文件表,內(nèi)核空間的fd響應(yīng)到數(shù)據(jù)后,
就會發(fā)送信號給用戶進(jìn)程數(shù)據(jù)已到;
用戶進(jìn)程再發(fā)送系統(tǒng)調(diào)用,比如(accept)將內(nèi)核空間的數(shù)據(jù)copy到用戶空間,同時作為接受數(shù)據(jù)端內(nèi)核空間的數(shù)據(jù)清除,
這樣重新監(jiān)聽時fd再有新的數(shù)據(jù)又可以響應(yīng)到了(發(fā)送端因為基于TCP協(xié)議所以需要收到應(yīng)答后才會清除)。
該模型的優(yōu)點:
相比其他模型,使用select() 的事件驅(qū)動模型只用單線程(進(jìn)程)執(zhí)行,占用資源少,不消耗太多 CPU,
同時能夠為多客戶端提供服務(wù)。如果試圖建立一個簡單的事件驅(qū)動的服務(wù)器程序,這個模型有一定的參考價值。
該模型的缺點:
首先select()接口并不是實現(xiàn)“事件驅(qū)動”的最好選擇。因為當(dāng)需要探測的句柄值較大時,select()接口本身需要消耗大量時間去輪詢各個句柄。
很多操作系統(tǒng)提供了更為高效的接口,如linux提供了epoll,BSD提供了kqueue,Solaris提供了/dev/poll,…。如果需要實現(xiàn)更高效的服務(wù)器程序,
類似epoll這樣的接口更被推薦。# 遺憾的是不同的操作系統(tǒng)特供的epoll接口有很大差異,所以使用類似于epoll的接口實現(xiàn)具有較好跨平臺能力的服務(wù)器會比較困難。
其次,該模型將事件探測和事件響應(yīng)夾雜在一起,一旦事件響應(yīng)的執(zhí)行體龐大,則對整個模型是災(zāi)難性的。
2.5異步IO
Linux下的 asynchronous IO其實用得不多,從內(nèi)核2.6版本才開始引入。先看一下它的流程:
圖片
用戶進(jìn)程發(fā)起 read操作之后,立刻就可以開始去做其它的事,而另一方面,從 kernel的角度,當(dāng)它受到一個 asynchronous read之后,首先它會立刻返回,所以不會對用戶進(jìn)程產(chǎn)生任何 block。
然后,kernel會等待數(shù)據(jù)準(zhǔn)備完成,然后將數(shù)據(jù)拷貝到用戶內(nèi)存,當(dāng)這一切都完成之后,kernel會給用戶進(jìn)程發(fā)送一個signal,告訴它read操作完成了。
2.6IO模型比較分析
到目前為止,已經(jīng)將四個 IO Model都介紹完了?,F(xiàn)在回過頭來回答最初的那幾個問題:blocking和non-blocking的區(qū)別在哪,synchronous IO和asynchronous IO的區(qū)別在哪。
先回答最簡單的這個:blocking vs non-blocking。前面的介紹中其實已經(jīng)很明確的說明了這兩者的區(qū)別。
調(diào)用 blocking IO會一直block住對應(yīng)的進(jìn)程直到操作完成,而non-blocking IO在kernel還準(zhǔn)備數(shù)據(jù)的情況下會立刻返回。
再說明 synchronous IO和asynchronous IO的區(qū)別之前,需要先給出兩者的定義。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;兩者的區(qū)別就在于 synchronous IO做”IO operation”的時候會將 process阻塞。
按照這個定義,四個IO模型可以分為兩大類,之前所述的 blocking IO,non-blocking IO,IO multiplexing都屬于synchronous IO這一類,而 asynchronous I/O后一類 。
有人可能會說,non-blocking IO并沒有被 block啊。這里有個非?!敖苹钡牡胤剑x中所指的”IO operation”是指真實的IO操作,就是例子中的 recvfrom這個 system call。
non-blocking IO在執(zhí)行recvfrom這個system call的時候,如果kernel的數(shù)據(jù)沒有準(zhǔn)備好,這時候不會block進(jìn)程。
但是,當(dāng) kernel中數(shù)據(jù)準(zhǔn)備好的時候,recvfrom會將數(shù)據(jù)從 kernel拷貝到用戶內(nèi)存中,這個時候進(jìn)程是被 block了,在這段時間內(nèi),進(jìn)程是被 block的。
而 asynchronous IO則不一樣,當(dāng)進(jìn)程發(fā)起 IO操作之后,就直接返回再也不理睬了,直到 kernel發(fā)送一個信號,告訴進(jìn)程說 IO完成。在這整個過程中,進(jìn)程完全沒有被 block。
各個 IO Model的比較如圖所示:
圖片
經(jīng)過上面的介紹,會發(fā)現(xiàn) non-blocking IO和 asynchronous IO的區(qū)別還是很明顯的。
在 non-blocking IO中,雖然進(jìn)程大部分時間都不會被 block,但是它仍然要求進(jìn)程去主動的 check,并且當(dāng)數(shù)據(jù)準(zhǔn)備完成以后,也需要進(jìn)程主動的再次調(diào)用 recvfrom來將數(shù)據(jù)拷貝到用戶內(nèi)存。
而 asynchronous IO則完全不同。它就像是用戶進(jìn)程將整個 IO操作交給了他人(kernel)完成,然后他人做完后發(fā)信號通知。在此期間,用戶進(jìn)程不需要去檢查 IO操作的狀態(tài),也不需要主動的去拷貝數(shù)據(jù)。
三、走進(jìn)IO模型的世界
3.1同步與異步:任務(wù)協(xié)作的不同策略
同步和異步,是 IO 模型中的兩種基本協(xié)作模式,它們決定了程序在處理任務(wù)時的執(zhí)行順序和響應(yīng)方式。簡單來說,同步就像是一場按部就班的接力賽,每個選手必須等前一個選手完成交接后才能出發(fā);而異步則更像一場并行的馬拉松,選手們可以各自按照自己的節(jié)奏奔跑,不需要相互等待。
在日常生活中,同步和異步的例子隨處可見。比如你去餐廳點餐,如果你選擇坐在餐桌前等待服務(wù)員做好餐食并端上桌,這就是同步的方式 —— 你必須等待當(dāng)前的點餐任務(wù)完成(餐食做好并送達(dá)),才能進(jìn)行下一步行動,比如開始用餐。而如果你選擇點外賣,下單后你可以繼續(xù)做其他事情,等外賣送到時再去取餐,這便是異步的過程 —— 你不需要一直等待外賣送達(dá),在等待過程中可以并行處理其他事務(wù) 。
在代碼世界里,同步和異步的區(qū)別也十分明顯。以 Python 代碼為例:
import time
# 同步函數(shù)
def synchronous_task():
print("同步任務(wù)開始")
time.sleep(2) # 模擬耗時操作
print("同步任務(wù)結(jié)束")
# 異步函數(shù)
async def asynchronous_task():
print("異步任務(wù)開始")
await asyncio.sleep(2) # 模擬耗時操作
print("異步任務(wù)結(jié)束")
# 同步調(diào)用
print("開始同步調(diào)用")
synchronous_task()
print("同步調(diào)用結(jié)束")
# 異步調(diào)用(使用asyncio庫)
import asyncio
print("開始異步調(diào)用")
asyncio.run(asynchronous_task())
print("異步調(diào)用結(jié)束")
在上述代碼中,synchronous_task是一個同步函數(shù),當(dāng)調(diào)用它時,程序會阻塞在time.sleep(2)這一行,直到 2 秒后函數(shù)執(zhí)行完畢,才會繼續(xù)執(zhí)行后續(xù)代碼。而asynchronous_task是一個異步函數(shù),await asyncio.sleep(2)雖然也模擬了 2 秒的耗時操作,但在這 2 秒內(nèi),程序并不會阻塞,而是可以去執(zhí)行其他異步任務(wù),當(dāng)asyncio.sleep(2)完成后,才會繼續(xù)執(zhí)行asynchronous_task函數(shù)后續(xù)的代碼。
在 IO 操作中,同步和異步的表現(xiàn)也截然不同。同步 IO 操作會導(dǎo)致程序阻塞,直到 IO 操作完成。例如,當(dāng)我們使用requests庫發(fā)送 HTTP 請求獲取網(wǎng)頁內(nèi)容時:
import requests
response = requests.get('https://www.example.com') # 同步請求,程序會阻塞在這里
print(response.text)
在這段代碼中,程序會在requests.get這一行阻塞,直到服務(wù)器響應(yīng)并返回網(wǎng)頁內(nèi)容,期間程序無法執(zhí)行其他任務(wù)。
而異步 IO 操作則不會阻塞程序。在 Python 的aiohttp庫中,我們可以實現(xiàn)異步的 HTTP 請求:
import aiohttp
import asyncio
async def fetch(session, url):
async with session.get(url) as response:
return await response.text()
async def main():
async with aiohttp.ClientSession() as session:
html = await fetch(session, 'https://www.example.com')
print(html)
loop = asyncio.get_event_loop()
loop.run_until_complete(main())
在這個例子中,fetch函數(shù)使用aiohttp庫進(jìn)行異步的 HTTP 請求。在請求過程中,程序不會阻塞,可以繼續(xù)執(zhí)行其他異步任務(wù),當(dāng)請求完成后,才會處理返回的結(jié)果。這種方式大大提高了程序的執(zhí)行效率,尤其是在處理大量 IO 操作時,能夠充分利用系統(tǒng)資源,減少等待時間。
3.2阻塞與非阻塞:等待數(shù)據(jù)的不同姿態(tài)
阻塞與非阻塞,描述的是程序在等待 IO 操作完成時的狀態(tài)。阻塞就像是在交通擁堵的道路上,車輛必須停下來等待前方道路暢通;非阻塞則如同在暢通無阻的高速公路上,車輛可以自由行駛,無需等待。
在 IO 操作中,阻塞 IO 是最常見的方式。當(dāng)一個進(jìn)程發(fā)起阻塞 IO 操作時,如果數(shù)據(jù)尚未準(zhǔn)備好,該進(jìn)程會被掛起,進(jìn)入等待狀態(tài),直到數(shù)據(jù)準(zhǔn)備就緒并完成 IO 操作,進(jìn)程才會被喚醒繼續(xù)執(zhí)行 。例如,使用read函數(shù)從文件中讀取數(shù)據(jù):
try:
with open('example.txt', 'r') as file:
data = file.read() # 阻塞IO操作,如果文件讀取緩慢,程序會在此阻塞
print(data)
except FileNotFoundError:
print("文件未找到")
在這段代碼中,如果example.txt文件較大或者磁盤讀取速度較慢,file.read()操作會阻塞程序,直到數(shù)據(jù)讀取完成。在阻塞期間,程序無法執(zhí)行其他任務(wù),CPU 資源被浪費。
非阻塞 IO 則不同,當(dāng)一個進(jìn)程發(fā)起非阻塞 IO 操作時,如果數(shù)據(jù)尚未準(zhǔn)備好,函數(shù)會立即返回一個錯誤碼或狀態(tài)標(biāo)識,而不會阻塞進(jìn)程。進(jìn)程可以繼續(xù)執(zhí)行其他任務(wù),然后通過輪詢等方式再次檢查數(shù)據(jù)是否準(zhǔn)備好 。在 Python 中,可以使用socket模塊實現(xiàn)非阻塞 IO:
import socket
# 創(chuàng)建一個socket對象
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setblocking(False) # 設(shè)置為非阻塞模式
try:
# 嘗試連接服務(wù)器
sock.connect(('www.example.com', 80))
except BlockingIOError:
pass # 連接未完成,繼續(xù)執(zhí)行其他任務(wù)
# 可以在此處執(zhí)行其他任務(wù)
# 輪詢檢查連接是否完成
while True:
try:
sock.send(b'GET / HTTP/1.1\r\nHost: www.example.com\r\n\r\n')
break
except BlockingIOError:
continue
# 接收數(shù)據(jù)
data = sock.recv(1024)
print(data.decode('utf-8'))
sock.close()
在上述代碼中,sock.setblocking(False)將 socket 設(shè)置為非阻塞模式。在調(diào)用connect方法時,如果連接不能立即建立,程序不會阻塞,而是繼續(xù)執(zhí)行后續(xù)代碼。然后通過輪詢的方式,不斷嘗試發(fā)送數(shù)據(jù),直到連接成功。這種方式提高了程序的并發(fā)性,使得程序在等待 IO 操作的過程中可以執(zhí)行其他任務(wù),充分利用了 CPU 資源 。
實際應(yīng)用中,阻塞 IO 適用于對響應(yīng)時間要求不高、IO 操作較少且數(shù)據(jù)量小的場景,例如簡單的文件讀取、本地數(shù)據(jù)庫查詢等。而非阻塞 IO 則更適合高并發(fā)、對響應(yīng)時間要求較高的場景,如網(wǎng)絡(luò)服務(wù)器的并發(fā)處理、實時數(shù)據(jù)采集等。在這些場景中,非阻塞 IO 能夠避免線程被大量阻塞,提高系統(tǒng)的吞吐量和響應(yīng)速度。
四、IO模型常見問題解析
4.1阻塞IO和非阻塞IO區(qū)別?
a.阻塞IO:在阻塞IO模型中,當(dāng)應(yīng)用程序發(fā)起一個IO操作(例如讀取文件或網(wǎng)絡(luò)數(shù)據(jù)),如果數(shù)據(jù)沒有準(zhǔn)備好,該操作會一直阻塞線程的執(zhí)行。線程會等待,直到數(shù)據(jù)準(zhǔn)備好后才能繼續(xù)執(zhí)行后續(xù)的代碼。這意味著在進(jìn)行阻塞IO操作期間,線程無法執(zhí)行其他任務(wù)。
b.非阻塞IO:相比之下,非阻塞IO模型允許應(yīng)用程序在發(fā)起一個IO操作后立即返回,并且無論數(shù)據(jù)是否準(zhǔn)備好都可以繼續(xù)執(zhí)行后續(xù)的代碼。如果數(shù)據(jù)還沒有準(zhǔn)備好,非阻塞IO模型會立即返回一個錯誤碼或者空數(shù)據(jù)。應(yīng)用程序需要通過輪詢或者重復(fù)嘗試來檢查數(shù)據(jù)是否已經(jīng)準(zhǔn)備好。
非阻塞IO與阻塞IO相比具有以下特點:
- 異步通知:在非阻塞IO模型中,應(yīng)用程序可以使用回調(diào)函數(shù)或者事件驅(qū)動機(jī)制來處理IO操作完成的通知。當(dāng)數(shù)據(jù)準(zhǔn)備就緒時,系統(tǒng)會通知應(yīng)用程序進(jìn)行讀取或?qū)懭氩僮鳌?/span>
- 非阻塞輪詢:為了檢查是否有數(shù)據(jù)可用,應(yīng)用程序需要通過輪詢的方式重復(fù)檢查IO狀態(tài)。這意味著在沒有數(shù)據(jù)可讀取時,應(yīng)用程序需要不斷地輪詢以避免線程被阻塞。
- 處理更多任務(wù):由于非阻塞IO模型允許應(yīng)用程序同時處理多個任務(wù),在等待一個任務(wù)的結(jié)果時可以繼續(xù)執(zhí)行其他任務(wù)。這可以提高并發(fā)性和吞吐量。
4.2同步IO和異步IO區(qū)別?
同步IO(Synchronous IO):在同步IO模型中,應(yīng)用程序發(fā)起一個IO操作后會被阻塞等待操作完成。這意味著應(yīng)用程序必須等待IO操作完成后才能繼續(xù)執(zhí)行后續(xù)代碼。當(dāng)數(shù)據(jù)就緒時,系統(tǒng)將數(shù)據(jù)傳輸給應(yīng)用程序并解除阻塞狀態(tài),應(yīng)用程序可以讀取或?qū)懭霐?shù)據(jù)。同步IO通常以函數(shù)調(diào)用的形式進(jìn)行,例如讀取文件、網(wǎng)絡(luò)請求等。
異步IO(Asynchronous IO):在異步IO模型中,應(yīng)用程序發(fā)起一個IO操作后立即返回,并不會阻塞等待結(jié)果返回。應(yīng)用程序可以繼續(xù)執(zhí)行后續(xù)代碼而無需等待。當(dāng)數(shù)據(jù)就緒時,系統(tǒng)會通知應(yīng)用程序進(jìn)行讀取或?qū)懭氩僮鳎⑻峁┫鄳?yīng)的回調(diào)函數(shù)或事件處理機(jī)制來處理完成通知。異步IO常見的實現(xiàn)方式包括回調(diào)函數(shù)、Promise/Future對象、協(xié)程/生成器等。
關(guān)鍵區(qū)別:
- 同步IO需要阻塞等待結(jié)果返回,而異步IO則不需要。
- 同步IO只能進(jìn)行一次性的單個操作,而異步IO可以同時處理多個任務(wù)。
- 同步IO適合于簡單和可預(yù)測的IO操作,而異步IO適合于需要同時處理大量任務(wù)和高并發(fā)的場景。
4.3信號驅(qū)動IO和異步IO區(qū)別?
信號驅(qū)動IO:信號驅(qū)動IO使用操作系統(tǒng)提供的信號機(jī)制來通知應(yīng)用程序IO事件的發(fā)生。應(yīng)用程序首先通過調(diào)用sigaction()函數(shù)注冊一個信號處理函數(shù),并指定需要監(jiān)聽的IO事件,如可讀、可寫等。當(dāng)指定的事件發(fā)生時,操作系統(tǒng)會發(fā)送相應(yīng)的信號給應(yīng)用程序,并執(zhí)行注冊的信號處理函數(shù)進(jìn)行后續(xù)操作。這種模型下,應(yīng)用程序可以繼續(xù)執(zhí)行其他任務(wù)而無需等待結(jié)果返回。
異步IO:異步IO則是通過操作系統(tǒng)提供的異步IO接口進(jìn)行實現(xiàn)。應(yīng)用程序通過向內(nèi)核發(fā)起異步IO請求,并傳入回調(diào)函數(shù)或其他形式的完成通知方式。當(dāng)數(shù)據(jù)就緒時,內(nèi)核會主動通知應(yīng)用程序并觸發(fā)回調(diào)函數(shù)或完成事件來進(jìn)行后續(xù)操作。在異步IO模型中,應(yīng)用程序可以并行地執(zhí)行其他任務(wù),而不需要一直等待結(jié)果返回。
關(guān)鍵區(qū)別:
- 信號驅(qū)動IO使用了操作系統(tǒng)提供的信號機(jī)制,而異步IO則使用了專門設(shè)計的異步接口。
- 信號驅(qū)動IO以信號為觸發(fā)點進(jìn)行通知,而異步IO則以回調(diào)函數(shù)或事件為觸發(fā)點進(jìn)行通知。
- 信號驅(qū)動IO的實現(xiàn)相對較為底層,需要應(yīng)用程序自行處理信號和回調(diào)函數(shù),而異步IO則提供了更高級的接口封裝。
選擇使用信號驅(qū)動IO還是異步IO取決于具體應(yīng)用的需求和平臺支持情況。在某些平臺上,可能更適合使用信號驅(qū)動IO來處理特定類型的事件。而在其他情況下,使用操作系統(tǒng)提供的異步IO接口能夠更方便地實現(xiàn)非阻塞IO操作。
4.4非阻塞IO是不是異步IO?
非阻塞IO(Non-blocking IO)和異步IO(Asynchronous IO)是兩種不同的IO操作模型,盡管它們都用于實現(xiàn)非阻塞式的IO處理,但在實現(xiàn)機(jī)制上有所不同。
非阻塞IO指的是應(yīng)用程序在進(jìn)行IO操作時,如果沒有立即得到所需的數(shù)據(jù)或結(jié)果,不會一直等待而是立即返回一個錯誤碼。應(yīng)用程序可以通過輪詢或選擇性地調(diào)用其他任務(wù)來繼續(xù)執(zhí)行,然后再次檢查IO是否就緒。這種模型下,應(yīng)用程序需要主動查詢和處理IO狀態(tài)。
異步IO則是一種更高級的模型,在發(fā)起一個IO請求后,應(yīng)用程序無需一直等待結(jié)果返回,而是可以繼續(xù)執(zhí)行其他任務(wù)。當(dāng)數(shù)據(jù)就緒或操作完成時,操作系統(tǒng)會通知應(yīng)用程序,并觸發(fā)事先注冊好的回調(diào)函數(shù)或者發(fā)送事件通知。這種模型下,應(yīng)用程序可以并行執(zhí)行多個任務(wù),并且在適當(dāng)時候接收通知。
盡管非阻塞IO和異步IO都能夠?qū)崿F(xiàn)非阻塞式的IO處理,但其實現(xiàn)方式和編程模型上有所差異。非阻塞IO需要應(yīng)用程序自行查詢和處理狀態(tài)變化,而異步IO則由操作系統(tǒng)負(fù)責(zé)監(jiān)測和通知狀態(tài)變化。因此,雖然非阻塞IO可以視為一種基本形式的異步IO,但兩者在編程模型和使用方式上還是有區(qū)別的。
五、優(yōu)化IO性能的實戰(zhàn)經(jīng)驗
讓我們來看一個具體的案例,深入了解如何通過優(yōu)化 IO 模型提升系統(tǒng)性能 。某在線教育平臺,其業(yè)務(wù)涵蓋了課程視頻播放、在線直播教學(xué)、用戶資料存儲等多個方面 。在平臺發(fā)展初期,用戶量較少,系統(tǒng)采用了同步阻塞 IO 模型來處理網(wǎng)絡(luò)請求和文件讀寫操作 ,這種簡單直接的方式在當(dāng)時能夠滿足業(yè)務(wù)需求 。
然而,隨著平臺知名度的提升,用戶數(shù)量急劇增加,特別是在黃金時間段,大量用戶同時在線觀看課程視頻和參與直播,系統(tǒng)開始出現(xiàn)嚴(yán)重的性能問題 。視頻播放卡頓、直播延遲明顯,用戶投訴不斷 。經(jīng)過深入分析,發(fā)現(xiàn)同步阻塞 IO 模型在高并發(fā)情況下,線程頻繁阻塞,導(dǎo)致系統(tǒng)資源利用率低下,無法及時處理大量的請求 。
為了解決這些問題,開發(fā)團(tuán)隊決定對系統(tǒng)進(jìn)行 IO 模型的優(yōu)化 。他們將核心的網(wǎng)絡(luò)請求處理部分從同步阻塞 IO 模型切換為 IO 多路復(fù)用模型(采用 epoll 實現(xiàn)) ,并結(jié)合異步 IO 來處理文件讀寫操作,尤其是在視頻文件的讀取和緩存方面 。具體來說,在網(wǎng)絡(luò)請求處理上,通過 epoll 監(jiān)聽多個客戶端連接,當(dāng)有請求到達(dá)時,迅速將其分配到線程池中進(jìn)行處理,大大提高了并發(fā)處理能力 。在視頻文件讀取時,采用異步 IO,提前將熱門視頻文件異步加載到內(nèi)存緩存中,當(dāng)用戶請求播放視頻時,可以直接從緩存中讀取數(shù)據(jù),減少了磁盤 IO 的等待時間 。
優(yōu)化后,系統(tǒng)性能得到了顯著提升 。視頻播放卡頓現(xiàn)象幾乎消失,直播延遲降低到了可接受的范圍內(nèi),用戶滿意度大幅提高 。同時,服務(wù)器的資源利用率也得到了優(yōu)化,能夠以更少的硬件資源支持更多的用戶并發(fā)訪問 。從這個案例中,我們可以總結(jié)出以下經(jīng)驗教訓(xùn):在系統(tǒng)設(shè)計初期,就應(yīng)該充分考慮業(yè)務(wù)的發(fā)展趨勢和可能面臨的并發(fā)場景,選擇合適的 IO 模型,避免后期因性能問題進(jìn)行大規(guī)模的架構(gòu)調(diào)整 。在優(yōu)化 IO 模型時,要綜合考慮系統(tǒng)的各個環(huán)節(jié),不僅僅是網(wǎng)絡(luò)請求處理,還包括文件讀寫、數(shù)據(jù)緩存等,進(jìn)行全面的優(yōu)化才能取得最佳效果 。