談談對不同I/O模型的理解 (阻塞/非阻塞IO,同步/非同步IO)

小熊餐館發表於2020-11-10

一、關於I/O模型的問題

  最近通過對ucore作業系統的學習,讓我開啟了作業系統核心這一黑盒子,與之前所學知識結合起來,解答了長久以來困擾我的關於I/O的一些問題。

  1. 為什麼redis能以單工作執行緒處理高達幾萬的併發請求?

  2. 什麼是I/O多路複用?為什麼redis、nginx、nodeJS以及netty等以高效能著稱的伺服器其底層都利用了I/O多路複用技術?

  3. 非阻塞I/O為什麼會流行起來,在許多場景下取代了傳統的阻塞I/O?

  4. 非阻塞I/O真的是銀彈嗎?為什麼即使在為海量使用者提供服務的,追求高效能的網際網路公司中依然有那麼多的伺服器在傳統的阻塞IO模型下工作?

  5. 什麼是協程?為什麼Go語言這麼受歡迎?

  在這篇部落格中,將介紹不同層面、不同I/O模型的原理,並嘗試著給出我對上述問題的回答。如果你也或多或少的對上述問題感到疑惑,希望這篇部落格能為你提供幫助。

  I/O模型和硬體、作業系統核心息息相關,部落格中會涉及到諸如保護模式、中斷、特權級、程式/執行緒、上下文切換、系統呼叫等關於作業系統、硬體相關的概念。由於計算機中的知識是按照層次組織起來的,如果對這些相對底層的概念不是很瞭解的話可能會影響對整體內容的理解。可以參考一下我關於作業系統、硬體學習相關的部落格:x86彙編學習作業系統學習(持續更新中)

二、硬體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主導的;阻塞指的是在發起I/O後CPU必須持續輪詢完成狀態,無法執行別的指令)。

程式控制I/O的優點:

  硬體結構簡單,編寫對應程式也簡單。

程式控制I/O的缺點:

  十分消耗CPU,持續的輪訓令寶貴的CPU資源無謂的浪費在了等待I/O完成的過程中,導致CPU利用率不高。

中斷驅動I/O:

  為了解決上述程式控制I/O模型對CPU資源利用率不高的問題,計算機硬體的設計者令CPU擁有了處理中斷的功能。

  在中斷驅動I/O模型中,CPU發起對外設的I/O請求後,就直接去執行別的指令了。當硬體處理完I/O請求後,通過中斷非同步的通知CPU。接到讀取完成中斷通知後,CPU負責將資料從外設緩衝區中寫入記憶體;接到寫出完成中斷通知後,CPU需要將記憶體中後續的資料接著寫出交給外設處理。

  從CPU的角度來說,中斷驅動I/O模型是同步、非阻塞的(同步指的是I/O操作依然是處於程式指令控制,由CPU主導的;非阻塞指的是在發起I/O後CPU不會停下等待,而是可以執行別的指令)。

中斷驅動I/O的優點:

  由於I/O總是相對耗時的,比起通過程式控制I/O模型下CPU不停的輪訓。在等待硬體I/O完成的過程中CPU可以解放出來執行另外的命令,大大提高了I/O密集程式的CPU利用率。

中斷驅動I/O的缺點:

  受制於硬體緩衝區的大小,一次硬體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後(完整的將資料讀入到記憶體或是完整的將某一記憶體塊的資料寫出到外設),再發起DMA中斷通知CPU。

  從CPU的角度來說,使用DMA的I/O模型是非同步、非阻塞的(非同步指的是整個I/O操作並不是由CPU主導,而是由DMA晶片與外設互動完成的;非阻塞指的是在發起I/O後CPU不會停下等待,而是可以執行別的指令)。

使用DMA的I/O優點:

  比起外設硬體中斷通知,對於一次完整的大資料記憶體與外設間的I/O,CPU只需要處理一次中斷。CPU的利用效率相對來說是最高的。

使用DMA的I/O缺點:

  1. 引入DMA晶片令硬體結構變複雜,成本較高。

  2. 由於DMA晶片的引入,使得DMA和CPU併發的對記憶體進行操作,在擁有快取記憶體的CPU中,引入了快取記憶體與記憶體不一致的問題

  總的來說,自DMA技術被發明以來,由於其極大減少了CPU在I/O時的效能損耗,已經成為了絕大多數通用計算機的硬體標配。隨著技術的發展又出現了更先進的通道I/O方式,相當於併發的DMA,允許併發的處理涉及多個不同記憶體區域、外設硬體的I/O操作。

三、作業系統I/O模型

  介紹完硬體的I/O模型後,下面介紹這篇部落格的重點:作業系統I/O模型。

  作業系統幫我們遮蔽了諸多硬體外設的差異,為應用程式的開發者提供了友好、統一的服務。為了避免應用程式破壞作業系統核心,CPU提供了保護模式機制,使得應用程式無法直接訪問被作業系統管理起來的外設,而必須通過核心提供的系統呼叫間接的訪問外設。關於作業系統I/O模型的討論針對的就是應用程式與核心之間進行I/O互動的系統呼叫模型。

'  作業系統核心提供的I/O模型大致可以分為幾種:同步阻塞I/O、同步非阻塞I/O、同步I/O多路複用、非同步非阻塞I/O(訊號驅動I/O用的比較少,就不在這裡展開了)。

同步阻塞I/O(Blocking I/O BIO)

  我們已經知道,高效的硬體層面I/O模型對於CPU來說是非同步的,但應用程式開發者總是希望在執行完I/O系統呼叫後能同步的返回,線性的執行後續邏輯(例如當磁碟讀取的系統呼叫返回後,下一行程式碼中就能直接訪問到所讀出的資料)。但這與硬體層面耗時、非同步的I/O模型相違背(程式控制I/O過於浪費CPU),因此作業系統核心提供了基於同步、阻塞I/O的系統呼叫(BIO)來解決這一問題。

  舉個例子:當執行緒通過基於BIO的系統呼叫進行磁碟讀取時,核心會令當前執行緒進入阻塞態,讓出CPU資源給其它併發的就緒態執行緒,以便更有效率的利用CPU。當DMA完成讀取,非同步的I/O中斷到來時,核心會找到先前被阻塞的對應執行緒,將其喚醒進入就緒態。當這個就緒態的執行緒被核心CPU排程器選中再度獲得CPU時,便能從對應的緩衝區結構中得到讀取到的磁碟資料,程式同步的執行流便能順利的向下執行了。(感覺好像執行緒卡在了那裡不動,過了一會才執行下一行,且指定的緩衝區中已經有了所需的資料)

  下面的虛擬碼示例中參考linux的設計,將不同的外設統一抽象為檔案,通過檔案描述符(file descriptor)來統一的訪問。

BIO虛擬碼例項 :

// 建立TCP套接字並繫結埠8888,進行服務監聽
listenfd = serverSocket(8888,"tcp");
while(true){
    // accept同步阻塞呼叫
    newfd = accept(listenfd);

    // read會阻塞,因此使用執行緒非同步處理,避免阻塞accpet(一般使用執行緒池)
    new thread(()->{
        // 同步阻塞讀取資料
        xxx = read(newfd);
        ... dosomething
        // 關閉連線
        close(newfd);
    });
}

BIO模型的優點:

  BIO的I/O模型由於同步、阻塞的特性,遮蔽了底層實質上非同步的硬體互動方式,令程式設計師可以編寫出簡單易懂的線性程式邏輯。

BIO模型的缺點:

  1. BIO的同步、阻塞特性在簡單易用的同時,也存在一些效能上的缺陷。由於BIO在等待I/O完成的時間中,執行緒雖然被阻塞不消耗CPU,但核心維護一個系統級執行緒本身也是有一定的開銷(維護執行緒控制塊、核心執行緒棧空間等等)。

  2. 不同執行緒在排程時的上下文切換CPU開銷較大,在如今大量使用者、高併發的網際網路時代越來越成為web伺服器效能的瓶頸。執行緒上下文切換本身需要需要儲存、恢復現場,同時還會清空CPU指令流水線,以及令快取記憶體大量失效。對於一個web伺服器,如果使用BIO模型,伺服器將至少需要1:1的維護同等數量的系統級執行緒(核心執行緒),由於持續併發的網路資料互動,導致不同執行緒由於網路I/O的完成事件被核心反覆的排程。

  在著名的C10K問題的語境下,一臺伺服器需要同時維護1W個併發的tcp連線和對等的1W個系統級執行緒。量變引起質變,1W個系統級執行緒排程引起的上下文切換和100個系統級執行緒的排程開銷完全不同,其將耗盡CPU資源,令整個系統卡死,崩潰。

BIO互動流程示意圖:

  

同步非阻塞I/O(NonBlocking I/O NIO)

  BIO模型簡單易用,但其阻塞核心執行緒的特性使得其已經不適用於需要處理大量(1K以上)併發網路連線場景的web伺服器了。為此,作業系統核心提供了非阻塞特性的I/O系統呼叫,即NIO(NonBlocking-IO)

  針對BIO模型的缺陷,NIO模型的系統呼叫不會阻塞當前呼叫執行緒。但由於I/O本質上的耗時特性,無法立即得到I/O處理的結果,NIO的系統呼叫在I/O未完成時會返回特定標識,代表對應的I/O事件還未完成。因此需要應用程式按照一定的頻率反覆呼叫,以獲取最新的IO狀態。

NIO虛擬碼例項 :

// 建立TCP套接字並繫結埠8888,進行服務監聽
listenfd = serverSocket(8888,"tcp");
clientFdSet = empty_set();
while(true){ // 開啟事件監聽迴圈
    // accept同步非阻塞呼叫,判斷是否接收了新的連線
    newfd = acceptNonBlock(listenfd);

    if(newfd != EMPTY){
        // 如果存在新連線將其加入監聽連線集合
        clientFdSet.add(newfd);
    }
    // 申請一個1024位元組的緩衝區
    buffer = new buffer(1024);
    for(clientfd in clientFdSet){
        // 非阻塞read讀
        num = readNonBlock(clientfd,buffer);
        if(num > 0){
            // 讀緩衝區存在資料
            data = buffer;
            ... dosomething
            if(needClose(data)){
                // 關閉連線時,移除當前監聽的連線
                clientFdSet.remove(clientfd);
            }
        }
        ... dosomething
        // 清空buffer
        buffer.clear();
    }
}

NIO模型的優點:

  NIO因為其非阻塞的特性,使得一個執行緒可以處理多個併發的網路I/O連線。在C10K問題的語境下,理論上可以通過一個執行緒處理這1W個併發連線(對於多核CPU,可以建立多個執行緒在每個CPU核心中分攤負載,提高效能)。

NIO模型的缺點:

  NIO克服了BIO在高併發條件下的缺陷,但原始的NIO系統呼叫依然有著一定的效能問題。在上述虛擬碼示例中,每個檔案描述符對應的I/O狀態查詢,都必須通過一次NIO系統呼叫才能完成。

  由於作業系統核心利用CPU提供的保護模式機制,使核心執行在高特權級,而令使用者程式執行在執行、訪問受限的低特權級。這樣設計的一個好處就是使得應用程式無法直接的訪問硬體,而必須由作業系統提供的系統呼叫間接的訪問硬體(網路卡、磁碟甚至電源等)。執行系統呼叫時,需要令應用執行緒通過系統呼叫陷入核心(即提高應用程式的當前特權級CPL,使其能夠訪問受保護的硬體),並在系統呼叫返回時恢復為低特權級,這樣一個過程在硬體上是通過中斷實現的。

  通過中斷實現系統呼叫的效率遠低於應用程式本地的函式呼叫,因此原始的NIO模式下通過系統呼叫迴圈訪問每個檔案描述符I/O就緒狀態的方式是低效的。

NIO互動流程示意圖:

  

同步I/O多路複用(I/O Multiplexing)

  為了解決上述NIO模型的系統呼叫中,一次事件迴圈遍歷進行N次系統呼叫的缺陷。作業系統核心在NIO系統呼叫的基礎上提供了I/O多路複用模型的系統呼叫。

  I/O多路複用相對於NIO模型的一個優化便是允許在一次I/O狀態查詢的系統呼叫中,一次傳遞複數個檔案描述符進行批量的I/O狀態查詢。在一次事件迴圈中只需要進行一次I/O多路複用的系統呼叫就能得到所傳遞檔案描述符集合的I/O狀態,減少了原始NIO模型中不必要的系統呼叫開銷。

  多路複用I/O模型大致可以分為三種實現(雖然不同作業系統在最終實現上略有不同,但原理是類似的,示例程式碼以linux核心舉例):select、poll、epoll。

select多路複用器介紹

  select I/O多路複用器允許應用程式傳遞需要監聽事件變化的檔案描述符集合,監聽其讀/寫,接受連線等I/O事件的狀態。

  select系統呼叫本身是同步、阻塞的,當所傳遞的檔案描述符集合中都沒有就緒的I/O事件時,執行select系統呼叫的執行緒將會進入阻塞態,直到至少一個檔案描述符對應的I/O事件就緒,則喚醒被select阻塞的執行緒(可以指定超時時間來強制喚醒並返回)。喚醒後獲得CPU的執行緒在select系統呼叫返回後可以遍歷所傳入的檔案描述符集合,處理完成了I/O事件的檔案描述符。

select虛擬碼示例:

// 建立TCP套接字並繫結埠8888,進行服務監聽
listenfd = serverSocket(8888,"tcp");
fdNum = 1;
clientFdSet = empty_set();
clientFdSet.add(listenfd);
while(true){ // 開啟事件監聽迴圈
    // man 2 select(檢視linux系統文件)
    // int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
    // 引數nfds:一共需要監聽的readfds、writefds、exceptfds中檔案描述符個數+1
    // 引數readfds/writefds/exceptfds: 需要監聽讀、寫、異常事件的檔案描述符集合
    // 引數timeout:select是同步阻塞的,當timeout時間內都沒有任何I/O事件就緒,則呼叫執行緒被喚醒並返回(ret=0)
    //         timeout為null代表永久阻塞
    // 返回值ret:
    //  1.返回大於0的整數,代表傳入的readfds/writefds/exceptfds中共有ret個被啟用(需要應用程式自己遍歷),
    //    2.返回0,在阻塞超時前沒有任何I/O事件就緒
    //    3.返回-1,出現錯誤

    listenReadFd = clientFdSet;
    // select多路複用,一次傳入需要監聽事件的全量連線集合(超時時間1s)
    result = select(fdNum+1,listenReadFd,null,null,timeval("1s"));
    if(result > 0){
        // 如果伺服器監聽連線存在讀事件
        if(IN_SET(listenfd,listenReadFd)){
            // 接收並建立連線
            newClientFd = accept(listenfd);
            // 加入客戶端連線集合
            clientFdSet.add(newClientFd);
       fdNum++; }
// 遍歷整個需要監聽的客戶端連線集合 for(clientFd : clientFdSet){ // 如果當前客戶端連線存在讀事件 if(IN_SET(clientFd,listenReadFd)){ // 阻塞讀取資料 data = read(clientfd); ... dosomething if(needClose(data)){ // 關閉連線時,移除當前監聽的連線 clientFdSet.remove(clientfd);
            fdNum--; } } } } }

select的優點:

  1. select多路複用避免了上述原始NIO模型中無謂的多次查詢I/O狀態的系統呼叫,將其聚合成集合,批量的進行監聽並返回結果集。

  2. select實現相對簡單,windows、linux等主流的作業系統都實現了select系統呼叫,跨平臺的相容性好。

select的缺點:

  1. 在事件迴圈中,每次select系統呼叫都需要從使用者態全量的傳遞所需要監聽的檔案描述符集合,並且select返回後還需要全量遍歷之前傳入的檔案描述符集合的狀態。

  2. 出於效能的考量,核心設定了select所監聽檔案描述符集合元素的最大數量(一般為1024,可在核心啟動時指定),使得單次select所能監聽的連線數受到了限制。

  3. 拋開效能的考慮,從介面設計的角度來看,select將系統呼叫的引數與返回值混合到了一起(返回值覆蓋了引數),增加了使用者理解的困難度。

I/O多路複用互動示意圖:

  

poll多路複用器介紹

  poll I/O多路複用器在使用上和select大同小異,也是通過傳入指定的檔案描述符集合以及指定核心監聽對應檔案描述符上的I/O事件集合,但在實現的細節上基於select做了一定的優化。

  和select一樣,poll系統呼叫在沒有任何就緒事件發生時也是同步、阻塞的(可以指定超時時間強制喚醒並返回),當返回後要判斷是否有就緒事件時,也一樣需要全量的遍歷整個返回的檔案描述符集合。

poll虛擬碼示例:

/*
// man 2 poll(檢視linux系統文件)
// 和select不同將引數events和返回值revents分開了
struct pollfd {
               int   fd;         // file descriptor 對應的檔案描述符 
               short events;     // requested events 需要監聽的事件
               short revents;    // returned events 返回時,就緒的事件
           };

// 引數fds,要監聽的poolfd陣列集合
// 引數nfds,傳入fds陣列中需要監聽的元素個數
// 引數timeout,阻塞的超時時間(傳入-1代表永久阻塞)
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

//events/revents是點陣圖表示的
//revents & POLLIN == 1 存在就緒的讀事件
//revents & POLLOUT == 1 存在就緒的寫事件
//revents & POLLHUP == 1 存在對端斷開連線或是通訊完成事件
*/

// 建立TCP套接字並繫結埠8888,進行服務監聽
listenfd = serverSocket(8888,"tcp");

MAX_LISTEN_SIZE = 100;
struct pollfd fds[MAX_LISTEN_SIZE];
// 設定伺服器監聽套接字(監聽讀事件)
fds[0].fd = listenfd;
fds[0].events = POLLIN;
fds[0].revents = 0;
// 客戶端連線數一開始為0
int clientCount = 0;

while(true){
    // poll同步阻塞呼叫(超時時間-1表示永久阻塞直到存在監聽的就緒事件)
    int ret = poll(fds, clientCount + 1, -1);
        
    for (int i = 0; i < clientCount + 1; i++){
        if(fds[i].fd == listenfd && fds[i].revents & POLLIN){
            // 伺服器監聽套接字讀事件就緒,建立新連線
            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){
            // 監聽到客戶端連線斷開,移除該連線
            fds[i] = fds[clientCount];
            i--;
            clientCount--;
            // 關閉該連線
            close(fd);
        }
    }
}

poll的優點:

  1. poll解決了select系統呼叫受限於核心配置引數的限制問題,可以同時監聽更多檔案描述符的I/O狀態(但不能超過核心限制當前程式所能擁有的最大檔案描述符數目限制)。

  2. 優化了介面設計,將引數與返回值的進行了分離。

poll的缺點:

  1. poll優化了select,但在處理大量閒置連線時,即使真正產生I/O就緒事件的活躍檔案描述符數量很少,依然免不了線性的遍歷整個監聽的檔案描述符集合。每次呼叫時,需要全量的將整個感興趣的檔案描述符集合從使用者態複製到核心態。

  2. 由於select/poll都需要全量的傳遞引數以及遍歷返回值,因此其時間複雜度為O(n),即處理的開銷隨著併發連線數n的增加而增加,而無論併發連線本身活躍與否。但一般情況下即使併發連線數很多,大量連線都產生I/O就緒事件的情況並不多,更多的情況是1W的併發連線,可能只有幾百個是處於活躍狀態的,這種情況下select/poll的效能並不理想,還存在優化的空間。

epoll多路複用器:

  epoll是linux系統中獨有的,針對select/poll上述缺點進行改進的高效能I/O多路複用器。

  針對poll系統呼叫介紹中的第一個缺點:在每次事件迴圈時都需要從使用者態全量傳遞整個需要監聽的檔案描述符集合

  epoll在核心中分配記憶體空間用於快取被監聽的檔案描述符集合。通過建立epoll的系統呼叫(epoll_create),在核心中維護了一個epoll結構,而在應用程式中只需要保留epoll結構的控制程式碼就可對其進行訪問(也是一個檔案描述符)。可以動態的在epoll結構的核心空間中增加/刪除/更新所要監聽的檔案描述符以及不同的監聽事件(epoll_ctl),而不必每次都全量的傳遞需要監聽的檔案描述符集合。

  針對select/poll的第二個缺點:在系統呼叫返回後通過修改所監聽檔案描述符結構的狀態,來標識檔案描述符對應的I/O事件是否就緒。每次系統呼叫返回時,都需要全量的遍歷整個監聽檔案描述符集合,而無論是否真的完成了I/O。

  epoll監聽事件的系統呼叫完成後,只會將真正活躍的、完成了I/O事件的檔案描述符返回,避免了全量的遍歷。在併發的連線數很大,但閒置連線佔比很高時,epoll的效能大大優於select/poll這兩種I/O多路複用器。epoll的時間複雜度為O(m),即處理的開銷不隨著併發連線n的增加而增加,而是僅僅和監控的活躍連線m相關;在某些情況下n遠大於m,epoll的時間複雜度甚至可以認為近似的達到了O(1)。

  通過epoll_wait系統呼叫,監聽引數中傳入對應epoll結構中關聯的所有檔案描述符的對應I/O狀態。epoll_wait本身是同步、阻塞的(可以指定超時時間強制喚醒並返回),當epoll_wait同步返回時,會返回處於活躍狀態的完成I/O事件的檔案描述符集合,避免了select/poll中的無效遍歷。同時epoll使用了mmap機制,將核心中的維護的就緒檔案描述符集合所在空間對映到了使用者態,令應用程式與epoll的核心共享這一區域的記憶體,避免了epoll返回就緒檔案描述符集合時的一次記憶體複製。

epoll虛擬碼示例:

/**
    epoll比較複雜,使用時大致依賴三個系統呼叫 (man 7 epoll)
    1. epoll_create 建立一個epoll結構,返回對應epoll的檔案描述符 (man 2 epoll_create)
        int epoll_create();
    2. epoll_ctl 控制某一epoll結構(epfd),向其增加/刪除/更新(op)某一其它連線(fd),監控其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同步阻塞的開始監聽(epfd),感興趣的I/O事件(events),所監聽fd的最大個數(maxevents),指定阻塞超時時間(timeout) (man 2 epoll_wait)
        int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
*/

// 建立TCP套接字並繫結埠8888,進行服務監聽
listenfd = serverSocket(8888,"tcp");
// 建立一個epoll結構
epollfd = epoll_create();

ev = new epoll_event();
ev.events = EPOLLIN; // 讀事件
ev.data.fd = listenfd;
// 通過epoll監聽伺服器埠讀事件(新連線建立請求)
epoll_ctl(epollfd,EPOLL_CTL_ADD,listenfd,ev);

// 最大監聽1000個連線
MAX_EVENTS = 1000;
listenEvents = new event[MAX_EVENTS];
while(true){
    // 同步阻塞監聽事件
    // 最多返回MAX_EVENTS個事件響應結果
    // (超時時間1000ms,標識在超時時間內沒有任何事件就緒則當前執行緒被喚醒,返回值nfd將為0)
    nfds = epoll_wait(epollfd, listenEvents, MAX_EVENTS, 1 * 1000);
        
    for(n = 0; n < nfds; ++n){
        if(events[n].data.fd == listenfd){
            // 當發現伺服器監聽套接字存在可讀事件,建立新的套接字連線
            clientfd = accept(listenfd);

            ev.events = EPOLLIN | EPOLLET;
            ev.data.fd = clientfd;
            // 新建立的套接字連線也加入當前epoll的監聽(監聽讀(EPOLLIN)/寫(EPOLLET)事件)
            epoll_ctl(epollfd,EPOLL_CTL_ADD,clientfd,ev);
        } else{
            // 否則是其它連線的I/O事件就緒,進行對應的操作
            ... do_something
        }
    }
}

epoll的優點:

  epoll是目前效能最好的I/O多路複用器之一,具有I/O多路複用優點的情況下很好的解決了select/poll的缺陷。目前linux平臺中,像nginx、redis、netty等高效能伺服器都是首選epoll作為基礎來實現網路I/O功能的。

epoll的缺點:

  1. 常規情況下閒置連線佔比很大,epoll的效能表現的很好。但是也有少部分場景中,絕大多數連線都是活躍的,那麼其效能與select/poll這種基於點陣圖、陣列等簡單結構的I/O多路複用器相比,就不那麼有優勢了。因為select/poll被詬病的一點就是通常情況下進行了無謂的全量檢查,而當活躍連線數佔比一直超過90%甚至更高時,就不再是浪費了;相反的,由於epoll內部結構比較複雜,在這種情況下其效能比select/poll還要低一點。

  2. epoll是linux作業系統下獨有的,使得基於epoll實現的應用程式的跨平臺相容性受到了一定影響。

非同步非阻塞I/O(Asynchronous I/O AIO)

  windows和linux都支援了select系統呼叫,但linux核心在之後又實現了epoll這一更高效能的I/O多路複用器來改進select。

  windows沒有模仿linux,而是提供了被稱為IOCP(Input/Output Completion Port 輸入輸出完成埠)的功能解決select效能的問題。IOCP採用非同步非阻塞IO(AIO)的模型,其與epoll同步非阻塞IO的最大區別在於,epoll呼叫完成後,僅僅返回了就緒的檔案描述符集合;而IOCP則在核心中自動的完成了epoll中原本應該由應用程式主動發起的I/O操作。

  舉個例子,當監聽到就緒事件開始讀取某一網路連線的請求報文時,epoll依然需要通過程式主動的發起讀取請求,將資料從核心中讀入使用者空間。而windows下的IOCP則是通過註冊回撥事件的方式工作,由核心自動的將資料放入指定的使用者空間,當處理完畢後會排程啟用註冊的回撥事件,被喚醒的執行緒能直接訪問到所需要的資料。

  這也是為什麼BIO/NIO/IO多路複用被稱為同步I/O,而IOCP被稱為非同步I/O的原因。

  同步I/O與非同步I/O的主要區別就在於站在應用程式的視角看,真正讀取/寫入資料時是否是由應用程式主導的。如果需要使用者程式主動發起最終的I/O請求就被稱為同步I/O;而如果是核心自動完成I/O後通知使用者程式,則被稱為非同步I/O。(可以類比在前面硬體I/O模型中,站在CPU視角的同步、非同步I/O模型,只不過這裡CPU變成了應用程式,而外設/DMA變成了作業系統核心)

AIO的優點:

  AIO作為非同步I/O,由核心自動的完成了底層一整套的I/O操作,應用程式在事件回撥通知中能直接獲取到所需資料。核心中可以實現非常高效的排程、通知框架。擁有前面NIO高效能的優點,又簡化了應用程式的開發。

AIO的缺點:

  由核心全盤控制的全自動I/O雖然能夠做到足夠高效,但是在一些特定場景下效能並不一定能超過由應用程式主導的,經過深度優化的程式碼。像epoll在支援了和select/poll一樣的水平觸發I/O的同時,還支援了更加細緻的邊緣觸發I/O,允許使用者自主的決定當I/O就緒時,是否需要立即處理或是快取起來等待稍後再處理。(就像java等支援自動記憶體垃圾回收的語言,即使其垃圾收集器經過持續的優化,在大多數情況下效能都很不錯,但卻依然無法達到和經過開發人員反覆調優,手動回收記憶體的C、C++等語言實現的程式一樣的效能)

  (截圖自《Unix網路程式設計 卷1》)

作業系統I/O模型小結

  1. 同步I/O包括了同步阻塞I/O和同步非阻塞I/O,而非同步I/O中由於非同步阻塞I/O模型沒有太大價值,因此提到非同步I/O(AIO)時,預設指的就是非同步非阻塞I/O。

  

  2. 在I/O多路複用器的工作中,當監聽到對應檔案描述符I/O事件就緒時,後續進行的讀/寫操作既可以是阻塞的,也可以是非阻塞的。如果是都以阻塞的方式進行讀/寫,雖然實現簡單,但如果某一檔案描述符需要讀寫的資料量很大時將耗時較多,可能會導致事件迴圈中的其它事件得不到及時處理。因此截圖中的阻塞讀寫資料部分並不準確,需要辯證的看待。

四、非阻塞I/O是銀彈嗎?

  計算機技術的發展看似日新月異,但本質上有兩類目標指引著其前進。一是儘可能的增強、壓榨硬體的效能,提高機器效率;二是儘可能的通過持續的抽象、封裝簡化軟體複雜度,提高程式設計師的開發效率。計算機軟體的發展方向必須至少需要滿足其中一種目標。

  從上面關於作業系統核心I/O模型的發展中可以看到,最初被廣泛使用的是易理解、開發簡單的BIO模型;但由於網際網路時代的到來,web伺服器系統面臨著C10K問題,需要能支援海量的併發客戶端連線,因此出現了包括NIO、I/O多路複用、AIO等技術,利用一個核心執行緒管理成百上千的併發連線,來解決BIO模型中一個核心執行緒對應一個網路連線的工作模式中,由於處理大量連線導致核心執行緒上下文頻繁切換,造成CPU資源耗盡的問題。上述的第一條原則指引著核心I/O模型的發展,使得web伺服器能夠獲得更大的連線服務吞吐量,提高了機器效率。

  但非阻塞I/O真的是完美無缺的嗎?

  有著非阻塞I/O模型開發經驗的程式設計師都知道,正是由於一個核心執行緒管理著成百上千個客戶端連線,因此在整個執行緒的執行流中不能出現耗時、阻塞的操作(比如同步阻塞的資料庫查詢、rpc介面呼叫等)。如果這種操作不可避免,則需要單獨使用另外的執行緒非同步的處理,而不能阻塞當前的整個事件迴圈,否則將會導致其它連線的請求得不到及時的處理,造成飢餓。

  對於多數網際網路分散式架構下處理業務邏輯的應用程式伺服器來說,在一個網路請求服務中,可能需要頻繁的訪問資料庫或者通過網路遠端呼叫其它服務的介面。如果使用的是基於NIO模型進行工作的話,則要求rpc庫以及資料庫、中介軟體等連線的庫是支援非同步非阻塞的。如果由於同步阻塞庫的存在,在每次接受連線進行服務時依然被迫通過另外的執行緒處理以避免阻塞,則NIO伺服器的效能將退化到和使用傳統的BIO模型一樣的地步。

  所幸的是隨著非阻塞I/O的逐漸流行,上述問題得到了很大的改善。

非阻塞I/O帶來的新問題

  非同步非阻塞庫改變了同步阻塞庫下程式設計師習以為常的,線性的思維方式,在編碼時被迫的以事件驅動的方式思考。邏輯上連貫的業務程式碼為了適應非同步非阻塞的庫程式,被迫分隔成多個獨立片段巢狀在各個不同層次的回撥函式中。對於複雜的業務而言,很容易出現巢狀為一層層的回撥函式,形成臭名昭著的callback hell(回撥地獄)

  最早被callback hell折磨的可能是客戶端程式的開發人員,因為客戶端程式需要時刻監聽著使用者操作事件的產生,通常以基於事件驅動的方式組織非同步處理程式碼。

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)->{
                // 。。。 有依賴的同步業務越複雜,層次越深,就像一個無底洞
            })
        })
    })
})

  非同步非阻塞庫的使用割裂了程式碼的連貫結構,使得程式變得難以理解、除錯,這一缺陷在堆積著複雜晦澀業務邏輯的web應用程式伺服器程式中顯得難以忍受。這也是為什麼如今web伺服器仍然有很大一部分依然使用傳統的同步阻塞的BIO模型進行開發的主要原因。通過分散式、叢集的方式分攤大量併發的連線,而只在業務相對簡單的API閘道器、訊息佇列等I/O密集型的中介軟體程式中NIO才被廣泛使用(實在不行,業務伺服器叢集可以加機器,保證開發效率也同樣重要)。

  那麼就沒有什麼辦法既能夠擁有非阻塞I/O支撐海量併發、高吞吐量的效能優勢;又能夠令程式設計師以同步方式思考、編寫程式,以提高開發效率嗎?

  解決辦法當然是存在的,且相關技術依然在不斷髮展。上述計算機技術發展的第二個原則指導著這些技術發展,目的是為了簡化程式碼複雜性,提高程式設計師的效率。

1. 優化語法、語言庫以簡化非同步程式設計的難度

  在函數語言程式設計的領域,就一直有著諸多晦澀的“黑科技”(CPS變換、monad等),能夠簡化callback hell,使得可以以幾乎是同步的方式編寫實質上是非同步執行的程式碼。例如EcmaScript便在EcmaScript6、EcmaScript7中分別引入了promise和async/await來解決這一問題。

2. 在語言級別支援使用者級執行緒(協程)

  前面提到,傳統的基於BIO模型的工作模式最大的優點在於可以同步的編寫程式碼,遇到需要等待的耗時操作時能夠被同步阻塞,使用起來簡單易懂。但由於1:1的維護核心執行緒在處理海量連線時由於頻繁的核心執行緒上下文切換而力不從心,催生了非阻塞I/O。

  而由於上述非阻塞I/O引起的程式碼複雜度的增加,電腦科學家們想到了很早之前就在作業系統概念中提出,但一直沒有被廣泛使用的另一種執行緒實現方式:使用者級執行緒。

  使用者級執行緒顧名思義,就是在使用者級實現的執行緒,作業系統核心對其是無感知的。使用者級執行緒在許多方面與大家所熟知的核心級執行緒相似,都有著自己獨立的執行流,和程式中的其它執行緒共享記憶體空間。

  使用者級執行緒與核心級執行緒最大的一個區別就在於由於作業系統對其無感知,因此無法對使用者級執行緒進行基於中斷的搶佔式排程。要使得同一程式下的不同使用者級執行緒能夠協調工作,必須小心的編寫執行邏輯,以互相之間主動讓渡CPU的形式工作,否則將會導致一個使用者級執行緒持續不斷的佔用CPU,而令其它使用者級執行緒處於飢餓狀態,因此使用者級執行緒也被稱為協程,即互相協作的執行緒。

  使用者級執行緒無論如何是基於至少一個核心執行緒/程式的,多個使用者級執行緒可以掛載在一個核心執行緒/程式中被核心統一的排程管理。

  (截圖自《現代作業系統》)

  協程可以在遇到I/O等耗時操作時選擇主動的讓出CPU,以實現同步阻塞的效果,令程式執行流轉移到另一個協程中。由於多個協程可以複用一個核心執行緒,每個協程所佔用的開銷相對核心級執行緒來說非常小;且協程上下文切換時由於不需要陷入核心,其切換效率也遠比核心執行緒的上下文切換高(開銷近似於一個函式呼叫)。

  最近很流行的Go語言就是由於其支援語言層面的協程而備受推崇。程式設計師可以利用一些語言層面提供的協程機制編寫高效的web伺服器程式(例如在語句中新增控制協程同步的關鍵字)。通過在編譯後的最終程式碼中加入對應的協程排程指令,由協程排程器接手,控制協程同步時在耗時I/O操作發生時主動的讓出CPU,並在處理完畢後能被排程回來接著執行。Go語言通過語言層面上對協程的支援,降低了編寫正確、協調工作的協程程式碼的難度。

  Go編寫的高效能web伺服器如果執行在多核CPU的linux作業系統中,一般會建立m個核心執行緒和n個協程(m正比與CPU核心數,n遠大於m且正比於併發連線數),底層每個核心執行緒依然可以利用epoll IO多路複用器處理併發的網路連線,並將業務邏輯處理的任務轉交給使用者態的協程(gorountine)。每個協程可以在不同的核心執行緒(CPU核心)中被來回撥度,以獲得最大的CPU吞吐量。

  使用協程,程式設計師在開發時能夠編寫同步阻塞的耗時I/O程式碼,又不用擔心高併發情況下BIO模型中的效能問題。可以說協程兼顧了程式開發效率與機器執行效率,因此越來越多的語言也在語言層面或是在庫函式中提供協程機制。

3. 實現使用者透明的協程

  在通過虛擬機器作為中間媒介,作業系統平臺無關的語言中(比如java),虛擬機器作為應用程式與作業系統核心的中間層,可以對應用程式進行各方面的優化,令程式設計師可以輕鬆編寫出高效的程式碼。

  有大牛在知乎的一篇回答中提到過,其曾經領導團隊在阿里巴巴工作時在java中實現了透明的協程。但似乎沒有和官方標準達成統一因此並沒有對外開放。

  如果能夠在虛擬機器中提供高效、使用者透明的協程機制,使得原本基於BIO多執行緒的伺服器程式無需改造便自動的獲得了支援海量併發的能力,那真是太強了Orz。

五、總結

  通過對ucore作業系統原始碼級的研究學習,加深了我對作業系統原理書中各種抽象概念的理解,也漸漸理解了一些關於各種I/O模型的問題。

  一方面,通過對作業系統I/O模型的總結,使得我對於上層應用程式如java中的nio和netty中的非阻塞的程式設計風格有了更深的理解,不再像之前只習慣於BIO程式設計那樣感到奇怪,而是覺得非常自然。另一方面,又意識到了自己還有太多的不足。

  站在作業系統I/O模型這一層面,向上看,依然對基於nio的各種中介軟體不太熟悉,不瞭解在具體實踐中如何利用好NIO這一利器,寫出魯棒、高效的程式碼;向下看,由於ucore為了儘可能的簡化實驗課的難度,省略了很多的功能沒有實現,導致我對於作業系統底層是如何實現網路協議棧、如何實現nio和io多路複用器的原理知之甚少,暫時只能將其當作黑盒子看待,很多地方可能理解的有偏差。令我在拓寬知識面的同時,感嘆知道的越多就越感覺自己無知,但人總是要向前走的,在學習中希望儘量能做到知其然而知其所以然。通過對ucore作業系統的學習,使得我對於作業系統核心的學習不再感到恐懼,在認知學習概念中就是從恐懼區轉為了學習區。以後有機會的話,可以通過研究早期的linux核心原始碼來解答我關於I/O模型底層實現的一系列問題。

  這篇部落格是這一段時間來對作業系統學習的一個階段性總結,直接或間接的回答了部落格開頭的幾個問題,希望能幫到對作業系統、I/O模型感興趣的人。這篇文章中還存在許多理解不到位的地方,請多多指教。

相關文章