多數的網站不須要大量計算,程序花費的時間主要集中在磁盤 I/O 和網絡 I/O 上面javascript
SSD讀取很快,但和CPU處理指令的速度比起來也不在一個數量級上,並且網絡上一個數據包來回的時間更慢:html
一個數據包來回的延遲平均320ms(我網速慢,ping國內網站會更快),這段時間內一個普通 cpu 執行幾千萬個週期應該沒問題java
所以異步IO就要發揮做用了,好比用多線程,若是用 Java 去讀一個文件,這是一個阻塞的操做,在等待數據返回的過程當中什麼也幹不了,所以就開一個新的線程來處理文件讀取,讀取操做結束後再去通知主線程。node
這樣雖然行得通,可是代碼寫起來比較麻煩。像 Node.js V8 這種沒法開一個線程的怎麼辦?linux
咱們能夠先默默地回答下下面的9個問題,是否都清楚呢? git
異步IO是指操做系統提供的IO(數據進出)的能力,好比鍵盤輸入,對應到顯示器上會有專門的數據輸出接口,這就是咱們生活中可見的IO能力;這個接口在向下會進入到操做系統這個層面,在操做系統中,會提供諸多的能力,好比:磁盤的讀寫,DNS的查詢,數據庫的鏈接啊,網絡請求的處理,等等;web
在不一樣的操做系統層面,表現的不一致。有的是異步非阻塞的;有的是同步的阻塞的,不管如何,咱們均可以看作是上層應用於下層系統之間的數據交互;上層依賴於下層,可是反過來,上層也能夠對下層提供的這些能力進行改造;若是這種操做是異步的,非阻塞的,那麼這種就是異步非阻塞的異步IO模型;若是是同步的阻塞的,那麼就是同步IO模型;面試
koa
就是一個上層的web服務框架,所有由js實現,他有操做系統之間的交互,所有經過nodejs
來實現;如nodejs
的 readFile
就是一個異步非阻塞的接口,readFileSync
就是一個同步阻塞接口;到這裏上面三個問題基本回答完畢;數據庫
事件循環是指Node.js
執行非阻塞I/O操做,儘管JavaScript是單線程的,但因爲大多數內核都是多線程的,node.js會盡量將操做裝載到系統內核。所以它們能夠處理在後臺執行的多個操做。當其中一個操做完成時,內核會告訴Node.js,以便node.js能夠將相應的回調添加到輪詢隊列中以最終執行。npm
nodejs是單線程執行的,同時它又是基於事件驅動
的非阻塞IO
編程模型。這就使得咱們不用等待異步操做結果返回,就能夠繼續往下執行代碼。當異步事件觸發以後,就會通知主線程,主線程執行相應事件的回調。
說道 Nodejs 架構, 首先要直到 Nodejs 與 V8 和 libUV 的關係和做用:
async I/O
, 提供消息循環. 可見, 是操做系統 API 層的一個抽象層.那麼 Nodejs 如何組織它們呢?
框架代碼以及用戶代碼即咱們編寫的應用程序代碼、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 經過一層 C++ Binding, 把 JS 傳入 V8, V8 解析後交給 libUV 發起 asnyc I/O, 並等待消息循環調度.
nodejs的依賴庫,包括大名鼎鼎的V八、libuv。
還有一些其餘的依賴庫
咱們知道,nodejs實現異步機制的核心即是libuv,libuv承擔着nodejs與文件、網絡等異步任務的溝通橋樑,下面這張圖讓咱們對libuv有個大概的印象:
這是libuv官網的一張圖,很明顯,nodejs的網絡I/O、文件I/O、DNS操做、還有一些用戶代碼都是在 libuv 工做的。 既然談到了異步,那麼咱們首先概括下nodejs裏的異步事件:
對於網絡I/O,各個平臺的實現機制不同,linux 是 epoll 模型,類 unix 是 kquene 、windows 下是高效的 IOCP 完成端口、SunOs 是 event ports,libuv 對這幾種網絡I/O模型進行了封裝。
libuv內部還維護着一個默認4個線程的線程池,這些線程負責執行文件I/O操做、DNS操做、用戶異步代碼。當 js 層傳遞給 libuv 一個操做任務時,libuv 會把這個任務加到隊列中。以後分兩種狀況:
一、線程池中的線程都被佔用的時候,隊列中任務就要進行排隊等待空閒線程。
二、線程池中有可用線程時,從隊列中取出這個任務執行,執行完畢後,線程歸還到線程池,等待下個任務。同時以事件的方式通知event-loop,event-loop接收到事件執行該事件註冊的回調函數。
固然,若是以爲4個線程不夠用,能夠在nodejs啓動時,設置環境變量UV_THREADPOOL_SIZE來調整,出於系統性能考慮,libuv 規定可設置線程數不能超過128個。
node.js啓動過程能夠分爲如下步驟:
一、調用platformInit方法 ,初始化 nodejs 的運行環境。
二、調用 performance_node_start 方法,對 nodejs 進行性能統計。
三、openssl設置的判斷。
四、調用v8_platform.Initialize,初始化 libuv 線程池。
五、調用 V8::Initialize,初始化 V8 環境。
六、建立一個nodejs運行實例。
七、啓動上一步建立好的實例。
八、開始執行js文件,同步代碼執行完畢後,進入事件循環。
九、在沒有任何可監聽的事件時,銷燬 nodejs 實例,程序執行完畢。
以上就是 nodejs 執行一個js文件的全過程。接下來着重介紹第八個步驟,事件循環。
Nodejs 徹底是單線程的. 從進程啓動後, 由主線程加載咱們的 js 文件(下圖中 main.js), 而後進入消息循環. 可見對於 js 程序而言, 完整運行在單線程之中.
但並非說 Node 進程只有一個線程. 正如 Node.js event loop workflow & lifecycle in low level 中所說:在 libUV 這一層其實是有個線程池輔助完成一些工做的.
再來看一下 JS 中的消息循環部分:
timers
:執行setTimeout() 和 setInterval()中到期的callback。I/O callbacks
:上一輪循環中有少數的I/Ocallback會被延遲到這一輪的這一階段執行idle, prepare
:僅內部使用poll
:最爲重要的階段,執行I/O callback,在適當的條件下會阻塞在這個階段check
:執行setImmediate的callbackclose callbacks
:執行close事件的callback,例如socket.on("close",func)Nodejs 將消息循環又細分爲 6 個階段(官方叫作 Phase), 每一個階段都會有一個相似於隊列的結構, 存儲着該階段須要處理的回調函數. 咱們來看一下這 6 個 Phase 的做用,這六個階段的核心代碼以下:
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;
}
複製代碼
這是消息循環的第一個階段, 用一個 for
循環處理全部 setTimeout
和 setInterval
的回調. 核心代碼以下:
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);
}
}
複製代碼
這些回調被保存在一個最小堆(min heap) 中. 這樣引擎只須要每次判斷頭元素, 若是符合條件就拿出來執行, 直到遇到一個不符合條件或者隊列空了, 才結束 Timer Phase.
Timer Phase 中判斷某個回調是否符合條件的方法也很簡單. 消息循環每次進入 Timer Phase 的時候都會保存一下當時的系統時間,而後只要看上述最小堆中的回調函數設置的啓動時間是否超過進入 Timer Phase 時保存的時間, 若是超過就拿出來執行.
此外, Nodejs 爲了防止某個 Phase 任務太多, 致使後續的 Phase 發生飢餓的現象, 因此消息循環的每個迭代(iterate) 中, 每一個 Phase 執行回調都有個最大數量. 若是超過數量的話也會強行結束當前 Phase 而進入下一個 Phase. 這一條規則適用於消息循環中的每個 Phase.
這一階段是執行你的 fs.read
, socket
等 IO 操做的回調函數, 同時也包括各類 error 的回調.
聽說是內部使用, 因此咱們也不在這裏過多討論.
這是整個消息循環中最重要的一個 Phase, 做用是等待異步請求和數據(原文: accepts new incoming connections (new socket establishment etc) and data (file read etc)
). 說它最重要是由於它支撐了整個消息循環機制.
Poll Phase 首先會執行 watch_queue
隊列中的 IO 請求, 一旦 watch_queue 隊列空, 則整個消息循環就會進入 sleep , 從而等待被內核事件喚醒. 源碼在這裏:
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階段。看到這裏,你可能會想了,會永遠阻塞在此處嗎?固然 Poll Phase 不能一直等下去.
它有着精妙的設計. 簡單來講,
它首先會判斷後面的 Check Phase
以及 Close Phase
是否還有等待處理的回調. 若是有, 則不等待, 直接進入下一個 Phase.
若是沒有其餘回調等待執行, 它會給 epoll
這樣的方法設置一個 timeout.
能夠猜一下, 這個 timeout 設置爲多少合適呢? 答案就是 Timer Phase 中最近要執行的回調啓動時間到如今的差值, 假設這個差值是 detal. 由於 Poll Phase 後面沒有等待執行的回調了. 因此這裏最多等待 delta 時長, 若是期間有事件喚醒了消息循環, 那麼就繼續下一個 Phase 的工做; 若是期間什麼都沒發生, 那麼到了 timeout 後, 消息循環依然要進入後面的 Phase, 讓下一個迭代的 Timer Phase 也可以獲得執行. Nodejs 就是經過 Poll Phase, 對 IO 事件的等待和內核異步事件的到達來驅動整個消息循環的.
接下來是 Check Phase. 這個階段只處理 setImmediate
的回調函數. 那麼爲何這裏要有專門一個處理 setImmediate
的 Phase 呢? 簡單來講, 是由於 Poll Phase 階段可能設置一些回調, 但願在 Poll Phase 後運行. 因此在 Poll Phase 後面增長了這個 Check Phase.
專門處理一些 close 類型的回調. 好比 socket.on('close', ...)
. 用於資源清理.
node 的初始化
進入 event-loop
1. 進入 timers 階段
2. 進入IO callbacks階段。
3. 進入 idle,prepare 階段:
這兩個階段與咱們編程關係不大,暫且按下不表。
4. 進入 poll 階段
第一種狀況:
第二種狀況:
若是沒有可用回調。
檢查是否有 immediate 回調,若是有,退出 poll 階段。若是沒有,阻塞在此階段,等待新的事件通知。
若是不存在還沒有完成的回調,退出poll階段。
5. 進入 check 階段
6. 進入 closing 階段。
檢查是否有活躍的 handles(定時器、IO等事件句柄)
細心的童鞋能夠發現,在事件循環的每個子階段退出以前都會按順序執行以下過程:
能夠看到, 消息循環隊列圖中並無涉及到 process.nextTick
以及 Promise
的回調. 那麼這兩個回調有什麼特殊性呢?
這個隊列先保證全部的 process.nextTick
回調, 而後將全部的 Promise
回調追加在後面. 最終在每一個 Phase 結束的時候一次性拿出來執行.
此外, 在不一樣的 Phase, process.nextTick
以及 Promise
回調的數量是受限制的. 也就是說, 若是一直往這個隊列中加入回調, 那麼整個消息循環就會被 "卡住". 咱們用一張圖來看看 process.nextTick
以及 Promise
:
setTimeout(..., 0)
vs. setImmediate
到底誰快?
咱們來舉個例子直觀的感覺一下.這是一道經典的 FE 面試題.請問以下代碼的輸出:
// index.js
setImmediate(() => console.log(2))
setTimeout(() => console.log(1),0)
複製代碼
答案: 多是 1 2, 也多是 2 1
咱們從原理的角度看看這道消息循環的基礎問題.首先,Nodejs啓動,初始化環境後加載咱們的JS代碼(index.js)
.發生了兩件事(此時還沒有進入消息循環環節):setImmediate
向 Check Phase 中添加了回調 console.log(2)
; setTimeout
向 Timer Phase 中添加了回調 console.log(1)
這時候, 要初始化階段完畢, 要進入 Nodejs 消息循環了, 以下圖:
爲何會有兩種輸出呢? 接下來一步很關鍵:
當執行到 Timer Phase 時, 會發生兩種可能. 由於每一輪迭代剛剛進入 Timer Phase 時會取系統時間保存起來, 以 ms(毫秒) 爲最小單位.
若是 Timer Phase 中回調預設的時間
> 消息循環所保存的時間
, 則執行 Timer Phase 中的該回調. 這種狀況下先輸出 1, 直到 Check Phase 執行後,輸出2.總的來講, 結果是 1 2.
若是運行比較快, Timer Phase 中回調預設的時間可能恰好等於消息循環所保存的時間, 這種狀況下, Timer Phase 中的回調得不到執行, 則繼續下一個 Phase. 直到 Check Phase, 輸出 2. 而後等下一輪迭代的 Timer Phase, 這時的時間必定是知足 Timer Phase 中回調預設的時間
> 消息循環所保存的時間
, 因此 console.log(1)
獲得執行, 輸出 1. 總的來講, 結果就是 2 1.
因此, 輸出不穩定的緣由就取決於進入 Timer Phase 的時間是否和執行 setTimeout
的時間在 1ms 內. 若是把代碼改爲以下, 則必定會獲得穩定的輸出:
require('fs').readFile('my-file-path.txt', () => {
setImmediate(() => console.log(2))
setTimeout(() => console.log(1))
});
// 2 1
複製代碼
這是由於消息循環在 Pneding I/O Phase
才向 Timer 和 Check 隊列插入回調. 這時按照消息循環的執行順序, Check 必定在 Timer 以前執行
最後,讓咱們來再看一道面試題加深對Node事件環的理解:
setImmediate(() => {
console.log('setImmediate1');
setTimeout(() => {
console.log('setTimeout1')
}, 0);
});
Promise.resolve().then(res=>{
console.log('then');
})
setTimeout(() => {
process.nextTick(() => {
console.log('nextTick');
});
console.log('setTimeout2');
setImmediate(() => {
console.log('setImmediate2');
});
}, 0);
複製代碼
這道題的輸出順序是:
then
、setTimeout2
、nextTick
、setImmediate1
、setImmediate2
、setTimeout1
,爲何是這樣的順序呢?微任務nextTick
的輸出是由於timers
隊列切換到check
隊列,setImmediate1
和setImmediate2
連續輸出是因只有當前隊列執行完畢後才能進去下一對列。
從性能角度講, setTimeout
的處理是在 Timer Phase, 其中 min heap 保存了 timer 的回調, 所以每執行一個回調的同時都會涉及到堆調整. 而 setImmediate
僅僅是清空一個隊列. 效率天然會高不少.
再從執行時機上講. setTimeout(..., 0)
和 setImmediate
徹底屬於兩個 Phase.
瀏覽器中,事件環的運行機制是,先會執行棧中的內容(同步代碼),棧中的內容執行後執行微任務,微任務清空後再執行宏任務,先取出一個宏任務,再去執行微任務,而後在取宏任務清微任務這樣不停的循環,咱們能夠看下面這張圖理解一下:
從圖中能夠看出,同步任務會進入執行棧,而異步任務會進入任務隊列(callback queue)等待執行。一旦執行棧中的內容執行完畢,就會讀取任務隊列中等待的任務放入執行棧開始執行。(圖中缺乏微任務)
那麼,咱們來道面試題檢驗一下,當咱們在瀏覽器中運行下面的代碼,輸出的結果是什麼呢?
setTimeout(() => {
console.log('setTimeout1');
Promise.resolve().then(data => {
console.log('then3');
});
},1000);
Promise.resolve().then(data => {
console.log('then1');
});
Promise.resolve().then(data => {
console.log('then2');
setTimeout(() => {
console.log('setTimeout2');
},1000);
});
console.log(2);
// 輸出結果:2 then1 then2 setTimeout1 then3 setTimeout2
複製代碼
先執行棧中的內容,也就是同步代碼,因此2被輸出出來; 而後清空微任務,因此依次輸出的是 then1 then2; 因代碼是從上到下執行的,因此1s後 setTimeout1 被執行輸出; 接着再次清空微任務,then3被輸出; 最後執行輸出setTimeout2
參考: