深入理解NodeJS Blocking/Non-blocking event並實現一個簡易的Event Loop
用過Node JS的朋友一定都聽過Event Loop, 幾年前Node JS剛出來的時候還曾經聽人說過Node JS速度不輸給像是Java之類的靜態語言(需要編譯的type safe的語言), 甚至超過他們, 全都歸功於Node JS是用Event Loop, event-driven的方式在處理客戶端請求, 那麼問題來了, 什麼是客戶端, Event loop又是什麼樣的魔術, 讓NodeJS可以用單線程(實際上不完全是)達到高效能。至於真的有廣告上說的那麼神, 速度那麼快, 先上數據(Web benchmark), 不完全精確, 因為這是在有框架的狀態下測試, 而非純語言比較, 但是可以發現NodeJS也許沒有大家說的那麼神, 那麼高效。
預先聲明
首先必須要先說, 這篇文章的code是由C/C++所寫的, 或許對於一般Web開發者不是很重要, 但畢竟NodeJS也是由C/C++所打造, 我會嘗試寫的簡單明白, 我主要工作也不是寫C語言所以也不是很懂, 但不懂的話就一起Google吧。
第一代Web server
web server是如何并行處理每個客戶端的請求呢? 早期的Apache server非常簡單, 直接fork子process去處理每個客戶端請求, 不需要鎖(mutex), 不需要複雜的邏輯, 就是直接暴力的fork, 用NodeJS的話來說, 就是 childprocess.fork()
對OS有一定概念的都知道, process相對thread消耗的資源是大得多, 因此現代多數的語言都是用thread來實現web server。
第二代Web server
第二代web server用的是thread, 每新增一個客戶端請求, 就開一個thread去處理他, 雖然thread相比process輕量的多, 但也不是沒有cost, 首先, thread有自己的stack, 每新增一個thread都必須消耗掉一些資源, 例如memory, 以Java來說, Java 8 (64-bit)的預設stack size是1MB, 也就是說如果有10,000個client同時請求, 10,000 * 1MB = 10GB(至少)的記憶體就會被吃掉, 再來, CPU必須花上非常大的loading在做context switch, 此外, 這類型的處理方式非常容易受到DoS attack, 只要開一堆客戶端發出請求, 即使什麼事都不幹, 只要不斷線,這些客戶端可以占用大量資源讓server癱瘓, 因此, 現在的web server通常都是用thread pool去來處理客戶端請求。
現代Web server — thread pool vs event-driven
Thread Pool就是, 預先創建一定數量的thread, 有請求來就先放到queue裡面, 然後由Pool裡面的worker thread來處理queue裡面的任務, 以Tomcat來說(可在conf/server.xml裡面設定), 即使沒有任何請求, 最小數量的worker thread也有25個(conf內的minSpareThreads), 最大worker thread數量也就200個(conf內的maxThreads), 也就是說, 即使同時有10,000個請求, 最多也只有200個線程可以同時處理客戶端請求, 其他請求只能在queue中等待worker把手上的請求處理完再去處理, 這種處理方式現在被大量程式語言採用, 例如Java。
然而, NodeJS使用了完全不同的方法: Event Loop, 單線程卻依然高效, 在理解Event Loop之前, 必須要先理解什麼是Blocking什麼是Non-Blocking Event。
Blocking event, 是最常見的, 例如以下這段Http請求
HttpURLConnection conn = url.openConnection();
// do something CPU-bounded works
int status = conn.getResponseCode();
openConnection()
就是一種blocking event, 也就是說, 在 openConnection()
HTTP請求完成之前, 程式碼都將卡在這裡, 直到對方返回Http Response以後才會繼續執行之後的程式碼, 因為這是IO-bounded的任務(非因為等待CPU處理, 而是因為如硬碟讀取, 網路延遲等), 中間CPU除了等待並沒有做任何事,;然而若是 non-blocking event, openConnection()
會立即返回, 即使它並沒有執行完畢, 在他之後的程式碼會繼續執行, 直到返回的結果被需要, 如 getResponseCode()
, 而在中間的時間CPU可以先做別的事情, 而不是讓CPU純粹的等待而不做任何事而浪費資源。
實驗開始
下面是Linux環境下的TCP socket programming, socket()
建立一個TCP IPv4的socket, 之後 bind()
把指定的地址、Port綁定上file descriptor, 之後 accept()
表示接受客戶端的連線, recv()
則接受客戶端的訊息, 這邊 recv()
是blocking-event, 用TCP客戶端程式嘗試連接, 如網路工具nc
echo 'Hello world' | nc localhost 3000
// 輸出為
Connect to a client 3000
[1622419502] Hello world
在沒有繼續送下一個訊息給Server前, 程式會一直卡在第22行 recv()
直到有新的訊息送進來
然而, 如果把 17行的 accept()
改成 accept4()
, 這只是多一個 flag
參數可以給 SOCK_NONBLOCK
表示這是一個non-blocking的socket
accept4(socketFd, (sockaddr *) &addr, &addrLen, SOCK_NONBLOCK);
輸出為
Connected to a client 3000
[1622420142] Hello worldWaiting for message…
Waiting for message…
Waiting for message…
可以看到22行的 recv()
會立即返回結果, 沒有任何訊息傳來時直接 len
(以接收的byte數)會為-1
, 表示錯誤, 錯誤碼為 EAGAIN
表示temporary unavailable, 也就是還沒有東西可以接收。
如果我們把32行的 sleep()
換成一些其他不受影響的工作, 每次只做一秒鐘, 就是Event Loop的基本原理了, 也就是說, CPU每秒去詢問(Polling)有沒有收到訊息, 如果沒有, 等待的這一秒可以拿來做別的事, 而非浪費資源卡在 recv()
等待訊息。
然而, 以上的程式碼只能接收一個客戶端請求, 沒辦法真正的多工, 如果要模擬實際情況下多客戶端同時請求, 該怎麼做呢?
最直接的方法是, for迴圈accept來自客戶端的所有請求, 然後for迴圈查看每個file descriptor有沒有東西可以讀取 recv(fd)
, 如果沒有就會返回 -1, errno = EAGAIN
。這樣做最直接的問題在於, 如果一台server同時有10K個客戶端同時在線, 等於一次需要輪詢10K個file descriptor, 這樣做非常的低效, Linux提供了另一個system call epoll
, OS會幫你監測每個file descriptor, 當他們有資料進來的時候通知你, epoll
的時間複雜度是常數時間 O(1)
, 因此相對高效得多, 也是目前多數library的實作方法。
// create epoll file descriptor
epollFd = epoll_create1(flag);// control epoll file descriptor
// operation:
// EPOLL_CTL_ADD: add a file descriptor to epoll watch list
// EPOLL_CTL_MOD: modify a file descriptor's interesting event
// EPOLL_CTL_DEL: remove a file descriptor to epoll watch list
epoll_ctl(epollFd, operation, fd, *epollEvent);// notify changes if file descriptor changed
epoll_wait(epollFd, *epollEvents, maxEventNum, timeout);
基本上就是, 可以利用 epoll_ctl()
把有興趣的file descriptor跟相關的event (例如 EPOLLIN
表示有東西可以讀取)放進去watch list, epoll會幫我們監聽所有我們有興趣的file descriptor, 若有我們感興趣的event發生時, epoll_wait()
會通知我們, 我們只需輪詢這個函數即可。
我們只需要一開始監聽server的socket, 一旦這個socket有 EPOLLIN
事件發生表示有客戶端連近來(39行), 這時我們只需要 accept()
完成連線, 並把這個新的連線產生的file descriptor放進watch list裡面(49行), 等到客戶端有新資料傳進來, 我們就會被通知, 這便是一個簡單的Event Loop的實作原理, 所謂的callback其實就是在被監聽的file descriptor產生事件時, 我們被通知以後所呼叫的函數, 實務上的event loop也多半是用以下的方式實作。
那麼問題來了, 如果說今天我們需要做一些需要大量CPU資源的運算會如何呢? 如果直接把這樣的程式碼放進Event Loop, 那麼整個Event Loop都會被卡住, 然而Event Loop是一個單線程的程式, 這時候怎麼辦呢?
實際上解決方法是: Thread Pool, 沒錯, 你以為的Node JS是單線程, 實際上並不完全是, 所謂單線程是指主要的Event Loop; 但當需要做一些複雜的運算時, 必須要利用額外的線程去做運算, 才不會讓整個程式卡住。如果有寫過一些客戶端程式, 例如Android App, .NET桌面程式等, 都一定會聽到大家說, 如果要做一些需要長時間運行的程式, 一定不能在主線程運行, 就是同樣的道理, 如果在主線程做運算, 使用者對UI做的任何操作都會卡住, 也就造成卡頓甚至被使用者認為是死當而關掉重開了。
我們來看看NodeJS的底層, libuv, 一個Event Loop的library, 這邊可以擷取一些片段
int uv__platform_loop_init(uv_loop_t* loop) {
int fd = epoll_create1(O_CLOEXEC);
/* epoll_create1() can fail either because
* it's not implemented (old kernel)
* or because it doesn't understand the O_CLOEXEC flag.
*/
if (fd == -1 && (errno == ENOSYS || errno == EINVAL)) {
fd = epoll_create(256);
if (fd != -1) uv__cloexec(fd, 1);
}
loop->backend_fd = fd;
loop->inotify_fd = -1;
loop->inotify_watchers = NULL;
if (fd == -1) return UV__ERR(errno);
return 0;
}
以及這裡, while loop所構成的Event Loop架構
int uv_run(uv_loop_t* loop, uv_run_mode mode) {
int timeout;
int r;
int ran_pending;
r = uv__loop_alive(loop);
if (!r) uv__update_time(loop);
while (r != 0 && loop->stop_flag == 0) {
uv__update_time(loop);
uv__run_timers(loop);
ran_pending = uv__run_pending(loop);
uv__run_idle(loop);
uv__run_prepare(loop);
...
}
}
此外, 這邊你可以看到threadPool的實作, 也就證明了NodeJS並非完全的單線程。
結語
最後分享一個非常高效且多數Web開發者都用過的快取資料庫Redis, Redis也是使用Event Loop, 他沒有複雜的鎖(lock)機制, 因此可以高效能的對資料進行改動, 相比之下一般資料庫MySQL, Oracle, PostgreSQL都是使用相對複雜的併行機制以及複雜的鎖來保證Race condition不發生, 然而Redis因為是單線程因此可以輕鬆做到這點(當然也不完全是, 例如背景線程可以把資料寫到硬碟做備份), 有興趣的話可以參考 ae.c
, aeCreateEventLoop()
函數創建Event Loop, ae_epoll.c
這裡可以看到跟上面近乎一樣的 epoll
相關的操作。