動態

詳情 返回 返回

【深入淺出Nodejs】異步非阻塞IO - 動態 詳情

概覽:本文介紹了阻塞I/O、非阻塞I/O、多路複用I/O和異步I/O 四種模型,在實際的操作系統和計算機中I/O本質總是阻塞的,通過返回fd狀態和輪詢的方式來使I/O在應用層不阻塞,然後通過多路複用的方式更高效實現這種不阻塞的效果。然後介紹了Node中異步I/O的實現,由於計算機本身的設計使得並不存在真正異步I/O,需要通過線程池來模擬出異步I/O。

I/O模式

I/O模式介紹

1.文件描述符

類unix操作系統將I/O抽象為文件描述符(file description,下面簡稱fd),可讀/可寫流都可以看做讀一個“文件”,打開文件和創建Socket等都是獲取到一個fd文件描述符。

2.操作I/O時發生了什麼

操作流就是讀和寫(read/write),下面用read進行説明。read時需要CPU進入內核態等待操作系統處理數據,等操作系統完成後會響應結果。用户態切換到內核態僅僅是CPU執行模式切換,線程本身並未改變,CPU進入內核態才能進行外部設備(外設)的準備工作,從而支持後續數據複製到內核緩衝區,完成後再切換回用户態,然後真正的讀數據到用户程序。

3.五種I/O模式
image.png

如圖,操作系統有5種I/O模式。

  • blocking
  • nonblocking
  • multiplexing
  • signal-driven (很少使用,不介紹)
  • async I/O
    可以的話,不妨看完下面詳細介紹後再回過頭看這張圖,對5種模式進行對比,相信你認識一定會更加深刻。

阻塞I/O (blocking)

image.png

  1. 當用户態調用read API讀流時,操作系統陷入內核態開始準備數據。
  2. 此時read是阻塞的。CPU是會切換到其他線程,做其他事的。原因就是現代計算機(採用了DMA技術)對於這種磁盤讀取工作中的數據傳輸部分CPU是不參與的,交給了DMA控制器負責,等處理好了DMA會發出一個CPU中斷,通知CPU切換回原來的線程繼續處理。
  3. 所以線程一定是阻塞的,當前線程的執行權讓出去了,也就是説沒有CPU時間片繼續執行當前線程。
  4. 內核態數據準備完成,原來的Thread被喚醒,繼續執行,表現為API讀流返回了數據。

P.S. DMA是通知操作系統,喚醒原來Thread,繼續執行。並不是通知Thread的具體某段程序執行,而是之前被阻塞時執行到哪,現在就繼續執行哪裏。

非阻塞I/O (non-blocking)

為甚麼還要有非阻塞I/O?
顯然,阻塞I/O會導致後面的代碼不能繼續執行,在要處理多個I/O的情況下就是串行發起I/O操作了。而非阻塞I/O就是希望發起I/O操作是併發的(不用等上一個流操作結束才發起下一個)。

非阻塞I/O: 調用read去讀fd的數據時,立即返回fd的狀態結果,不阻塞後面代碼的執行。此時操作系統就需要考慮如何實現這種非阻塞,如管理多個I/O流。

/*偽代碼*/
fd = openStream(); //打開文件,創建Socket等都能獲得一個fd,不阻塞
n = read(fd); //讀取這個fd的數據,不阻塞

image.png

  1. 當用户態調用read API讀流時,操作系統陷入內核態檢查數據是否就緒。
  2. 此時read是不阻塞的,可以繼續執行後面的代碼。但是後續需要不斷「check」(就是read)來檢查數據是否就緒。
  3. DMA通知喚醒Thread(如果Thread一直都是激活狀態,不存在被喚醒這一動作)。「check」發現有fd的數據就緒,就進行數據處理。

非阻塞I/O 是指==read讀數據能立即返回fd狀態,而不用等待,但是需要你主動去read==。如下圖所示(圖來自《深入淺出Nodejs》):
image.png

C++偽代碼實現

// 文件描述符集合
std::vector<int> fds = {fd1, fd2, fd3}; // 假設有3個需要監控的文件描述符

// 設置為非阻塞模式
for(auto& fd : fds) {
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

// 輪詢循環
while(true) {
    bool all_done = true;
    
    // 應用層輪詢每個文件描述符
    for(auto fd : fds) {
        char buffer[1024];
        ssize_t n = read(fd, buffer, sizeof(buffer)); // 非阻塞調用
        
        if(n > 0) {
            // 成功讀取到數據
            process_data(buffer, n);
        } 
        else if(n == 0) {
            // 連接關閉
            remove_fd(fd);
        }
        else if(n < 0) {
            if(errno == EAGAIN || errno == EWOULDBLOCK) {
                // 數據未就緒,立即返回 - 繼續輪詢其他fd
                continue;
            } else {
                // 真實錯誤
                handle_error(fd);
            }
        }
        
        // 檢查是否還有需要處理的數據
        if(has_pending_operations()) {
            all_done = false;
        }
    }
    
    // 可選的短暫休眠避免CPU佔用過高
    if(all_done) {
        usleep(1000); // 1s休眠
    }
    
    // 退出條件
    if(should_exit) break;
}

此時,還需要我們手動一個個檢查fd的狀態。下面就介紹I/O多路複用,它做到了批量監聽多個fd狀態,不用我們手動去管理監聽每一個fd了。

I/O多路複用(multiplexing)

類unix操作系統下,多路複用的方式有 select, poll, epoll(macos/freeBSD 上的替代品是 kqueue)。而在windows下面則直接使用IOCP(基於線程池的異步I/O方式),下面會介紹。
select、poll分別早在1983年、1986年就出現了,而epoll知道Linux2.6(大約2003)年才出現。
現代系統都是非阻塞I/O大都採用epoll或者IOCP的方式作為主流I/O併發方案了。

select

通過select()系統調用來監視多個fd的數組,返回一個int值(表示了fd就緒的個數),當調用select會阻塞,直到有一個fd就緒。

int select(int maxfdp, fd_set *readset, fd_set *writeset, fd_set *exceptset,struct timeval *timeout);
//maxfdp:被監聽的文件描述符的總數; 
//readset:讀fd集合
//writeset:寫fd集合
//exceptset
//timeout:用於設置select函數的超時時間,即告訴內核select等待多長時間之後就放棄等待。
//返回值:超時返回0;失敗返回-1;成功返回大於0的整數,這個整數表示就緒描述符的數目。

下圖展示了select方式(圖來自《深入淺出Nodejs》):

image.png

具體過程大致如下:
  1、調用select()方法,上下文切換轉換為內核態
  2、將fd從用户空間複製到內核空間
  3、內核遍歷所有fd,查看其對應事件是否發生
  4、如果沒發生,將進程阻塞,當設備驅動產生中斷或者timeout時間後,將進程喚醒,再次進行遍歷
  5、返回遍歷後的fd
  6、將fd從內核空間複製到用户空間
poll

poll是對select差不多,當調用poll會阻塞。但進行了一定改進:使用鏈表維護fd集合(select內是使用數組),這樣沒有了maxfdp的限制。

int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// fds:polld結構體集合,每個結構體描述了fd及其事件
// nfs:指定 `fds`數組中的元素個數,類型 `nfds_t`通常為無符
// timeout:等待時間,`-1`表示阻塞等待直到有事件發生;`0`表示立即返回(非阻塞);大於 `0`則表示最長等待時間
// 返回值:超時返回0;失敗返回-1;成功返回大於0的整數,這個整數表示就緒描述符的數目。
struct pollfd {
    int   fd;         /* 文件描述符 */
    short events;     /* 需要監視的事件(輸入) */
    short revents;    /* 實際發生的事件(輸出) */
};

下圖展示了poll方式(圖來自《深入淺出Nodejs》):

image.png

poll方式偽代碼

// 主循環
while (1) {
    int ret = poll(fds, nfds, 3000); // 等待 3 秒
    if (ret < 0) {
        perror("poll error");
        break;
    } else if (ret == 0) {
        printf("[poll] 超時,沒有事件\n");
        continue;
    }

    // 遍歷所有 fd,檢查哪些 revents 有標誌
    for (int i = 0; i < nfds; i++) {
        if (fds[i].revents & POLLIN) {
            char buf[1024];
            ssize_t n = read(fds[i].fd, buf, sizeof(buf) - 1);
            if (n > 0) {
                buf[n] = '\0';
                process_data(buf, n, fds[i].fd);
            } else if (n == 0) {
                // EOF,連接關閉
                remove_fd(fds, &nfds, i);
                i--; // 數組被壓縮,重新檢查當前位置
            } else if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK) {
                perror("read error");
                remove_fd(fds, &nfds, i);
                i--;
            }
        }
    }

    if (nfds == 0) {
        printf("所有 fd 都關閉了,退出。\n");
        break;
    }
}

pollselect的區別不大,都是要遍歷fd看是否有就緒。最大的區別在於poll沒有監視的fd集合大小限制(因為採用的鏈表),而select有大小限制(因為內部採用的數組存儲,可以通過參數maxfdp修改,默認1024)。

epoll
epoll_create創建一個 epoll 實例,同時返回一個引用該實例的文件描述符
int epoll_create(int size);
epoll_ctl 會將文件描述符 fd 添加到 epoll 實例的監聽列表裏,同時為 fd 設置一個回調函數,並監聽事件 event,如果紅黑樹中已經存在立刻返回。當 fd 上發生相應事件時,會調用回調函數,將 fd 添加到 epoll 實例的就緒隊列上。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// epfd 即 epoll_create 返回的文件描述符,指向一個 epoll 實例
// 表示要監聽的目標文件描述符
// op 表示要對 fd 執行的操作, 例如為 fd 添加一個監聽事件 event
// event 表示要監聽的事件
// 返回值 0 或 -1,表示上述操作成功與否。

epoll 模型的主要函數epoll_wait,功能相當於 select。調用該函數時阻塞,等待事件通知喚醒進程。

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
// epfd 即 epoll_create 返回的文件描述符,指向一個 epoll 實例
// events 是一個數組,保存就緒狀態的文件描述符,其空間由調用者負責申請
// maxevents 指定 events 的大小
// timeout 類似於 select 中的 timeout。如果沒有文件描述符就緒,即就緒隊列為空,則 epoll_wait 會阻塞 timeout 毫秒。如果 timeout 設為 -1,則 epoll_wait 會一直阻塞,直到有文件描述符就緒;如果 timeout 設為 0,則 epoll_wait 會立即返回
// 返回值表示 events 中存儲的就緒描述符個數,最大不超過 maxevents。

下圖展示了epoll方式(圖來自《深入淺出Nodejs》):

image.png

epoll方式偽代碼

int epfd = epoll_create(1024);
struct epoll_event ev, events[MAX_CONN];
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

while (1) {
    int n = epoll_wait(epfd, events, MAX_CONN, -1);
    for (int i = 0; i < n; i++) { 
        if (events[i].events & EPOLLIN) {
            // 處理可讀事件
        }
    }
}

select和poll存在的缺點:

  • 內核線程需要遍歷一遍fd集合,返回給用户空間後需要應用層再遍歷一遍fd數組。
  • 每次select/poll都會內核空間到用户空間拷貝fd集合。
  • 性能開銷隨fd線性增加,時間複雜度O(n)

epoll主要改進點:

  • 通過epoll_ctl提前給fd設置一個事件回調函數,fd上有事件觸發了就執行回調函數,把fd放到一個就緒隊列上,這樣在內核線程是不存在遍歷fd集合的,時間複雜度O(1)。
  • epoll_wait不會對fd集合在內核空間和用户空間拷貝, 而是“利用mmap()文件映射內存加速與內核空間的消息傳遞,減少拷貝開銷。”

到這裏,我們可以試着總結non-blocking和多路複用區別和聯繫。
區別:

  • non-blocking I/O:靠不斷“主動輪詢”實現不阻塞
  • I/O 多路複用:靠“事件通知 + 輪詢”實現更高效的不阻塞
    個人理解,廣義的來説,多路複用本身也是一種非阻塞I/O。

異步I/O

儘管epoll已經利用了事件來降低CPU的耗用,但是休眠期間CPU幾乎是閒置的,對於當前線程而言利用率不夠,那麼是否有一種理想的異步I/O呢?

下圖展示了理想的異步I/O(圖來自《深入淺出Nodejs》):

image.png

真正的異步I/O是在操作流時(發起異步操作)即不阻塞後續的代碼執行,又不需要自己去主動輪詢(read),只需要內核通知應用層執行回調(並且數據從內核空間讀取到用户空間也是不阻塞的)。很遺憾,這種異步I/O幾乎不存在(之所以説幾乎,是因為Linux原生提供了一種這樣的異步I/O——AIO,但存在缺陷)。

現實中的異步I/O,基本上都是通過線程池的方式來實現的,windows的IOCP也是內核級別實現了線程池。

==在Node單線程中,通過讓其他部分線程進行「阻塞I/O」或者「非阻塞I/O+輪詢技術」來完成數據獲取,等數據獲取完成後通知主線程執行回調。此時主線程是不會讓出CPU執行權的,可以一直繼續執行其他代碼。這樣就實現了異步I/O。==

下圖展示了線程池模擬的異步I/O(圖來自《深入淺出Nodejs》):

image.png

由於Windows*nix的平台差異,Node提供了libuv作為抽象封裝層來對不同平台做兼容性判斷。
下圖展示了Node的libuv架構(圖來自《深入淺出Nodejs》):
image.png

Node的事件循環

請求對象 :一個異步I/O的發起,libuv會產生一個封裝好的請求對象。比如fs.open會產生一個FSReqWrap的對象。

觀察者: 可以理解成觀察者模式中的觀察者,它主要是觀察判斷事件隊列中是否有事件了,當有事件了就需要去處理這個事件。

這裏我用一張流程圖説明發起異步I/O是如何被線程池執行,然後通過事件通知主線程的流程。

image.png

當異步任務執行的結果放入了事件隊列,此時觀察者會在主線程同步任務執行完後,查看事件隊列中是否有事件任務,有則取出執行。等這個任務(同步代碼)執行完後接着取下一個任務執行,一直循環,這就是Node的事件循環

P.S.這裏的事件隊列是一個籠統的隊列概念,可以理解成包括宏任務隊列和微任務隊列。

總結

本文介紹了阻塞I/O、非阻塞I/O、多路複用I/O和異步I/O 四種模型,在實際的操作系統和計算機中I/O本質總是阻塞的,通過返回fd狀態和輪詢的方式來使I/O在應用層不阻塞,然後通過多路複用的方式更高效實現這種不阻塞的效果。然後介紹了Node中異步I/O的實現,由於計算機本身的設計使得並不存在真正異步I/O,需要通過線程池來模擬出異步I/O。

在多路複用中,結合C++偽代碼和圖示的方式展示了select/poll/epoll的原理和差異,Linux中通常使用epoll(mac中有類似的kqueue)來實現非阻塞I/O,具備不用遍歷fd集合和反覆拷貝fd集合的性能優點。

最後,介紹了基於線程池的異步非阻塞I/O的實現原理,再結合事件隊列和觀察者實現了Node事件循環。

參考資料

Select、Poll、Epoll、 異步IO 介紹
【操作系統】I/O 多路複用,select / poll / epoll 詳解
深入淺出Nodejs

user avatar smalike 頭像 nihaojob 頭像 qingzhan 頭像 dirackeeko 頭像 huajianketang 頭像 xiaoxxuejishu 頭像 zxl20070701 頭像 yuzhihui 頭像 evenboy 頭像 munergs 頭像 licin 頭像 haixiudezhusun 頭像
點贊 82 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.