动态

详情 返回 返回

【深入淺出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
0 用户, 点赞了这篇动态!

发布 评论

Some HTML is okay.