此處如無特殊指出的話,event loop的語境都是指nodejsjavascript
本文研究所用的nodejs環境是:操做系統window10 + nodejs版本號爲v12.16.2html
event loop是指由libuv提供的,一種實現非阻塞I/O的機制。具體來說,由於javascript一門single-threaded編程語言,因此nodejs只能把異步I/O操做的實現(非阻塞I/O的實現結果的就是異步I/O)轉交給libuv來作。由於I/O既可能發生在不少不一樣操做系統上(Unix,Linux,Mac OX,Window),又能夠分爲不少不一樣類型的I/O(file I/O, Network I/O, DNS I/O,database I/O等)。因此,對於libuv而言,若是當前系統對某種類型的I/O操做提供相應的異步接口的話,那麼libuv就使用這些現成的接口,不然的話就啓動一個線程池來本身實現。這就是官方文檔所說的:「事件循環使Node.js能夠經過將操做轉移到系統內核中來執行非阻塞I / O操做(儘管JavaScript是單線程的)」的意思。java
The event loop is what allows Node.js to perform non-blocking I/O operations — despite the fact that JavaScript is single-threaded — by offloading operations to the system kernel whenever possible.node
在繼續討論nodejs event loop以前,咱們不妨來看看nodejs的架構圖:linux
從上面的架構圖,你能夠看出,libuv是位於架構的最底層的。而咱們所要講得event loop的實現是由libuv來提供的。如今,你的腦海裏面應該有一幅完整的畫面,並清楚地知道event loop到底處在哪一個位置了。chrome
這裏值得強調的一點是,不管是chrome瀏覽器中的仍是nodejs中的event loop,其實都不是由v8引擎來實現的。數據庫
常常聽到這樣的說法,用戶的javascript代碼跑在主線程上,nodejs其他的javascript代碼(不是用戶寫的)跑在event loop的這個線程上。每一次當有異步操做發生的時候,主線程會把I/O操做的實現交給event loop線程。當異步I/O有告終果以後,event loop線程就會把結果通知主線程,主線程就會去執行用戶註冊的callback函數。編程
無論是用戶寫的仍是nodejs自己內置的javascript代碼(nodejs API),全部的javascript代碼都運行在同一個線程裏面。在nodejs的角度看來,全部的javascript代碼要麼是同步代碼,要麼就是異步代碼。或許咱們能夠這樣說,全部的同步代碼的執行都是由v8來完成的,全部異步代碼的執行都是由libuv提供的event loop功能模塊來完成的。那event loop與v8是什麼關係呢?咱們能夠看看下面的源代碼:promise
Environment* CreateEnvironment(Isolate* isolate, uv_loop_t* loop, Handle<Context> context, int argc, const char* const* argv, int exec_argc, const char* const* exec_argv) {
HandleScope handle_scope(isolate);
Context::Scope context_scope(context);
Environment* env = Environment::New(context, loop);
isolate->SetAutorunMicrotasks(false);
uv_check_init(env->event_loop(), env->immediate_check_handle());
uv_unref(reinterpret_cast<uv_handle_t*>(env->immediate_check_handle()));
uv_idle_init(env->event_loop(), env->immediate_idle_handle());
uv_prepare_init(env->event_loop(), env->idle_prepare_handle());
uv_check_init(env->event_loop(), env->idle_check_handle());
uv_unref(reinterpret_cast<uv_handle_t*>(env->idle_prepare_handle()));
uv_unref(reinterpret_cast<uv_handle_t*>(env->idle_check_handle()));
// Register handle cleanups
env->RegisterHandleCleanup(reinterpret_cast<uv_handle_t*>(env->immediate_check_handle()), HandleCleanup, nullptr);
env->RegisterHandleCleanup(reinterpret_cast<uv_handle_t*>(env->immediate_idle_handle()), HandleCleanup, nullptr);
env->RegisterHandleCleanup(reinterpret_cast<uv_handle_t*>(env->idle_prepare_handle()), HandleCleanup, nullptr);
env->RegisterHandleCleanup(reinterpret_cast<uv_handle_t*>(env->idle_check_handle()), HandleCleanup, nullptr);
if (v8_is_profiling) {
StartProfilerIdleNotifier(env);
}
Local<FunctionTemplate> process_template = FunctionTemplate::New(isolate);
process_template->SetClassName(FIXED_ONE_BYTE_STRING(isolate, "process"));
Local<Object> process_object = process_template->GetFunction()->NewInstance();
env->set_process_object(process_object);
SetupProcessObject(env, argc, argv, exec_argc, exec_argv);
LoadAsyncWrapperInfo(env);
return env;
}
複製代碼
能夠看到,nodejs在建立v8環境的時候,會把libuv默認的event loop做爲參數傳遞進去的。event loop是被v8所使用一個功能模塊。所以,咱們能夠說,v8包含了event loop。瀏覽器
對於這個單一的線程,有些人稱之爲v8線程,有些人稱之爲event loop線程,還有些人稱之爲node線程。鑑於nodejs大多時候都被稱爲javascript的運行時,因此,我更傾向於稱之爲「node線程」。不過,須要重申一次的是:「不管它叫什麼,本質都是同樣的。那就是它們都是指全部javascript運行所在的那一個線程。」
異步操做,好比像文件系統的讀寫,發出HTTP請求或者對數據庫進行讀寫等等都是load off給libuv的線程池來完成的。
libuv確實會建立一個具備四個線程的線程池。可是,時至今日,許多操做系統已經向外提供一些實現異步I/O的接口了(例如:Linux上面的AIO),libuv內部會優先考慮使用這些現成的API接口來完成異步I/O。只有在特定狀況下(某個操做系統對某種類型I/O沒有提供相應的異步接口的時候),libuv纔會使用線程池中的線程+輪詢來實現異步I/O。
event loop會持續地以一種FIFO的方式遍歷一個裝滿着異步task callback的隊列,當這個task完成以後,event loop就會執行它相應的callback。
event loop機制中確實是涉及到相似於隊列的數據結構,可是並非只有一個這種「隊列」。實際上,event loop主要遍歷的是不一樣的階段(phase),每一個階段會有一個裝着callback函數的隊列與之相對應(稱之爲callback queue)。當執行到某個階段的時候,event loop纔會去遍歷這個階段所對應的callback queue。
首先,咱們從nodejs程序生命週期的角度來看看,event loop所處的位置:
上面的圖中,mainline code指的就是咱們nodejs的入口文件。入口文件被看做是同步代碼,由v8來執行。在從上到下的解釋/編譯的過程當中,若是遇到執行異步代碼的請求的時候,nodejs就會把它交給event loop來執行。
在nodejs中,異步代碼有不少類型,好比定時器,process.nextTick()和各類的I/O操做。上面的這張圖把異步I/O單獨拎出來,主要是由於在nodejs中,它佔據異步代碼的大半壁江山,處於十分重要的地位。這裏的「Event Demultiplexer」其實指的就是由libuv中幫咱們封裝好的各個I/O功能模塊的集合(能夠查看上面的libuv架構圖)。當Event Demultiplexer從操做系統中拿到I/O處理結果後,它就會通知event loop將相應的callback/handler入隊到相應的隊列中。
event loop是一個單線程,半無限的循環。之因此說它是「半無限」,是由於當沒有任何任務(更多的異步I/O請求或者timer)要作的的時候,event loop會退出這個循環,整個nodejs程序也就執行完成了。
以上是event loop在整個nodejs程序生命週期裏面的位置。當咱們單獨對event loop展開來看的的時候,實際上它主要是包括六個階段:
event loop會依次進入上述的每一個階段。每一個階段都會有一callback queue與之相對應。event loop會遍歷這個callback queue,執行裏面的每個callback。直到callback queue爲空或者當前callback的執行數量超過了某個閾值爲止,event loop纔會移步到下一個階段。
在這個階段,event loop會檢查是否有到期的定時器能夠執行。若是有,則執行。調用setTimeout或者setInterval方法時傳入的callback會在指定的延遲時間後入隊到timers callback queue 。跟瀏覽器環境中的setTimeout和setInterval方法同樣,調用時候傳入的延遲時間並非回調確切執行的時間。timer callback的執行時間點沒法獲得穩定的,一致的保證,由於它們的執行會受到操做系統調度層面和其餘callback函數調用耗時的影響。因此,對傳入setTimeout或者setInterval方法的延遲時間參數正確的指望是:在我指定的延遲時間後,nodejs啊,我但願你儘快地幫我執行個人callback。也就是說timer callback函數的執行只會比咱們預約的時間的要晚,不會比咱們預約的時間要早。
從技術上來講,poll階段實際控制了timer callback執行的時間點。
這個階段主要是執行某些系統層級操做的回調函數。好比說,TCP發生錯誤時候的錯誤回調。假如一個TCP socket在嘗試創建鏈接的時候發生了「ECONNREFUSED」錯誤,則nodejs須要將對應的錯誤回調入隊到pending callback queue中,並立刻執行,以此來通知操做系統。
只供nodejs內部來用的階段。對於開發者而言,幾乎能夠忽略。
在進入輪詢階段以前,event loop會檢查timer callback queue是否爲空,若是不爲空的話,那麼event loop就會回退到timer階段,依次執行全部的timer callback纔回到輪詢階段。
進入輪詢階段後,event loop會作兩件事:
由於nodejs是志在應用於I/O密集型軟件,因此,在一個event loop循環中,它會花費很大比例的時間在輪詢階段。在這個階段,event loop要麼處於執行I/O callback狀態,要麼處於輪詢等待的狀態。固然,輪詢階段佔用event loop的時間也會是有個限度的。這就是第一件事情要完成的事-計算出有一個切合當前操做系統環境的適合的最大時間值。event loop退出當前輪詢階段有兩個條件:
一旦符合以上兩個條件之中的一個,event loop就會退出輪詢階段,進入check階段。
從上面的描述,咱們能夠看出,輪詢階段跟timer階段和immediate階段是有某種關係的。它們之間的關係能夠用下面的流程圖來體現:
正如上面給出的流程圖所描述的那樣,當poll處於空閒狀態的時候(也就是I/Ocallback queue爲空的時候),一旦event loop發現immediate callback queue有callback入隊了,event loop就會退出輪詢階段,立刻進入check階段。
調用setImmediate()時傳入的callback會被傳入到immediate callback queue中。event loop會依次執行隊列中的callback,直到隊列爲空,纔會移步到下一個階段。
setImmediate()其實是執行在另外一個階段的timer。在內部實現裏面,它是利用libuv的一個負責調度代碼的接口來實如今poll階段以後執行相應的代碼。
執行那些註冊在關閉事件上callback的階段。好比說:socket.on('close',callback)。這種類型的異步代碼比較少,就不展開闡述了。
正如上面小節所解釋的,這六個階段裏面,pending callbacks和idle/prepare這兩個階段是nodejs內部在使用的,只有四個階段跟用戶代碼是相關的。咱們的異步代碼最終是被推入到這四個階段所對應的callback queue裏面的。因此event loop自己有着如下的幾個隊列:
除了event loop的四個隊列以外,還有兩個隊列值得咱們注意:
這兩個隊列雖然不屬於event loop裏面的,可是它們同樣屬於nodejs異步機制的一部分。若是以event loop機制所涉及的這六個隊列爲視角的話,event loop運行機制能夠用下面的示意圖來描述:
當nodejs程序的入口文件,也就是上圖中的mainline code執行完畢後,在進入event loop以前是前後執行next tick callback和micortask callback的。有的技術文章將next tick callback歸爲microtask callback,二者是共存在一個隊列裏面,並強調它的優先級比諸如promise之類的其餘microtask的優先級高。也有的技術文章強調二者是分別歸屬爲不一樣的隊列,nodejs先執行next tick queue,再執行microtask callback queue。不管是哪種,所描述的運行結果都是同樣的。顯然,本文更同意採用後者。
調用process.nextTick()後,callback會入隊到next tick callback queue中。調用Promise/then()後,相應的callback會進入microtask callback queue中。即便這兩個隊列同時不爲空,nodejs老是先執行next tick callback queue,直到整個隊列爲空後,纔會執行microtask callback queue。當microtask callback queue爲空後,nodejs會再次回去檢查next tick callback queue。只有當這兩個隊列都爲空的狀況下,nodejs纔會進入event loop。 認真觀察的話,咱們會發現,這兩個隊列的支持遞納入隊的特性跟瀏覽器的event loop中micrtask隊列是同樣的。從這個角度,有些技術文章把next tick callback稱爲microtask callback是存在合理性的。當對microtask callback無限遞納入隊時,會形成一個後果:event loop starvation。也便是會阻塞event loop。雖然,這個特性不會形成nodejs程序報調用棧溢出的錯誤,可是實際上,nodejs已經處於沒法假死的狀態了。因此,咱們不推薦無限遞納入隊。
能夠看出,next tick callback和microtask callback的執行已經造成了一個小循環,nodejs只有跳轉這個小循環,纔會進入event loop這個大循環。
當mainline code執行完畢後,nodejs也進入了event loop以後,假如此時timer callback queue和 immediate callback queue都不爲空的時候,那應該先執行誰呢?你可能以爲確定是執行timer callback queue啊。是的,正常狀況下是會這樣的。由於timer階段在check階段以前嘛。可是存在一種狀況,是會先執行immediate callback queue,再執行timer callback queue。什麼狀況呢?那就是二者的入隊動做發生在poll階段(也能夠說發生在I/O callback代碼裏面)。爲何?由於poll階段處於idle狀態後,event loop一旦發現你immediate callback queue有callback了,它就會退出輪詢階段,從而進入check階段去執行全部的immediate callback。此處不會像進入poll階段以前所發生階段回退,即不會優先回退到timer階段去執行全部的timer callback。其實,timer callback的執行已是發生在下一次event loop裏面了。綜上所述,若是timer callback和immediate callback在I/O callback裏面同時入隊的話,event loop老是先執行後者,再執行前者。
假如在mainline code有這樣代碼:
// timeout_vs_immediate.js
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
複製代碼
那必定是先打印「timeout」,後打印「immediate」嗎?答案是不必定。由於timer callback的入隊時間點有可能受到進程性能(機器上運行中的其餘應用程序會影響到nodejs應用進程性能)的影響,從而致使在event loop進入timer階段以前,timer callback沒能如預期進入隊列。這個時候,event loop就已經進入了下一個階段了。因此,上面的代碼的打印順序是沒法保證的。有時候是先打印「timeout」,有時候是先打印「immediate」:
$ node timeout_vs_immediate.js
timeout
immediate
$ node timeout_vs_immediate.js
immediate
timeout
複製代碼
大循環指的就是event loop,小循環就是指由next tick callback queue和microtask callback queue所組成的小循環。咱們能夠下這麼一個結論:一旦進入大循環以後,每執行完一個大循環 callback以後,就必須檢查小循環。若是小循環有callback要執行,則須要執行完全部的小循環calback以後纔會迴歸到大循環裏面。 注意,這裏強調的是,nodejs不會把event loop中當前階段的隊列都清空以後才進入小循環,而是執行了一個callback以後,就進入了小循環了。關於這一點,官方文檔是這麼說的:
......This is because process.nextTick() is not technically part of the event loop. Instead, the nextTickQueue will be processed after the current operation is completed, regardless of the current phase of the event loop. Here, an operation is defined as a transition from the underlying C/C++ handler, and handling the JavaScript that needs to be executed.
注意:在node v11.15.0以前(不包括自己),在這一點上是不同的。在這些版本里面,表現是:event loop執行完當前階段callback queue裏面的全部callback纔會進入小循環。你能夠在runkit上面驗證一下。
爲了幫助咱們理解,請看下面代碼:
setImmediate(() => console.log('this is set immediate 1'));
setImmediate(() => {
Promise.resolve().then(()=>{
console.log('this is promise1 in setImmediate2');
});
process.nextTick(() => console.log('this is process.nextTick1 added inside setImmediate2'));
Promise.resolve().then(()=>{
console.log('this is promise2 in setImmediate2');
});
process.nextTick(() => console.log('this is process.nextTick2 added inside setImmediate2'));
console.log('this is set immediate 2')
});
setImmediate(() => console.log('this is set immediate 3'));
複製代碼
若是是一次性執行完全部的immediate callback才進入小循環的話,那麼打印結果應該是這樣的:
this is set immediate 1
this is set immediate 2
this is set immediate 3
this is process.nextTick1 added inside setImmediate2
this is process.nextTick2 added inside setImmediate2
this is promise1 in setImmediate2
this is promise2 in setImmediate2
複製代碼
可是實際打印結果是這樣的:
看到沒,在執行完第二個immediate以後,小循環已經有callback在隊列裏面了。這時候,nodejs會優先執行小循環裏面的callback。假若小循環經過遞納入隊造成了無限循環的話,那麼就會出現上面所提到的「event loop starvation」。上面的示例代碼只是拿immediate callback作個舉例而已,對於event loop其餘隊列裏面的callback也是同樣的,在這裏就不贅述了。
也許你會好奇,若是在小循環的callback裏面入隊小循環callback(也就是說遞納入隊),那會怎樣呢?也就是下面的代碼的運行結果會是怎樣呢?
process.nextTick(()=>{
console.log('this is process.nextTick 1')
});
process.nextTick(()=>{
console.log('this is process.nextTick 2')
process.nextTick(() => console.log('this is process.nextTick added inside process.nextTick 2'));
});
process.nextTick(()=>{
console.log('this is process.nextTick 3')
});
複製代碼
運行結果以下:
this is process.nextTick 1
this is process.nextTick 2
this is process.nextTick 3
this is process.nextTick added inside process.nextTick 2
複製代碼
能夠看出,遞納入隊的callback並不會插隊到隊列的中間,而是被插入到隊列的末尾。這個表現跟在event loop中被入隊的表現是不同的。這就是大循環和小循環在執行入隊next tick callback和microtask callback時候的區別。
這二者之間有相同點,也有差別點。再次強調,如下結論是基於node v12.16.2來得出的。
從運行機制的實質上來看,二者大致上是沒有什麼區別的。具體展開來講就是:若是把nodejs event loop中的mainline code和各個階段中的callback都概括爲macrotask callback,把next tick callback和其餘諸如Promise/then()的microtask callback都概括爲microtask callback的話,這兩個event loop機制大致是一致的:都是先執行一個macrotask callback,再執行一個完整的microtask callback隊列。microtask callback都具有遞納入隊的特性,無限遞納入隊都會產生「event loop starvation」後果。只有執行完microtask callback queue中的全部callback,纔會執行下一個macrotask callback。
從技術細節來看,這二者仍是有幾個不一樣點: