Node.js 非同步非阻塞 I/O 機制剖析
前言
近幾年,「非同步」與「非阻塞」這兩個概念在服務端應用開發中廣泛提及。很多時候大家都喜歡將其合在一起描述,導致許多人可能會混淆了對這兩個詞的理解。本文試著從 Linux I/O
的角度講解這兩者之間的恩怨情仇。
本文涉及以下內容:
-
Linux
的I/O
基礎知識; -
I/O
模型含義與現有的幾類: - 阻塞
I/O
; - 多執行緒阻塞
I/O
; - 非阻塞
I/O
; -
I/O
多路複用:select
/poll
/epoll
; - 非同步
I/O
-
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
函式取得以一個數字,通過將其用於 write
與 read
操作,最後呼叫 close
,這就是最基礎的 Linux
I/O
操作 流程。
這邊常寫 JavaScript,而不熟悉 c 的人可能會有兩個問題。
- open 方法返回的數字是什麼?
- read 操作會從硬碟讀取資源,read 之後的程式碼需要等待,如何做到的(好像和 Node.js 裡面不太一樣)。
帶著疑問我們開始下面的知識。
檔案操作符
我們知道 Linux
有句 slogan
叫做 “一切皆檔案”。體現這個特點的很重要一點就是檔案描述符機制。
我們來總結下常見的 I/O 操作,包括:
-
TCP
/UDP
- 標準輸入輸出
- 檔案讀寫
DNS
- 管道(程式通訊)
Linux
採用了檔案操作符機制來實現介面一致,包括:
- 引入檔案操作符(
file descriptor
,以下簡稱fd
); - 統一對
read
和write
方法進行讀寫操作。
上文例子提到 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 開始的原因 。
所以在 read
或write
操作時傳入 fd
,Linux
自然能找到你需要操作的檔案。
如何感知 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/O
是 read
/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
操作的需求,可同樣會帶來問題:
- 如果
while
迴圈輪詢等待執行的操作,會造成不必要的CPU
運算的浪費,因為此時I/O
操作未完成,read
函式拿不到結果; - 如果使用
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
多路複用的問題,但同時還存在一些瑕疵:
- fd_set 結構允許傳入的最大 fd 數量是 1024,如果超過這個數字,可能依然需要使用多執行緒的方式來解決了;
- 效能開銷
-
select
函式每次的執行,都存在fd_set
從用態到核心態的拷貝; - 核心需要輪詢
fd_set
中fd
的狀態; - 返回值在使用者態中也需要進行輪詢確定哪些
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
詳細介紹可以看這裡。簡單來講,epoll
將 select
,poll
一步完成的操作分成了三步:
-
epoll_create ,建立一個
epoll_fd
,用於 3 階段監聽; -
epoll_ctl ,將你要監聽的 fd 繫結到
epoll_fd
之上; -
epoll_wait,傳入
epoll_fd
,程式進入阻塞態,在監聽的任意fd
發生變化後程序解除阻塞。
我們來看下 epoll
是如何解決上述提到的效能開銷的:
-
fd
的繫結是在epoll_ctl
階段完成,epoll_wait
只需要傳入epoll_fd
,不需要重複傳入fd
集合; -
epoll_ctl
傳入的fd
會在核心態維護一顆紅黑樹,當由I/O
操作完成時, 通過紅黑樹以 O(LogN) 的方式定位到fd
,避免輪詢; - 返回到使用者態的
fd
陣列是真實進入可讀,可寫狀態的fd
集合,不再需要使用者輪詢所有 fd。
如此看來,epoll
方案是多路複用方案的最佳方案了。這有個 epoll
構建 web 伺服器的例子,可以看下。
但 epoll
就沒有缺陷嗎?答案是否定的:
-
epoll
目前只支援pipe
,網路等操作產生的fd
,暫不支援檔案系統產生的fd
。
非同步 I/O
上面介紹的,無論是阻塞 I/O
還是 非阻塞 I/O
還是 I/O
多路複用,都是同步 I/O
。都需要使用者等待 I/O
操作完成,並接收返回的內容。而作業系統本身也提供了非同步 I/O
的方案,對應到不同的作業系統:
- Linux
- aio,目前比較被詬病,比較大缺陷是隻支援
Direct I/O
(檔案操作) - io_uring, Linux Kernel 在 5.1 版本加入的新東西,被認為是 Linux 非同步
I/O
的新歸宿 - windows
- iocp,作為 libuv 在 windows 之上的非同步處理方案。(筆者對
windows
研究不多,不多做介紹了。)
至此,介紹了常見的幾種 I/O
模型。
而目前在 Linux
上比較推薦的方案還是 epoll
的機制。但 epoll
不支援監聽檔案 fd
的問題,還需要動點腦筋,我們來看看 libuv
怎麼解決的。
libuv 的解決方案
libuv
使用 epoll
來構建 event-loop
的主體,其中:
-
socket
,pipe
等能通過epoll
方式監聽的fd
型別,通過epoll_wait
的方式進行監聽; - 檔案處理 / DNS 解析 / 解壓、壓縮等操作,使用工作執行緒的進行處理,將請求和結果通過兩個佇列建立聯絡,由一個
pipe
與主執行緒進行通訊,epoll
監聽該fd
的方式來確定讀取佇列的時機。
到這裡,本文要結束了。做個簡短的總結:
首先,我們介紹了檔案描述符的概念,緊接著介紹了 Linux
基礎程式狀態的切換。然後引入阻塞 I/O
的概念,以及缺陷,從而引入了多執行緒阻塞 I/O
,非阻塞 I/O
,以及I/O
多路複用和非同步 I/O
的概念。最後結合上面的知識,簡單介紹了 libuv 內部的 I/O
運轉機制。
最後,打個「小廣告」。位元組跳動誠邀優秀的前端工程師和 Node.js
工程師加入做有趣的事情,有意者歡迎傳送至 [email protected]。
好了,我們下期再會。