談談對不同I/O模型的理解 (阻塞/非阻塞IO,同步/異步IO)
點擊上方藍色字體,選擇“標星公眾號”
優(yōu)質文章,第一時間送達
? 作者?|??小熊餐館
來源 |? urlify.cn/aAJZfm
一、關于I/O模型的問題
最近通過對ucore操作系統(tǒng)的學習,讓我打開了操作系統(tǒng)內核這一黑盒子,與之前所學知識結合起來,解答了長久以來困擾我的關于I/O的一些問題。
1. 為什么redis能以單工作線程處理高達幾萬的并發(fā)請求?
2. 什么是I/O多路復用?為什么redis、nginx、nodeJS以及netty等以高性能著稱的服務器其底層都利用了I/O多路復用技術?
3. 非阻塞I/O為什么會流行起來,在許多場景下取代了傳統(tǒng)的阻塞I/O?
4. 非阻塞I/O真的是銀彈嗎?為什么即使在為海量用戶提供服務的,追求高性能的互聯(lián)網公司中依然有那么多的服務器在傳統(tǒng)的阻塞IO模型下工作?
5. 什么是協(xié)程?為什么Go語言這么受歡迎?
在這篇博客中,將介紹不同層面、不同I/O模型的原理,并嘗試著給出我對上述問題的回答。如果你也或多或少的對上述問題感到疑惑,希望這篇博客能為你提供幫助。
I/O模型和硬件、操作系統(tǒng)內核息息相關,博客中會涉及到諸如保護模式、中斷、特權級、進程/線程、上下文切換、系統(tǒng)調用等關于操作系統(tǒng)、硬件相關的概念。由于計算機中的知識是按照層次組織起來的,如果對這些相對底層的概念不是很了解的話可能會影響對整體內容的理解。可以參考一下我關于操作系統(tǒng)、硬件學習相關的博客:x86匯編學習、操作系統(tǒng)學習(持續(xù)更新中)。
二、硬件I/O模型
軟件的功能總是構建在硬件上的,計算機中的I/O本質上是CPU/內存與外設(網卡、磁盤等)進行數據的單向或雙向傳輸。
從外設讀入數據到CPU/內存稱作Input輸入,從CPU/內存中寫出數據到外設稱作Output輸出。
要想理解軟件層次上的不同I/O模型,必須先對其基于的硬件I/O模型有一個基本的認識。硬件I/O模型大致可以分為三種:程序控制I/O、中斷驅動I/O、使用DMA的I/O。
程序控制I/O:
程序控制I/O模型中,通過指令控制CPU不斷的輪詢外設是否就緒,當硬件就緒時一點一點的反復讀/寫數據。
從CPU的角度來說,程序控制I/O模型是同步、阻塞的(同步指的是I/O操作依然是處于程序指令控制,由CPU主導的;阻塞指的是在發(fā)起I/O后CPU必須持續(xù)輪詢完成狀態(tài),無法執(zhí)行別的指令)。
程序控制I/O的優(yōu)點:
硬件結構簡單,編寫對應程序也簡單。
程序控制I/O的缺點:
十分消耗CPU,持續(xù)的輪訓令寶貴的CPU資源無謂的浪費在了等待I/O完成的過程中,導致CPU利用率不高。
中斷驅動I/O:
為了解決上述程序控制I/O模型對CPU資源利用率不高的問題,計算機硬件的設計者令CPU擁有了處理中斷的功能。
在中斷驅動I/O模型中,CPU發(fā)起對外設的I/O請求后,就直接去執(zhí)行別的指令了。當硬件處理完I/O請求后,通過中斷異步的通知CPU。接到讀取完成中斷通知后,CPU負責將數據從外設緩沖區(qū)中寫入內存;接到寫出完成中斷通知后,CPU需要將內存中后續(xù)的數據接著寫出交給外設處理。
從CPU的角度來說,中斷驅動I/O模型是同步、非阻塞的(同步指的是I/O操作依然是處于程序指令控制,由CPU主導的;非阻塞指的是在發(fā)起I/O后CPU不會停下等待,而是可以執(zhí)行別的指令)。
中斷驅動I/O的優(yōu)點:
由于I/O總是相對耗時的,比起通過程序控制I/O模型下CPU不停的輪訓。在等待硬件I/O完成的過程中CPU可以解放出來執(zhí)行另外的命令,大大提高了I/O密集程序的CPU利用率。
中斷驅動I/O的缺點:
受制于硬件緩沖區(qū)的大小,一次硬件I/O可以處理的數據是相對有限的。在處理一次大數據的I/O請求中,CPU需要被反復的中斷,而處理讀寫中斷事件本身也是有一定開銷的。
使用DMA的I/O:
為了解決中斷驅動I/O模型中,大數據量的I/O傳輸使得CPU需要反復處理中斷的缺陷,計算機硬件的設計者提出了基于DMA模式的I/O(DMA Direct Memory Access 直接存儲器訪問)。DMA也是一種處理器芯片,和CPU一樣也可以訪問內存和外設,但DMA芯片是被設計來專門處理I/O數據傳輸的,因此其成本相對CPU較低。
在使用DMA的I/O模型中,CPU與DMA芯片交互,指定需要讀/寫的數據塊大小和需要進行I/O數據的目的內存地址后,便異步的處理別的指令了。由DMA與外設硬件進行交互,一次大數據量的I/O需要DMA反復的與外設進行交互,當DMA完成了整體數據塊的I/O后(完整的將數據讀入到內存或是完整的將某一內存塊的數據寫出到外設),再發(fā)起DMA中斷通知CPU。
從CPU的角度來說,使用DMA的I/O模型是異步、非阻塞的(異步指的是整個I/O操作并不是由CPU主導,而是由DMA芯片與外設交互完成的;非阻塞指的是在發(fā)起I/O后CPU不會停下等待,而是可以執(zhí)行別的指令)。
使用DMA的I/O優(yōu)點:
比起外設硬件中斷通知,對于一次完整的大數據內存與外設間的I/O,CPU只需要處理一次中斷。CPU的利用效率相對來說是最高的。
使用DMA的I/O缺點:
1. 引入DMA芯片令硬件結構變復雜,成本較高。
2. 由于DMA芯片的引入,使得DMA和CPU并發(fā)的對內存進行操作,在擁有高速緩存的CPU中,引入了高速緩存與內存不一致的問題。
總的來說,自DMA技術被發(fā)明以來,由于其極大減少了CPU在I/O時的性能損耗,已經成為了絕大多數通用計算機的硬件標配。隨著技術的發(fā)展又出現(xiàn)了更先進的通道I/O方式,相當于并發(fā)的DMA,允許并發(fā)的處理涉及多個不同內存區(qū)域、外設硬件的I/O操作。
三、操作系統(tǒng)I/O模型
介紹完硬件的I/O模型后,下面介紹這篇博客的重點:操作系統(tǒng)I/O模型。
操作系統(tǒng)幫我們屏蔽了諸多硬件外設的差異,為應用程序的開發(fā)者提供了友好、統(tǒng)一的服務。為了避免應用程序破壞操作系統(tǒng)內核,CPU提供了保護模式機制,使得應用程序無法直接訪問被操作系統(tǒng)管理起來的外設,而必須通過內核提供的系統(tǒng)調用間接的訪問外設。關于操作系統(tǒng)I/O模型的討論針對的就是應用程序與內核之間進行I/O交互的系統(tǒng)調用模型。
' 操作系統(tǒng)內核提供的I/O模型大致可以分為幾種:同步阻塞I/O、同步非阻塞I/O、同步I/O多路復用、異步非阻塞I/O(信號驅動I/O用的比較少,就不在這里展開了)。
同步阻塞I/O(Blocking I/O BIO)
我們已經知道,高效的硬件層面I/O模型對于CPU來說是異步的,但應用程序開發(fā)者總是希望在執(zhí)行完I/O系統(tǒng)調用后能同步的返回,線性的執(zhí)行后續(xù)邏輯(例如當磁盤讀取的系統(tǒng)調用返回后,下一行代碼中就能直接訪問到所讀出的數據)。但這與硬件層面耗時、異步的I/O模型相違背(程序控制I/O過于浪費CPU),因此操作系統(tǒng)內核提供了基于同步、阻塞I/O的系統(tǒng)調用(BIO)來解決這一問題。
舉個例子:當線程通過基于BIO的系統(tǒng)調用進行磁盤讀取時,內核會令當前線程進入阻塞態(tài),讓出CPU資源給其它并發(fā)的就緒態(tài)線程,以便更有效率的利用CPU。當DMA完成讀取,異步的I/O中斷到來時,內核會找到先前被阻塞的對應線程,將其喚醒進入就緒態(tài)。當這個就緒態(tài)的線程被內核CPU調度器選中再度獲得CPU時,便能從對應的緩沖區(qū)結構中得到讀取到的磁盤數據,程序同步的執(zhí)行流便能順利的向下執(zhí)行了。(感覺好像線程卡在了那里不動,過了一會才執(zhí)行下一行,且指定的緩沖區(qū)中已經有了所需的數據)
下面的偽代碼示例中參考linux的設計,將不同的外設統(tǒng)一抽象為文件,通過文件描述符(file descriptor)來統(tǒng)一的訪問。
BIO偽代碼實例 :
//?創(chuàng)建TCP套接字并綁定端口8888,進行服務監(jiān)聽
listenfd?=?serverSocket(8888,"tcp");
while(true){
????//?accept同步阻塞調用
????newfd?=?accept(listenfd);
????//?read會阻塞,因此使用線程異步處理,避免阻塞accpet(一般使用線程池)
????new?thread(()->{
????????//?同步阻塞讀取數據
????????xxx?=?read(newfd);
????????...?dosomething
????????//?關閉連接
????????close(newfd);
????});
}
BIO模型的優(yōu)點:
BIO的I/O模型由于同步、阻塞的特性,屏蔽了底層實質上異步的硬件交互方式,令程序員可以編寫出簡單易懂的線性程序邏輯。
BIO模型的缺點:
1. BIO的同步、阻塞特性在簡單易用的同時,也存在一些性能上的缺陷。由于BIO在等待I/O完成的時間中,線程雖然被阻塞不消耗CPU,但內核維護一個系統(tǒng)級線程本身也是有一定的開銷(維護線程控制塊、內核線程??臻g等等)。
2. 不同線程在調度時的上下文切換CPU開銷較大,在如今大量用戶、高并發(fā)的互聯(lián)網時代越來越成為web服務器性能的瓶頸。線程上下文切換本身需要需要保存、恢復現(xiàn)場,同時還會清空CPU指令流水線,以及令高速緩存大量失效。對于一個web服務器,如果使用BIO模型,服務器將至少需要1:1的維護同等數量的系統(tǒng)級線程(內核線程),由于持續(xù)并發(fā)的網絡數據交互,導致不同線程由于網絡I/O的完成事件被內核反復的調度。
在著名的C10K問題的語境下,一臺服務器需要同時維護1W個并發(fā)的tcp連接和對等的1W個系統(tǒng)級線程。量變引起質變,1W個系統(tǒng)級線程調度引起的上下文切換和100個系統(tǒng)級線程的調度開銷完全不同,其將耗盡CPU資源,令整個系統(tǒng)卡死,崩潰。
BIO交互流程示意圖:

同步非阻塞I/O(NonBlocking I/O NIO)
BIO模型簡單易用,但其阻塞內核線程的特性使得其已經不適用于需要處理大量(1K以上)并發(fā)網絡連接場景的web服務器了。為此,操作系統(tǒng)內核提供了非阻塞特性的I/O系統(tǒng)調用,即NIO(NonBlocking-IO)。
針對BIO模型的缺陷,NIO模型的系統(tǒng)調用不會阻塞當前調用線程。但由于I/O本質上的耗時特性,無法立即得到I/O處理的結果,NIO的系統(tǒng)調用在I/O未完成時會返回特定標識,代表對應的I/O事件還未完成。因此需要應用程序按照一定的頻率反復調用,以獲取最新的IO狀態(tài)。
NIO偽代碼實例 :
//?創(chuàng)建TCP套接字并綁定端口8888,進行服務監(jiān)聽
listenfd?=?serverSocket(8888,"tcp");
clientFdSet?=?empty_set();
while(true){?//?開啟事件監(jiān)聽循環(huán)
????//?accept同步非阻塞調用,判斷是否接收了新的連接
????newfd?=?acceptNonBlock(listenfd);
????if(newfd?!=?EMPTY){
????????//?如果存在新連接將其加入監(jiān)聽連接集合
????????clientFdSet.add(newfd);
????}
????//?申請一個1024字節(jié)的緩沖區(qū)
????buffer?=?new?buffer(1024);
????for(clientfd?in?clientFdSet){
????????//?非阻塞read讀
????????num?=?readNonBlock(clientfd,buffer);
????????if(num?>?0){
????????????//?讀緩沖區(qū)存在數據
????????????data?=?buffer;
????????????...?dosomething
????????????if(needClose(data)){
????????????????//?關閉連接時,移除當前監(jiān)聽的連接
????????????????clientFdSet.remove(clientfd);
????????????}
????????}
????????...?dosomething
????????//?清空buffer
????????buffer.clear();
????}
}
NIO模型的優(yōu)點:
NIO因為其非阻塞的特性,使得一個線程可以處理多個并發(fā)的網絡I/O連接。在C10K問題的語境下,理論上可以通過一個線程處理這1W個并發(fā)連接(對于多核CPU,可以創(chuàng)建多個線程在每個CPU核心中分攤負載,提高性能)。
NIO模型的缺點:
NIO克服了BIO在高并發(fā)條件下的缺陷,但原始的NIO系統(tǒng)調用依然有著一定的性能問題。在上述偽代碼示例中,每個文件描述符對應的I/O狀態(tài)查詢,都必須通過一次NIO系統(tǒng)調用才能完成。
由于操作系統(tǒng)內核利用CPU提供的保護模式機制,使內核運行在高特權級,而令用戶程序運行在執(zhí)行、訪問受限的低特權級。這樣設計的一個好處就是使得應用程序無法直接的訪問硬件,而必須由操作系統(tǒng)提供的系統(tǒng)調用間接的訪問硬件(網卡、磁盤甚至電源等)。執(zhí)行系統(tǒng)調用時,需要令應用線程通過系統(tǒng)調用陷入內核(即提高應用程序的當前特權級CPL,使其能夠訪問受保護的硬件),并在系統(tǒng)調用返回時恢復為低特權級,這樣一個過程在硬件上是通過中斷實現(xiàn)的。
通過中斷實現(xiàn)系統(tǒng)調用的效率遠低于應用程序本地的函數調用,因此原始的NIO模式下通過系統(tǒng)調用循環(huán)訪問每個文件描述符I/O就緒狀態(tài)的方式是低效的。
NIO交互流程示意圖:

同步I/O多路復用(I/O Multiplexing)
為了解決上述NIO模型的系統(tǒng)調用中,一次事件循環(huán)遍歷進行N次系統(tǒng)調用的缺陷。操作系統(tǒng)內核在NIO系統(tǒng)調用的基礎上提供了I/O多路復用模型的系統(tǒng)調用。
I/O多路復用相對于NIO模型的一個優(yōu)化便是允許在一次I/O狀態(tài)查詢的系統(tǒng)調用中,一次傳遞復數個文件描述符進行批量的I/O狀態(tài)查詢。在一次事件循環(huán)中只需要進行一次I/O多路復用的系統(tǒng)調用就能得到所傳遞文件描述符集合的I/O狀態(tài),減少了原始NIO模型中不必要的系統(tǒng)調用開銷。
多路復用I/O模型大致可以分為三種實現(xiàn)(雖然不同操作系統(tǒng)在最終實現(xiàn)上略有不同,但原理是類似的,示例代碼以linux內核舉例):select、poll、epoll。
select多路復用器介紹
select I/O多路復用器允許應用程序傳遞需要監(jiān)聽事件變化的文件描述符集合,監(jiān)聽其讀/寫,接受連接等I/O事件的狀態(tài)。
select系統(tǒng)調用本身是同步、阻塞的,當所傳遞的文件描述符集合中都沒有就緒的I/O事件時,執(zhí)行select系統(tǒng)調用的線程將會進入阻塞態(tài),直到至少一個文件描述符對應的I/O事件就緒,則喚醒被select阻塞的線程(可以指定超時時間來強制喚醒并返回)。喚醒后獲得CPU的線程在select系統(tǒng)調用返回后可以遍歷所傳入的文件描述符集合,處理完成了I/O事件的文件描述符。
select偽代碼示例:
//?創(chuàng)建TCP套接字并綁定端口8888,進行服務監(jiān)聽
listenfd?=?serverSocket(8888,"tcp");
fdNum?=?1;
clientFdSet?=?empty_set();
clientFdSet.add(listenfd);
while(true){?//?開啟事件監(jiān)聽循環(huán)
????//?man?2?select(查看linux系統(tǒng)文檔)
????//?int?select(int?nfds,?fd_set?*readfds,?fd_set?*writefds,?fd_set?*exceptfds,?struct?timeval?*timeout);
????//?參數nfds:一共需要監(jiān)聽的readfds、writefds、exceptfds中文件描述符個數+1
????//?參數readfds/writefds/exceptfds:?需要監(jiān)聽讀、寫、異常事件的文件描述符集合
????//?參數timeout:select是同步阻塞的,當timeout時間內都沒有任何I/O事件就緒,則調用線程被喚醒并返回(ret=0)
????//?????????timeout為null代表永久阻塞
????//?返回值ret:
????//??1.返回大于0的整數,代表傳入的readfds/writefds/exceptfds中共有ret個被激活(需要應用程序自己遍歷),
????//????2.返回0,在阻塞超時前沒有任何I/O事件就緒
????//????3.返回-1,出現(xiàn)錯誤
????listenReadFd?=?clientFdSet;
????//?select多路復用,一次傳入需要監(jiān)聽事件的全量連接集合(超時時間1s)
????result?=?select(fdNum+1,listenReadFd,null,null,timeval("1s"));
????if(result?>?0){
????????//?如果服務器監(jiān)聽連接存在讀事件
????????if(IN_SET(listenfd,listenReadFd)){
????????????//?接收并建立連接
????????????newClientFd?=?accept(listenfd);
????????????//?加入客戶端連接集合
????????????clientFdSet.add(newClientFd);???????fdNum++;
????????}
????????
????????//?遍歷整個需要監(jiān)聽的客戶端連接集合
????????for(clientFd?:?clientFdSet){
????????????//?如果當前客戶端連接存在讀事件
????????????if(IN_SET(clientFd,listenReadFd)){
????????????????//?阻塞讀取數據
????????????????data?=?read(clientfd);
????????????????...?dosomething
????????????????
????????????????if(needClose(data)){
????????????????????//?關閉連接時,移除當前監(jiān)聽的連接
????????????????????clientFdSet.remove(clientfd);????????????fdNum--;
????????????????}
????????????}
????????}
????}
}
select的優(yōu)點:
1. select多路復用避免了上述原始NIO模型中無謂的多次查詢I/O狀態(tài)的系統(tǒng)調用,將其聚合成集合,批量的進行監(jiān)聽并返回結果集。
2. select實現(xiàn)相對簡單,windows、linux等主流的操作系統(tǒng)都實現(xiàn)了select系統(tǒng)調用,跨平臺的兼容性好。
select的缺點:
1. 在事件循環(huán)中,每次select系統(tǒng)調用都需要從用戶態(tài)全量的傳遞所需要監(jiān)聽的文件描述符集合,并且select返回后還需要全量遍歷之前傳入的文件描述符集合的狀態(tài)。
2. 出于性能的考量,內核設置了select所監(jiān)聽文件描述符集合元素的最大數量(一般為1024,可在內核啟動時指定),使得單次select所能監(jiān)聽的連接數受到了限制。
3. 拋開性能的考慮,從接口設計的角度來看,select將系統(tǒng)調用的參數與返回值混合到了一起(返回值覆蓋了參數),增加了使用者理解的困難度。
I/O多路復用交互示意圖:

poll多路復用器介紹
poll I/O多路復用器在使用上和select大同小異,也是通過傳入指定的文件描述符集合以及指定內核監(jiān)聽對應文件描述符上的I/O事件集合,但在實現(xiàn)的細節(jié)上基于select做了一定的優(yōu)化。
和select一樣,poll系統(tǒng)調用在沒有任何就緒事件發(fā)生時也是同步、阻塞的(可以指定超時時間強制喚醒并返回),當返回后要判斷是否有就緒事件時,也一樣需要全量的遍歷整個返回的文件描述符集合。
poll偽代碼示例:
/*
//?man?2?poll(查看linux系統(tǒng)文檔)
//?和select不同將參數events和返回值revents分開了
struct?pollfd?{
???????????????int???fd;?????????//?file?descriptor?對應的文件描述符?
???????????????short?events;?????//?requested?events?需要監(jiān)聽的事件
???????????????short?revents;????//?returned?events?返回時,就緒的事件
???????????};
//?參數fds,要監(jiān)聽的poolfd數組集合
//?參數nfds,傳入fds數組中需要監(jiān)聽的元素個數
//?參數timeout,阻塞的超時時間(傳入-1代表永久阻塞)
int?poll(struct?pollfd?*fds,?nfds_t?nfds,?int?timeout);
//events/revents是位圖表示的
//revents?&?POLLIN?==?1?存在就緒的讀事件
//revents?&?POLLOUT?==?1?存在就緒的寫事件
//revents?&?POLLHUP?==?1?存在對端斷開連接或是通信完成事件
*/
//?創(chuàng)建TCP套接字并綁定端口8888,進行服務監(jiān)聽
listenfd?=?serverSocket(8888,"tcp");
MAX_LISTEN_SIZE?=?100;
struct?pollfd?fds[MAX_LISTEN_SIZE];
//?設置服務器監(jiān)聽套接字(監(jiān)聽讀事件)
fds[0].fd?=?listenfd;
fds[0].events?=?POLLIN;
fds[0].revents?=?0;
//?客戶端連接數一開始為0
int?clientCount?=?0;
while(true){
????//?poll同步阻塞調用(超時時間-1表示永久阻塞直到存在監(jiān)聽的就緒事件)
????int?ret?=?poll(fds,?clientCount?+?1,?-1);
????????
????for?(int?i?=?0;?i?????????if(fds[i].fd?==?listenfd?&&?fds[i].revents?&?POLLIN){
????????????//?服務器監(jiān)聽套接字讀事件就緒,建立新連接
????????????clientCount++;
????????????fds[clientCount].fd?=?conn;
????????????fds[clientCount].events?=?POLLIN?|?POLLRDHUP?;
????????????fds[clientCount].revents?=?0;
????????}else?if(fds[i].revents?&?POLLIN){
????????????//?其他鏈接可讀,進行讀取
????????????read(fds[i].fd);
????????????...?doSomething
????????}else?if(fds[i].revents?&?POLLRDHUP){
????????????//?監(jiān)聽到客戶端連接斷開,移除該連接
????????????fds[i]?=?fds[clientCount];
????????????i--;
????????????clientCount--;
????????????//?關閉該連接
????????????close(fd);
????????}
????}
}
poll的優(yōu)點:
1. poll解決了select系統(tǒng)調用受限于內核配置參數的限制問題,可以同時監(jiān)聽更多文件描述符的I/O狀態(tài)(但不能超過內核限制當前進程所能擁有的最大文件描述符數目限制)。
2. 優(yōu)化了接口設計,將參數與返回值的進行了分離。
poll的缺點:
1. poll優(yōu)化了select,但在處理大量閑置連接時,即使真正產生I/O就緒事件的活躍文件描述符數量很少,依然免不了線性的遍歷整個監(jiān)聽的文件描述符集合。每次調用時,需要全量的將整個感興趣的文件描述符集合從用戶態(tài)復制到內核態(tài)。
2. 由于select/poll都需要全量的傳遞參數以及遍歷返回值,因此其時間復雜度為O(n),即處理的開銷隨著并發(fā)連接數n的增加而增加,而無論并發(fā)連接本身活躍與否。但一般情況下即使并發(fā)連接數很多,大量連接都產生I/O就緒事件的情況并不多,更多的情況是1W的并發(fā)連接,可能只有幾百個是處于活躍狀態(tài)的,這種情況下select/poll的性能并不理想,還存在優(yōu)化的空間。
epoll多路復用器:
epoll是linux系統(tǒng)中獨有的,針對select/poll上述缺點進行改進的高性能I/O多路復用器。
針對poll系統(tǒng)調用介紹中的第一個缺點:在每次事件循環(huán)時都需要從用戶態(tài)全量傳遞整個需要監(jiān)聽的文件描述符集合。
epoll在內核中分配內存空間用于緩存被監(jiān)聽的文件描述符集合。通過創(chuàng)建epoll的系統(tǒng)調用(epoll_create),在內核中維護了一個epoll結構,而在應用程序中只需要保留epoll結構的句柄就可對其進行訪問(也是一個文件描述符)。可以動態(tài)的在epoll結構的內核空間中增加/刪除/更新所要監(jiān)聽的文件描述符以及不同的監(jiān)聽事件(epoll_ctl),而不必每次都全量的傳遞需要監(jiān)聽的文件描述符集合。
針對select/poll的第二個缺點:在系統(tǒng)調用返回后通過修改所監(jiān)聽文件描述符結構的狀態(tài),來標識文件描述符對應的I/O事件是否就緒。每次系統(tǒng)調用返回時,都需要全量的遍歷整個監(jiān)聽文件描述符集合,而無論是否真的完成了I/O。
epoll監(jiān)聽事件的系統(tǒng)調用完成后,只會將真正活躍的、完成了I/O事件的文件描述符返回,避免了全量的遍歷。在并發(fā)的連接數很大,但閑置連接占比很高時,epoll的性能大大優(yōu)于select/poll這兩種I/O多路復用器。epoll的時間復雜度為O(m),即處理的開銷不隨著并發(fā)連接n的增加而增加,而是僅僅和監(jiān)控的活躍連接m相關;在某些情況下n遠大于m,epoll的時間復雜度甚至可以認為近似的達到了O(1)。
通過epoll_wait系統(tǒng)調用,監(jiān)聽參數中傳入對應epoll結構中關聯(lián)的所有文件描述符的對應I/O狀態(tài)。epoll_wait本身是同步、阻塞的(可以指定超時時間強制喚醒并返回),當epoll_wait同步返回時,會返回處于活躍狀態(tài)的完成I/O事件的文件描述符集合,避免了select/poll中的無效遍歷。同時epoll使用了mmap機制,將內核中的維護的就緒文件描述符集合所在空間映射到了用戶態(tài),令應用程序與epoll的內核共享這一區(qū)域的內存,避免了epoll返回就緒文件描述符集合時的一次內存復制。
epoll偽代碼示例:
/**
????epoll比較復雜,使用時大致依賴三個系統(tǒng)調用?(man?7?epoll)
????1.?epoll_create?創(chuàng)建一個epoll結構,返回對應epoll的文件描述符?(man?2?epoll_create)
????????int?epoll_create();
????2.?epoll_ctl?控制某一epoll結構(epfd),向其增加/刪除/更新(op)某一其它連接(fd),監(jiān)控其I/O事件(event)?(man?2?epoll_ctl)
????????op有三種合法值:EPOLL_CTL_ADD代表新增、EPOLL_CTL_MOD代表更新、EPOLL_CTL_DEL代表刪除
????????int?epoll_ctl(int?epfd,?int?op,?int?fd,?struct?epoll_event?*event);
????3.?epoll_wait?令某一epoll同步阻塞的開始監(jiān)聽(epfd),感興趣的I/O事件(events),所監(jiān)聽fd的最大個數(maxevents),指定阻塞超時時間(timeout)?(man?2?epoll_wait)
????????int?epoll_wait(int?epfd,?struct?epoll_event?*events,int?maxevents,?int?timeout);
*/
//?創(chuàng)建TCP套接字并綁定端口8888,進行服務監(jiān)聽
listenfd?=?serverSocket(8888,"tcp");
//?創(chuàng)建一個epoll結構
epollfd?=?epoll_create();
ev?=?new?epoll_event();
ev.events?=?EPOLLIN;?//?讀事件
ev.data.fd?=?listenfd;
//?通過epoll監(jiān)聽服務器端口讀事件(新連接建立請求)
epoll_ctl(epollfd,EPOLL_CTL_ADD,listenfd,ev);
//?最大監(jiān)聽1000個連接
MAX_EVENTS?=?1000;
listenEvents?=?new?event[MAX_EVENTS];
while(true){
????//?同步阻塞監(jiān)聽事件
????//?最多返回MAX_EVENTS個事件響應結果
????//?(超時時間1000ms,標識在超時時間內沒有任何事件就緒則當前線程被喚醒,返回值nfd將為0)
????nfds?=?epoll_wait(epollfd,?listenEvents,?MAX_EVENTS,?1?*?1000);
????????
????for(n?=?0;?n?????????if(events[n].data.fd?==?listenfd){
????????????//?當發(fā)現(xiàn)服務器監(jiān)聽套接字存在可讀事件,建立新的套接字連接
????????????clientfd?=?accept(listenfd);
????????????ev.events?=?EPOLLIN?|?EPOLLET;
????????????ev.data.fd?=?clientfd;
????????????//?新建立的套接字連接也加入當前epoll的監(jiān)聽(監(jiān)聽讀(EPOLLIN)/寫(EPOLLET)事件)
????????????epoll_ctl(epollfd,EPOLL_CTL_ADD,clientfd,ev);
????????}?else{
????????????//?否則是其它連接的I/O事件就緒,進行對應的操作
????????????...?do_something
????????}
????}
}
epoll的優(yōu)點:
epoll是目前性能最好的I/O多路復用器之一,具有I/O多路復用優(yōu)點的情況下很好的解決了select/poll的缺陷。目前l(fā)inux平臺中,像nginx、redis、netty等高性能服務器都是首選epoll作為基礎來實現(xiàn)網絡I/O功能的。
epoll的缺點:
1. 常規(guī)情況下閑置連接占比很大,epoll的性能表現(xiàn)的很好。但是也有少部分場景中,絕大多數連接都是活躍的,那么其性能與select/poll這種基于位圖、數組等簡單結構的I/O多路復用器相比,就不那么有優(yōu)勢了。因為select/poll被詬病的一點就是通常情況下進行了無謂的全量檢查,而當活躍連接數占比一直超過90%甚至更高時,就不再是浪費了;相反的,由于epoll內部結構比較復雜,在這種情況下其性能比select/poll還要低一點。
2. epoll是linux操作系統(tǒng)下獨有的,使得基于epoll實現(xiàn)的應用程序的跨平臺兼容性受到了一定影響。
異步非阻塞I/O(Asynchronous I/O AIO)
windows和linux都支持了select系統(tǒng)調用,但linux內核在之后又實現(xiàn)了epoll這一更高性能的I/O多路復用器來改進select。
windows沒有模仿linux,而是提供了被稱為IOCP(Input/Output Completion Port 輸入輸出完成端口)的功能解決select性能的問題。IOCP采用異步非阻塞IO(AIO)的模型,其與epoll同步非阻塞IO的最大區(qū)別在于,epoll調用完成后,僅僅返回了就緒的文件描述符集合;而IOCP則在內核中自動的完成了epoll中原本應該由應用程序主動發(fā)起的I/O操作。
舉個例子,當監(jiān)聽到就緒事件開始讀取某一網絡連接的請求報文時,epoll依然需要通過程序主動的發(fā)起讀取請求,將數據從內核中讀入用戶空間。而windows下的IOCP則是通過注冊回調事件的方式工作,由內核自動的將數據放入指定的用戶空間,當處理完畢后會調度激活注冊的回調事件,被喚醒的線程能直接訪問到所需要的數據。
這也是為什么BIO/NIO/IO多路復用被稱為同步I/O,而IOCP被稱為異步I/O的原因。
同步I/O與異步I/O的主要區(qū)別就在于站在應用程序的視角看,真正讀取/寫入數據時是否是由應用程序主導的。如果需要用戶程序主動發(fā)起最終的I/O請求就被稱為同步I/O;而如果是內核自動完成I/O后通知用戶程序,則被稱為異步I/O。(可以類比在前面硬件I/O模型中,站在CPU視角的同步、異步I/O模型,只不過這里CPU變成了應用程序,而外設/DMA變成了操作系統(tǒng)內核)
AIO的優(yōu)點:
AIO作為異步I/O,由內核自動的完成了底層一整套的I/O操作,應用程序在事件回調通知中能直接獲取到所需數據。內核中可以實現(xiàn)非常高效的調度、通知框架。擁有前面NIO高性能的優(yōu)點,又簡化了應用程序的開發(fā)。
AIO的缺點:
由內核全盤控制的全自動I/O雖然能夠做到足夠高效,但是在一些特定場景下性能并不一定能超過由應用程序主導的,經過深度優(yōu)化的代碼。像epoll在支持了和select/poll一樣的水平觸發(fā)I/O的同時,還支持了更加細致的邊緣觸發(fā)I/O,允許用戶自主的決定當I/O就緒時,是否需要立即處理或是緩存起來等待稍后再處理。(就像java等支持自動內存垃圾回收的語言,即使其垃圾收集器經過持續(xù)的優(yōu)化,在大多數情況下性能都很不錯,但卻依然無法達到和經過開發(fā)人員反復調優(yōu),手動回收內存的C、C++等語言實現(xiàn)的程序一樣的性能)
(截圖自《Unix網絡編程 卷1》)
操作系統(tǒng)I/O模型小結
1. 同步I/O包括了同步阻塞I/O和同步非阻塞I/O,而異步I/O中由于異步阻塞I/O模型沒有太大價值,因此提到異步I/O(AIO)時,默認指的就是異步非阻塞I/O。

2. 在I/O多路復用器的工作中,當監(jiān)聽到對應文件描述符I/O事件就緒時,后續(xù)進行的讀/寫操作既可以是阻塞的,也可以是非阻塞的。如果是都以阻塞的方式進行讀/寫,雖然實現(xiàn)簡單,但如果某一文件描述符需要讀寫的數據量很大時將耗時較多,可能會導致事件循環(huán)中的其它事件得不到及時處理。因此截圖中的阻塞讀寫數據部分并不準確,需要辯證的看待。
四、非阻塞I/O是銀彈嗎?
計算機技術的發(fā)展看似日新月異,但本質上有兩類目標指引著其前進。一是盡可能的增強、壓榨硬件的性能,提高機器效率;二是盡可能的通過持續(xù)的抽象、封裝簡化軟件復雜度,提高程序員的開發(fā)效率。計算機軟件的發(fā)展方向必須至少需要滿足其中一種目標。
從上面關于操作系統(tǒng)內核I/O模型的發(fā)展中可以看到,最初被廣泛使用的是易理解、開發(fā)簡單的BIO模型;但由于互聯(lián)網時代的到來,web服務器系統(tǒng)面臨著C10K問題,需要能支持海量的并發(fā)客戶端連接,因此出現(xiàn)了包括NIO、I/O多路復用、AIO等技術,利用一個內核線程管理成百上千的并發(fā)連接,來解決BIO模型中一個內核線程對應一個網絡連接的工作模式中,由于處理大量連接導致內核線程上下文頻繁切換,造成CPU資源耗盡的問題。上述的第一條原則指引著內核I/O模型的發(fā)展,使得web服務器能夠獲得更大的連接服務吞吐量,提高了機器效率。
但非阻塞I/O真的是完美無缺的嗎?
有著非阻塞I/O模型開發(fā)經驗的程序員都知道,正是由于一個內核線程管理著成百上千個客戶端連接,因此在整個線程的執(zhí)行流中不能出現(xiàn)耗時、阻塞的操作(比如同步阻塞的數據庫查詢、rpc接口調用等)。如果這種操作不可避免,則需要單獨使用另外的線程異步的處理,而不能阻塞當前的整個事件循環(huán),否則將會導致其它連接的請求得不到及時的處理,造成饑餓。
對于多數互聯(lián)網分布式架構下處理業(yè)務邏輯的應用程序服務器來說,在一個網絡請求服務中,可能需要頻繁的訪問數據庫或者通過網絡遠程調用其它服務的接口。如果使用的是基于NIO模型進行工作的話,則要求rpc庫以及數據庫、中間件等連接的庫是支持異步非阻塞的。如果由于同步阻塞庫的存在,在每次接受連接進行服務時依然被迫通過另外的線程處理以避免阻塞,則NIO服務器的性能將退化到和使用傳統(tǒng)的BIO模型一樣的地步。
所幸的是隨著非阻塞I/O的逐漸流行,上述問題得到了很大的改善,越來越的框架/庫都提供了異步非阻塞的api接口。
非阻塞I/O帶來的新問題
異步非阻塞庫改變了同步阻塞庫下程序員習以為常的,線性的思維方式,在編碼時被迫的以事件驅動的方式思考。邏輯上連貫的業(yè)務代碼為了適應異步非阻塞的庫程序,被迫分隔成多個獨立片段嵌套在各個不同層次的回調函數中。對于復雜的業(yè)務而言,很容易出現(xiàn)嵌套為一層層的回調函數調用鏈,形成臭名昭著的callback hell(回調地獄)。
最早被callback hell折磨的可能是客戶端程序的開發(fā)人員,因為客戶端程序需要時刻監(jiān)聽著用戶操作事件的產生,通常以基于事件驅動的方式組織異步處理代碼。
callback hell偽代碼示例:
//?由于互相之間有前后的數據依賴,按照順序異步的調用A、B、C、D
A.dosomething((res)->{
????data?=?res.xxx;
????B.dosomething(data,(res)->{
????????data?=?res.xxx;
????????C.dosomething(data,(res)->{
????????????data?=?res.xxx
????????????D.dosomething(data,(res)->{
????????????????//?。。。?有依賴的同步業(yè)務越復雜,層次越深,就像一個無底洞
????????????})
????????})
????})
})
異步非阻塞庫的使用割裂了代碼的連貫結構,使得程序變得難以理解、調試,這一缺陷在堆積著復雜晦澀業(yè)務邏輯的web應用程序服務器程序中顯得難以忍受。這也是為什么如今web服務器仍然有很大一部分依然使用傳統(tǒng)的同步阻塞的BIO模型進行開發(fā)的主要原因。通過分布式、集群的方式分攤大量并發(fā)的連接,而只在業(yè)務相對簡單的API網關、消息隊列等I/O密集型的中間件程序中NIO才被廣泛使用(實在不行,業(yè)務服務器集群可以加機器,保證開發(fā)效率也同樣重要)。
那么就沒有什么辦法既能夠擁有非阻塞I/O支撐海量并發(fā)、高吞吐量的性能優(yōu)勢;又能夠令程序員以同步方式思考、編寫程序,以提高開發(fā)效率嗎?
解決辦法當然是存在的,且相關技術依然在不斷發(fā)展。上述計算機技術發(fā)展的第二個原則指導著這些技術發(fā)展,目的是為了簡化代碼復雜性,提高程序員的效率。
1. 優(yōu)化語法、語言庫以簡化異步編程的難度
在函數式編程的領域,就一直有著諸多晦澀的“黑科技”(CPS變換、monad等),能夠簡化callback hell,使得可以以幾乎是同步的方式編寫實質上是異步執(zhí)行的代碼。例如EcmaScript便在EcmaScript6、EcmaScript7中分別引入了promise和async/await來解決這一問題。
2. 在語言級別支持用戶級線程(協(xié)程)
前面提到,傳統(tǒng)的基于BIO模型的工作模式最大的優(yōu)點在于可以同步的編寫代碼,遇到需要等待的耗時操作時能夠被阻塞,使用起來簡單易懂。但由于1:1的維護內核線程在處理海量連接時由于頻繁的內核線程上下文切換而力不從心,催生了非阻塞I/O。
而由于上述非阻塞I/O引起的代碼復雜度增加的問題,計算機科學家們想到了很早之前就在操作系統(tǒng)概念中提出,但一直沒有被廣泛使用的另一種線程實現(xiàn)方式:用戶級線程。
用戶級線程顧名思義,就是在用戶級實現(xiàn)的線程,操作系統(tǒng)內核對其是無感知的。用戶級線程在許多方面與大家所熟知的內核級線程相似,都有著自己獨立的執(zhí)行流,和進程中的其它線程共享內存空間。
用戶級線程與內核級線程最大的一個區(qū)別就在于由于操作系統(tǒng)對其無感知,因此無法對用戶級線程進行基于中斷的搶占式調度。要使得同一進程下的不同用戶級線程能夠協(xié)調工作,必須小心的編寫執(zhí)行邏輯,以互相之間主動讓渡CPU的形式工作,否則將會導致一個用戶級線程持續(xù)不斷的占用CPU,而令其它用戶級線程處于饑餓狀態(tài),因此用戶級線程也被稱為協(xié)程,即互相協(xié)作的線程。
用戶級線程無論如何是基于至少一個內核線程/進程的,多個用戶級線程可以掛載在一個內核線程/進程中被內核統(tǒng)一的調度管理。
(截圖自《現(xiàn)代操作系統(tǒng)》)
協(xié)程可以在遇到I/O等耗時操作時選擇主動的讓出CPU,以實現(xiàn)同步阻塞的效果,令程序執(zhí)行流轉移到另一個協(xié)程中。由于多個協(xié)程可以復用一個內核線程,每個協(xié)程所占用的開銷相對內核級線程來說非常??;且協(xié)程上下文切換時由于不需要陷入內核,其切換效率也遠比內核線程的上下文切換高(開銷近似于一個函數調用)。
最近很流行的Go語言就是由于其支持語言層面的協(xié)程而備受推崇。程序員可以利用一些語言層面提供的協(xié)程機制編寫高效的web服務器程序(例如在語句中添加控制協(xié)程同步的關鍵字)。通過在編譯后的最終代碼中加入對應的協(xié)程調度指令,由協(xié)程調度器接手,控制協(xié)程同步時在耗時I/O操作發(fā)生時主動的讓出CPU,并在處理完畢后能被調度回來接著執(zhí)行。Go語言通過語言層面上對協(xié)程的支持,降低了編寫正確、協(xié)調工作的協(xié)程代碼的難度。
Go編寫的高性能web服務器如果運行在多核CPU的linux操作系統(tǒng)中,一般會創(chuàng)建m個內核線程和n個協(xié)程(m正比與CPU核心數,n遠大于m且正比于并發(fā)連接數),底層每個內核線程依然可以利用epoll IO多路復用器處理并發(fā)的網絡連接,并將業(yè)務邏輯處理的任務轉交給用戶態(tài)的協(xié)程(gorountine)。每個協(xié)程可以在不同的內核線程(CPU核心)中被來回調度,以獲得最大的CPU吞吐量。
使用協(xié)程,程序員在開發(fā)時能夠編寫同步阻塞的耗時I/O代碼,又不用擔心高并發(fā)情況下BIO模型中的性能問題??梢哉f協(xié)程兼顧了程序開發(fā)效率與機器執(zhí)行效率,因此越來越多的語言也在語言層面或是在函數庫中提供協(xié)程機制。
3. 實現(xiàn)用戶透明的協(xié)程
在通過虛擬機作為中間媒介,操作系統(tǒng)平臺無關的語言中(比如java),虛擬機作為應用程序與操作系統(tǒng)內核的中間層,可以對應用程序進行各方面的優(yōu)化,令程序員可以輕松編寫出高效的代碼。
有大牛在知乎的一篇回答中提到過,其曾經領導團隊在阿里巴巴工作時在java中實現(xiàn)了透明的協(xié)程。但似乎沒有和官方標準達成統(tǒng)一因此并沒有對外開放。
如果能夠在虛擬機中提供高效、用戶透明的協(xié)程機制,使得原本基于BIO多線程的服務器程序無需改造便自動的獲得了支持海量并發(fā)的能力,那真是太強了Orz。
五、總結
通過對ucore操作系統(tǒng)源碼級的研究學習,加深了我對操作系統(tǒng)原理書中各種抽象概念的理解,也漸漸理解了一些關于各種I/O模型的問題。
一方面,通過對操作系統(tǒng)I/O模型的總結,使得我對于上層應用程序如java中的nio和netty中的非阻塞的編程風格有了更深的理解,不再像之前只習慣于BIO編程那樣感到奇怪,而是覺得非常自然。另一方面,又意識到了自己還有太多的不足。
站在操作系統(tǒng)I/O模型這一層面,向上看,依然對基于nio的各種中間件不太熟悉,不了解在具體實踐中如何利用好NIO這一利器,寫出魯棒、高效的代碼;向下看,由于ucore為了盡可能的簡化實驗課的難度,省略了很多的功能沒有實現(xiàn),導致我對于操作系統(tǒng)底層是如何實現(xiàn)網絡協(xié)議棧、如何實現(xiàn)nio和io多路復用器的原理知之甚少,暫時只能將其當作黑盒子看待,很多地方可能理解的有偏差。令我在拓寬知識面的同時,感嘆知道的越多就越感覺自己無知,但人總是要向前走的,在學習中希望盡量能做到知其然而知其所以然。通過對ucore操作系統(tǒng)的學習,使得我對于操作系統(tǒng)內核的學習不再感到恐懼,在認知學習概念中就是從恐懼區(qū)轉為了學習區(qū)。以后有機會的話,可以通過研究早期的linux內核源碼來解答我關于I/O模型底層實現(xiàn)的一系列問題。
這篇博客是這一段時間來對操作系統(tǒng)學習的一個階段性總結,直接或間接的回答了博客開頭的幾個問題,希望能幫到對操作系統(tǒng)、I/O模型感興趣的人。這篇文章中還存在許多理解不到位的地方,請多多指教。
粉絲福利:實戰(zhàn)springboot+CAS單點登錄系統(tǒng)視頻教程免費領取
???
?長按上方微信二維碼?2 秒 即可獲取資料
感謝點贊支持下哈?
