1. 程式人生 > 程式設計 >Node.js 非同步非阻塞 I/O 機制剖析

Node.js 非同步非阻塞 I/O 機制剖析

前言

近幾年,「非同步」與「非阻塞」這兩個概念在服務端應用開發中廣泛提及。很多時候大家都喜歡將其合在一起描述,導致許多人可能會混淆了對這兩個詞的理解。本文試著從 Linux I/O 的角度講解這兩者之間的恩怨情仇。

本文涉及以下內容:

  1. LinuxI/O 基礎知識;
  2. I/O 模型含義與現有的幾類:
    1. 阻塞 I/O
    2. 多執行緒阻塞 I/O;
    3. 非阻塞 I/O
    4. I/O多路複用select/poll/ epoll
    5. 非同步I/O
  3. libuv 中如何解決 I/O 的問題。

另外,本文所涉及的例子,已託管在 GITHUB,歡迎下載試執行。

Linux 的 I/O 基礎知識

從讀取檔案的例子開始

現在有個需求,通過 shell 指令碼實現檔案的讀取。我相信大部分人能夠馬上完成實現:

$ cat say-hello.txt複製程式碼

而現在我們要求用 C 來實現同樣的功能,以求揭露更多細節。

#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int main(int argc,char const *argv[])
{
    char buffer[1024];
    int fd = open("say-hello.txt",O_RDONLY,0666);
    int size;

    if (fd < 0) {
        perror("open"
); return 1; } while ((size = read(fd,buffer,sizeof(buffer))) > 0) { printf("%s\n",buffer); } if (size < 0) { perror("read"); } close(fd); return 0; }複製程式碼

呼叫 open 函式取得以一個數字,通過將其用於 writeread 操作,最後呼叫 close,這就是最基礎的 Linux I/O 操作 流程。

這邊常寫 JavaScript,而不熟悉 c 的人可能會有兩個問題。

  1. open 方法返回的數字是什麼?
  2. read 操作會從硬碟讀取資源,read 之後的程式碼需要等待,如何做到的(好像和 Node.js 裡面不太一樣)。

帶著疑問我們開始下面的知識。

檔案操作符

我們知道 Linux 有句 slogan 叫做 “一切皆檔案”。體現這個特點的很重要一點就是檔案描述符機制。

我們來總結下常見的 I/O 操作,包括:

  • TCP / UDP
  • 標準輸入輸出
  • 檔案讀寫
  • DNS
  • 管道(程式通訊)

Linux採用了檔案操作符機制來實現介面一致,包括:

  1. 引入檔案操作符file descriptor,以下簡稱 fd);
  2. 統一對 readwrite 方法進行讀寫操作。

上文例子提到 open 函式返回一個數字,就是檔案描述符,用於對應到當前程式內唯一的檔案。

Linux 在執行中,會在 程式控制塊(PCB) 使用一個固定大小的陣列,陣列每一項指向核心為每一個程式所維護的該程式開啟檔案的記錄表。

struct task_struct {
    ...
    /* Open file information: */
    struct files_struct		*files;
    ...
}
/*
 * Open file table structure
 */
struct files_struct {
	spinlock_t file_lock ____cacheline_aligned_in_smp;
	unsigned int next_fd;
	unsigned long close_on_exec_init[1];
	unsigned long open_fds_init[1];
	unsigned long full_fds_bits_init[1];
	struct file __rcu * fd_array[NR_OPEN_DEFAULT];
};複製程式碼

fd 其實是對應檔案結構在這個陣列中的序號,這也是 fd 會從 0 開始的原因 。

所以在 readwrite 操作時傳入 fdLinux自然能找到你需要操作的檔案。

如何感知 fd 的存在,可以使用 lsof 命令,比如使用以下命令列印 Chrome 瀏覽器當前開啟的 fd 情況(如果你有使用 Chrome 瀏覽器訪問當前頁面的話)。

$ lsof -p$(ps -ef |grep Chrome|awk 'NR==1{print $2}')複製程式碼

上面例子引入的第二個問題:

read 操作會從硬碟讀取資源,read 之後的程式碼需要等待,如何做到的(好像和 Node.js 裡面不太一樣)。

在 Linux 執行過程中,程式執行的主體是程式 or 執行緒(Linux 核心 2.4 之前是程式,之後排程的基礎單位變成了執行緒,程式成為執行緒的容器)。

程式在執行過程中會有基礎執行狀態是這樣子的

結合上面的例子,在 read 函式執行後,程式進入阻塞態,並在 I/O 結束後由系統中斷重新將程式解除阻塞態,進入就緒態,並等待時間片分配後,進入執行狀態。

也就是說我們的程式會在 I/O 操作發生時阻塞住,這就是上面問題的解釋。

I/O 模型

上面介紹的這個 I/O 機制 就是我們I/O 模型中的 阻塞 I/O 機制 。

阻塞 I/O

阻塞 I/Oread/write 函式的預設執行機制,會在讀寫操作執行時將程式置為阻塞態,I/O 完成後,由系統中斷將其置為就緒態,等待時間片分配,並執行。

但阻塞 I/O 的機制存在一個問題,就是無法併發地執行 I/O 操作,或者在 I/O 操作執行的同時執行 CPU 的計算。如果在 web 請求/響應場景下,如果一個請求讀取狀態發生阻塞,那麼其他請求則無法處理。

我們需要解決這個問題。

多執行緒阻塞 I/O

第一個思路是使用多執行緒。

我們預先初始化一個執行緒池,利用訊號量的 wait 原語進入阻塞狀態。等到有 I/O 操作需求時,通過訊號量signal將執行緒喚醒並執行相關的 I/O 操作。詳細的操作請看程式碼

但多執行緒非阻塞 I/O 有個弊端,就是當連線數達到很大的一個程度時,執行緒切換也是一筆不小的開銷。

所以,期望能夠在一個執行緒內解決 I/O 的等待操作,避免開啟多個執行緒而造成的執行緒上下文切換的開銷。有沒有這樣的方式呢,所以就可以引入非阻塞 I/O 的模式了。

非阻塞 I/O

非阻塞 I/O 是一種機制,允許使用者在呼叫 I/O 讀寫函式後,立即返回,如果緩衝區不可讀或不可寫,直接返回 -1。這裡有一個非阻塞 I/O 構建 web 伺服器的例子,可以看程式碼

關鍵性的函式是這段:

fcntl(fd,F_SETFL,O_NONBLOCK);複製程式碼

可以看到此處的程式 STATE 不再進入了阻塞狀態,I/O 操作執行的同時可以進行其他 CPU 運算

非阻塞 I/O 能夠幫我們解決在一個執行緒併發執行 I/O 操作的需求,可同樣會帶來問題:

  1. 如果 while 迴圈輪詢等待執行的操作,會造成不必要的 CPU 運算的浪費,因為此時 I/O 操作未完成,read 函式拿不到結果;
  2. 如果使用 sleep/usleep 的方式強行讓程式睡眠一段時間,又回造成 I/O 操作的返回不及時。

所以,系統有沒有一種機制來允許我們原生地等待多個 I/O 操作的執行呢。答案是有的,需要引入我們的 I/O 多路複用。

I/O 多路複用

I/O 多路複用,故名意思是在一個程式內同時執行多個 I/O 操作。本身也有一段進化的過程,分別是 select,poll,epoll(macos 上的替代品是 kqueue) 這幾個階段。我們依次來介紹。

select

select 使用一般包含三個函式(詳細介紹,戳這):

void FD_SET(int fd,fd_set *fdset);

int select(int nfds,fd_set *restrict readfds,fd_set *restrict writefds,fd_set *restrict errorfds,struct timeval *restrict timeout);

int FD_ISSET(int fd,fd_set *fdset);複製程式碼

select 作用是可以批量監聽 fd,當傳入的 fd_set 中任何一個 fd 的緩衝區進入可讀/可寫狀態時,解除阻塞,並通過 FD_ISSET 來迴圈定位到具體的 fd

fd_set tmpset = *fds;

struct timeval tv;
tv.tv_sec = 5;

int r = select(fd_size,&tmpset,NULL,&tv);

if (r < 0) do {
        perror("select()");
        break;
    } while(0);
else if (r) {
    printf("fd_size %d\n",r);
    QUEUE * q;
    QUEUE_FOREACH(q,&loop->wq->queue) {
        watcher_t * watcher = QUEUE_DATA(q,watcher_t,queue);
        int fd = watcher->fd;
        if (FD_ISSET(fd,&tmpset)) {
            int n = watcher->work(watcher);
        }
    }
}
else {
    printf("%d No data within five seconds.\n",r);
}複製程式碼

這裡有一個 select 構建 web 伺服器的例子,可以看看

select 函式雖然能解決 I/O 多路複用的問題,但同時還存在一些瑕疵:

  1. fd_set 結構允許傳入的最大 fd 數量是 1024,如果超過這個數字,可能依然需要使用多執行緒的方式來解決了;
  2. 效能開銷
    1. select 函式每次的執行,都存在 fd_set 從用態到核心態的拷貝;
    2. 核心需要輪詢 fd_setfd 的狀態;
    3. 返回值在使用者態中也需要進行輪詢確定哪些 fd 進入了可讀狀態;

poll

第一個問題由 poll 解決了,poll 函式接收的 fd 集合改成了陣列, 不再有 1024 大小的限制。poll 函式的定義如下:

int poll(struct pollfd *fds,nfds_t nfds,int timeout);複製程式碼

具體的用法和 select 非常像:

struct pollfd * fds = poll_init_fd_set(loop,count);

int r = poll(fds,count,5000);

if (r < 0) do {
        perror("select()");
        break;
    } while(0);
else if (r) {
    QUEUE * q;

    QUEUE_FOREACH(q,queue);
        int fd = watcher->fd;

        if (watcher->fd_idx == -1) {
            continue;
        }

        if ((fds + watcher->fd_idx)->revents & POLLIN) {
            watcher->work(watcher);
        }
    }
}
else {
    printf("%d No data within five seconds.\n",r);
}複製程式碼

這裡有一個 poll 構建 web 伺服器的例子,可以看看

但上述在 select 提到的效能開銷,問題仍然存在。而在 epoll 上,問題得到了解決。

epoll

詳細介紹可以看這裡。簡單來講,epollselect,poll 一步完成的操作分成了三步:

  1. epoll_create ,建立一個 epoll_fd,用於 3 階段監聽;
  2. epoll_ctl ,將你要監聽的 fd 繫結到 epoll_fd 之上;
  3. epoll_wait,傳入 epoll_fd,程式進入阻塞態,在監聽的任意 fd 發生變化後程序解除阻塞。

我們來看下 epoll 是如何解決上述提到的效能開銷的:

  1. fd 的繫結是在 epoll_ctl 階段完成,epoll_wait只需要傳入 epoll_fd,不需要重複傳入 fd 集合;
  2. epoll_ctl 傳入的 fd 會在核心態維護一顆紅黑樹,當由 I/O 操作完成時, 通過紅黑樹以 O(LogN) 的方式定位到 fd,避免輪詢;
  3. 返回到使用者態的 fd 陣列是真實進入可讀,可寫狀態的 fd 集合,不再需要使用者輪詢所有 fd。

如此看來,epoll 方案是多路複用方案的最佳方案了。這有個 epoll 構建 web 伺服器的例子,可以看下

epoll 就沒有缺陷嗎?答案是否定的:

  1. epoll 目前只支援 pipe,網路等操作產生的 fd,暫不支援檔案系統產生的 fd

非同步 I/O

上面介紹的,無論是阻塞 I/O 還是 非阻塞 I/O 還是 I/O 多路複用,都是同步 I/O。都需要使用者等待 I/O操作完成,並接收返回的內容。而作業系統本身也提供了非同步 I/O 的方案,對應到不同的作業系統:

  1. Linux
    1. aio,目前比較被詬病,比較大缺陷是隻支援 Direct I/O(檔案操作)
    2. io_uring, Linux Kernel 在 5.1 版本加入的新東西,被認為是 Linux 非同步 I/O 的新歸宿
  2. windows
    1. iocp,作為 libuv 在 windows 之上的非同步處理方案。(筆者對 windows 研究不多,不多做介紹了。)

至此,介紹了常見的幾種 I/O 模型。

而目前在 Linux 上比較推薦的方案還是 epoll 的機制。但 epoll 不支援監聽檔案 fd 的問題,還需要動點腦筋,我們來看看 libuv 怎麼解決的。

libuv 的解決方案

libuv 使用 epoll 來構建 event-loop 的主體,其中:

  1. socket,pipe 等能通過 epoll 方式監聽的 fd 型別,通過 epoll_wait 的方式進行監聽;
  2. 檔案處理 / DNS 解析 / 解壓、壓縮等操作,使用工作執行緒的進行處理,將請求和結果通過兩個佇列建立聯絡,由一個 pipe 與主執行緒進行通訊, epoll 監聽該 fd 的方式來確定讀取佇列的時機。

到這裡,本文要結束了。做個簡短的總結:

首先,我們介紹了檔案描述符的概念,緊接著介紹了 Linux 基礎程式狀態的切換。然後引入阻塞 I/O 的概念,以及缺陷,從而引入了多執行緒阻塞 I/O,非阻塞 I/O,以及I/O多路複用和非同步 I/O的概念。最後結合上面的知識,簡單介紹了 libuv 內部的 I/O 運轉機制。

最後,打個「小廣告」。位元組跳動誠邀優秀的前端工程師和 Node.js 工程師加入做有趣的事情,有意者歡迎傳送至 [email protected]

好了,我們下期再會。