本文首發在github,感興趣請點選此處
nodejs是單執行緒執行的,同時它又是基於事件驅動的非阻塞IO程式設計模型。這就使得我們不用等待非同步操作結果返回,就可以繼續往下執行程式碼。當非同步事件觸發之後,就會通知主執行緒,主執行緒執行相應事件的回撥。
以上是眾所周知的內容。今天我們從原始碼入手,分析一下nodejs的事件迴圈機制。
nodejs架構
首先,我們先看下nodejs架構,下圖所示:
如上圖所示,nodejs自上而下分為- 使用者程式碼 ( js 程式碼 )
使用者程式碼即我們編寫的應用程式程式碼、npm包、nodejs內建的js模組等,我們日常工作中的大部分時間都是編寫這個層面的程式碼。
- binding程式碼或者三方外掛(js 或 C/C++ 程式碼)
膠水程式碼,能夠讓js呼叫C/C++的程式碼。可以將其理解為一個橋,橋這頭是js,橋那頭是C/C++,通過這個橋可以讓js呼叫C/C++。
在nodejs裡,膠水程式碼的主要作用是把nodejs底層實現的C/C++庫暴露給js環境。
三方外掛是我們自己實現的C/C++庫,同時需要我們自己實現膠水程式碼,將js和C/C++進行橋接。
- 底層庫
nodejs的依賴庫,包括大名鼎鼎的V8、libuv。
V8: 我們都知道,是google開發的一套高效javascript執行時,nodejs能夠高效執行 js 程式碼的很大原因主要在它。
libuv:是用C語言實現的一套非同步功能庫,nodejs高效的非同步程式設計模型很大程度上歸功於libuv的實現,而libuv則是我們今天重點要分析的。
還有一些其他的依賴庫
http-parser:負責解析http響應
openssl:加解密
c-ares:dns解析
npm:nodejs包管理器
...
關於nodejs不再過多介紹,大家可以自行查閱學習,接下來我們重點要分析的就是libuv。
libuv 架構
我們知道,nodejs實現非同步機制的核心便是libuv,libuv承擔著nodejs與檔案、網路等非同步任務的溝通橋樑,下面這張圖讓我們對libuv有個大概的印象:
這是libuv官網的一張圖,很明顯,nodejs的網路I/O、檔案I/O、DNS操作、還有一些使用者程式碼都是在 libuv 工作的。 既然談到了非同步,那麼我們首先歸納下nodejs裡的非同步事件:
- 非I/O:
- 定時器(setTimeout,setInterval)
- microtask(promise)
- process.nextTick
- setImmediate
- DNS.lookup
- I/O:
- 網路I/O
- 檔案I/O
- 一些DNS操作
- ...
網路I/O
對於網路I/O,各個平臺的實現機制不一樣,linux 是 epoll 模型,類 unix 是 kquene 、windows 下是高效的 IOCP 完成埠、SunOs 是 event ports,libuv 對這幾種網路I/O模型進行了封裝。
檔案I/O、非同步DNS操作
libuv內部還維護著一個預設4個執行緒的執行緒池,這些執行緒負責執行檔案I/O操作、DNS操作、使用者非同步程式碼。當 js 層傳遞給 libuv 一個操作任務時,libuv 會把這個任務加到佇列中。之後分兩種情況:
- 1、執行緒池中的執行緒都被佔用的時候,佇列中任務就要進行排隊等待空閒執行緒。
- 2、執行緒池中有可用執行緒時,從佇列中取出這個任務執行,執行完畢後,執行緒歸還到執行緒池,等待下個任務。同時以事件的方式通知event-loop,event-loop接收到事件執行該事件註冊的回撥函式。
當然,如果覺得4個執行緒不夠用,可以在nodejs啟動時,設定環境變數UV_THREADPOOL_SIZE來調整,出於系統效能考慮,libuv 規定可設定執行緒數不能超過128個。
nodejs原始碼
先簡要介紹下nodejs的啟動過程:
- 1、呼叫platformInit方法 ,初始化 nodejs 的執行環境。
- 2、呼叫 performance_node_start 方法,對 nodejs 進行效能統計。
- 3、openssl設定的判斷。
- 4、呼叫v8_platform.Initialize,初始化 libuv 執行緒池。
- 5、呼叫 V8::Initialize,初始化 V8 環境。
- 6、建立一個nodejs執行例項。
- 7、啟動上一步建立好的例項。
- 8、開始執行js檔案,同步程式碼執行完畢後,進入事件迴圈。
- 9、在沒有任何可監聽的事件時,銷燬 nodejs 例項,程式執行完畢。
以上就是 nodejs 執行一個js檔案的全過程。接下來著重介紹第八個步驟,事件迴圈。
我們看幾處關鍵原始碼:
- 1、core.c,事件迴圈執行的核心檔案。
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);
//執行 timers 佇列
uv__run_timers(loop);
//執行由於上個迴圈未執行完,並被延遲到這個迴圈的I/O 回撥。
ran_pending = uv__run_pending(loop);
//內部呼叫,使用者不care,忽略
uv__run_idle(loop);
//內部呼叫,使用者不care,忽略
uv__run_prepare(loop);
timeout = 0;
if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
//計算距離下一個timer到來的時間差。
timeout = uv_backend_timeout(loop);
//進入 輪詢 階段,該階段輪詢I/O事件,有則執行,無則阻塞,直到超出timeout的時間。
uv__io_poll(loop, timeout);
//進入check階段,主要執行 setImmediate 回撥。
uv__run_check(loop);
//進行close階段,主要執行 **關閉** 事件
uv__run_closing_handles(loop);
if (mode == UV_RUN_ONCE) {
//更新當前時間戳
uv__update_time(loop);
//再次執行timers回撥。
uv__run_timers(loop);
}
//判斷當前事件迴圈是否存活。
r = uv__loop_alive(loop);
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
break;
}
/* The if statement lets gcc compile it to a conditional store. Avoids
* dirtying a cache line.
*/
if (loop->stop_flag != 0)
loop->stop_flag = 0;
return r;
}
複製程式碼
- 2、timers 階段,原始碼檔案:timers.c。
void uv__run_timers(uv_loop_t* loop) {
struct heap_node* heap_node;
uv_timer_t* handle;
for (;;) {
//取出定時器堆中超時時間最近的定時器控制程式碼
heap_node = heap_min((struct heap*) &loop->timer_heap);
if (heap_node == NULL)
break;
handle = container_of(heap_node, uv_timer_t, heap_node);
// 判斷最近的一個定時器控制程式碼的超時時間是否大於當前時間,如果大於當前時間,說明還未超時,跳出迴圈。
if (handle->timeout > loop->time)
break;
// 停止最近的定時器控制程式碼
uv_timer_stop(handle);
// 判斷定時器控制程式碼型別是否是repeat型別,如果是,重新建立一個定時器控制程式碼。
uv_timer_again(handle);
//執行定時器控制程式碼繫結的回撥函式
handle->timer_cb(handle);
}
}
複製程式碼
- 3、 輪詢階段 原始碼,原始碼檔案:kquene.c
void uv__io_poll(uv_loop_t* loop, int timeout) {
/*一連串的變數初始化*/
//判斷是否有事件發生
if (loop->nfds == 0) {
//判斷觀察者佇列是否為空,如果為空,則返回
assert(QUEUE_EMPTY(&loop->watcher_queue));
return;
}
nevents = 0;
// 觀察者佇列不為空
while (!QUEUE_EMPTY(&loop->watcher_queue)) {
/*
取出佇列頭的觀察者物件
取出觀察者物件感興趣的事件並監聽。
*/
....省略一些程式碼
w->events = w->pevents;
}
assert(timeout >= -1);
//如果有超時時間,將當前時間賦給base變數
base = loop->time;
// 本輪執行監聽事件的最大數量
count = 48; /* Benchmarks suggest this gives the best throughput. */
//進入監聽迴圈
for (;; nevents = 0) {
// 有超時時間的話,初始化spec
if (timeout != -1) {
spec.tv_sec = timeout / 1000;
spec.tv_nsec = (timeout % 1000) * 1000000;
}
if (pset != NULL)
pthread_sigmask(SIG_BLOCK, pset, NULL);
// 監聽核心事件,當有事件到來時,即返回事件的數量。
// timeout 為監聽的超時時間,超時時間一到即返回。
// 我們知道,timeout是傳進來得下一個timers到來的時間差,所以,在timeout時間內,event-loop會一直阻塞在此處,直到超時時間到來或者有核心事件觸發。
nfds = kevent(loop->backend_fd,
events,
nevents,
events,
ARRAY_SIZE(events),
timeout == -1 ? NULL : &spec);
if (pset != NULL)
pthread_sigmask(SIG_UNBLOCK, pset, NULL);
/* Update loop->time unconditionally. It's tempting to skip the update when
* timeout == 0 (i.e. non-blocking poll) but there is no guarantee that the
* operating system didn't reschedule our process while in the syscall.
*/
SAVE_ERRNO(uv__update_time(loop));
//如果核心沒有監聽到可用事件,且本次監聽有超時時間,則返回。
if (nfds == 0) {
assert(timeout != -1);
return;
}
if (nfds == -1) {
if (errno != EINTR)
abort();
if (timeout == 0)
return;
if (timeout == -1)
continue;
/* Interrupted by a signal. Update timeout and poll again. */
goto update_timeout;
}
。。。
//判斷事件迴圈的觀察者佇列是否為空
assert(loop->watchers != NULL);
loop->watchers[loop->nwatchers] = (void*) events;
loop->watchers[loop->nwatchers + 1] = (void*) (uintptr_t) nfds;
// 迴圈處理核心返回的事件,執行事件繫結的回撥函式
for (i = 0; i < nfds; i++) {
。。。。
}
}
複製程式碼
uv__io_poll階段原始碼最長,邏輯最為複雜,可以做個概括,如下: 當js層程式碼註冊的事件回撥都沒有返回的時候,事件迴圈會阻塞在poll階段。看到這裡,你可能會想了,會永遠阻塞在此處嗎?
1、首先呢,在poll階段執行的時候,會傳入一個timeout超時時間,該超時時間就是poll階段的最大阻塞時間。
2、其次呢,在poll階段,timeout時間未到的時候,如果有事件返回,就執行該事件註冊的回撥函式。timeout超時時間到了,則退出poll階段,執行下一個階段。
所以,我們不用擔心事件迴圈會永遠阻塞在poll階段。
以上就是事件迴圈的兩個核心階段。限於篇幅,timers階段的其他原始碼和setImmediate、process.nextTick的涉及到的原始碼就不羅列了,感興趣的童鞋可以看下原始碼。
最後,總結出事件迴圈的原理如下,以上你可以不care,記住下面的總結就好了。
事件迴圈原理
- node 的初始化
- 初始化 node 環境。
- 執行輸入程式碼。
- 執行 process.nextTick 回撥。
- 執行 microtasks。
- 進入 event-loop
- 進入 timers 階段
- 檢查 timer 佇列是否有到期的 timer 回撥,如果有,將到期的 timer 回撥按照 timerId 升序執行。
- 檢查是否有 process.nextTick 任務,如果有,全部執行。
- 檢查是否有microtask,如果有,全部執行。
- 退出該階段。
- 進入IO callbacks階段。
- 檢查是否有 pending 的 I/O 回撥。如果有,執行回撥。如果沒有,退出該階段。
- 檢查是否有 process.nextTick 任務,如果有,全部執行。
- 檢查是否有microtask,如果有,全部執行。
- 退出該階段。
- 進入 idle,prepare 階段:
- 這兩個階段與我們程式設計關係不大,暫且按下不表。
- 進入 poll 階段
- 首先檢查是否存在尚未完成的回撥,如果存在,那麼分兩種情況。
- 第一種情況:
- 如果有可用回撥(可用回撥包含到期的定時器還有一些IO事件等),執行所有可用回撥。
- 檢查是否有 process.nextTick 回撥,如果有,全部執行。
- 檢查是否有 microtaks,如果有,全部執行。
- 退出該階段。
- 第二種情況:
- 如果沒有可用回撥。
- 檢查是否有 immediate 回撥,如果有,退出 poll 階段。如果沒有,阻塞在此階段,等待新的事件通知。
- 第一種情況:
- 如果不存在尚未完成的回撥,退出poll階段。
- 首先檢查是否存在尚未完成的回撥,如果存在,那麼分兩種情況。
- 進入 check 階段。
- 如果有immediate回撥,則執行所有immediate回撥。
- 檢查是否有 process.nextTick 回撥,如果有,全部執行。
- 檢查是否有 microtaks,如果有,全部執行。
- 退出 check 階段
- 進入 closing 階段。
- 如果有immediate回撥,則執行所有immediate回撥。
- 檢查是否有 process.nextTick 回撥,如果有,全部執行。
- 檢查是否有 microtaks,如果有,全部執行。
- 退出 closing 階段
- 檢查是否有活躍的 handles(定時器、IO等事件控制程式碼)。
- 如果有,繼續下一輪迴圈。
- 如果沒有,結束事件迴圈,退出程式。
- 進入 timers 階段
細心的童鞋可以發現,在事件迴圈的每一個子階段退出之前都會按順序執行如下過程:
- 檢查是否有 process.nextTick 回撥,如果有,全部執行。
- 檢查是否有 microtaks,如果有,全部執行。
- 退出當前階段。
記住這個規律哦。
那麼,按照以上公式,代入網上各種有關 nodejs 事件迴圈的測試程式碼,相信你已經能夠解釋為什麼會輸出那樣的結果了。如果不能,那就私信我吧~~