前言 The last time, I have learned 【THE LAST TIME】一直是我想寫的一個系列,旨在厚積薄發,重溫前端。 也是給自己的查缺補漏和技術分享。 歡迎大家多多評論指點吐槽。 系列文章均首發於公眾號【全棧前端精選】,筆者文章集合詳見 "Nealyang/persona ...
前言
The last time, I have learned
【THE LAST TIME】一直是我想寫的一個系列,旨在厚積薄發,重溫前端。
也是給自己的查缺補漏和技術分享。
歡迎大家多多評論指點吐槽。
系列文章均首發於公眾號【全棧前端精選】,筆者文章集合詳見Nealyang/personalBlog。目錄皆為暫定
執行 & 運行
首先我們需要聲明下,JavaScript
的執行和運行是兩個不同概念的,執行,一般依賴於環境,比如 node
、瀏覽器、Ringo
等, JavaScript 在不同環境下的執行機制可能並不相同。而今天我們要討論的 Event Loop
就是 JavaScript
的一種執行方式。所以下文我們還會梳理 node
的執行方式。而運行呢,是指JavaScript 的解析引擎。這是統一的。
關於 JavaScript
此篇文章中,這個小標題下,我們只需要牢記一句話: JavaScript 是單線程語言 ,無論HTML5
裡面 Web-Worker
還是 node 裡面的cluster
都是“紙老虎”,而且 cluster
還是進程管理相關。這裡讀者註意區分:進程和線程。
既然 JavaScript
是單線程語言,那麼就會存在一個問題,所有的代碼都得一句一句的來執行。就像我們在食堂排隊打飯,必須一個一個排隊點菜結賬。那些沒有排到的,就得等著~
概念梳理
在詳解執行機制之前,先梳理一下 JavaScript
的一些基本概念,方便後面我們說到的時候大伙兒心裡有個印象和大概的輪廓。
事件迴圈(Event Loop)
什麼是 Event Loop?
其實這個概念還是比較模糊的,因為他必須得結合著運行機制來解釋。
JavaScript
有一個主線程 main thread
,和調用棧 call-stack
也稱之為執行棧。所有的任務都會放到調用棧中等待主線程來執行。
暫且,我們先理解為上圖的大圈圈就是 Event Loop 吧!並且,這個圈圈,一直在轉圈圈~ 也就是說,JavaScript
的 Event Loop
是伴隨著整個源碼文件生命周期的,只要當前 JavaScript
在運行中,內部的這個迴圈就會不斷地迴圈下去,去尋找 queue
裡面能執行的 task
。
任務隊列(task queue)
task
,就是任務的意思,我們這裡理解為每一個語句就是一個任務
console.log(1);
console.log(2);
如上語句,其實就是就可以理解為兩個 task
。
而 queue
呢,就是FIFO
的隊列!
所以 Task Queue
就是承載任務的隊列。而 JavaScript
的 Event Loop
就是會不斷地過來找這個 queue
,問有沒有 task
可以運行運行。
同步任務(SyncTask)、非同步任務(AsyncTask)
同步任務說白了就是主線程來執行的時候立即就能執行的代碼,比如:
console.log('this is THE LAST TIME');
console.log('Nealyang');
代碼在執行到上述 console
的時候,就會立即在控制臺上列印相應結果。
而所謂的非同步任務就是主線程執行到這個 task
的時候,“唉!你等會,我現在先不執行,等我 xxx 完了以後我再來等你執行” 註意上述我說的是等你來執行。
說白了,非同步任務就是你先去執行別的 task,等我這 xxx 完之後再往 Task Queue 裡面塞一個 task 的同步任務來等待被執行
setTimeout(()=>{
console.log(2)
});
console.log(1);
如上述代碼,setTimeout
就是一個非同步任務,主線程去執行的時候遇到 setTimeout
發現是一個非同步任務,就先註冊了一個非同步的回調,然後接著執行下麵的語句console.log(1)
,等上面的非同步任務等待的時間到了以後,在執行console.log(2)
。具體的執行機制會在後面剖析。
- 主線程自上而下執行所有代碼
- 同步任務直接進入到主線程被執行,而非同步任務則進入到
Event Table
並註冊相對應的回調函數 - 非同步任務完成後,
Event Table
會將這個函數移入Event Queue
- 主線程任務執行完了以後,會從
Event Queue
中讀取任務,進入到主線程去執行。 - 迴圈如上
上述動作不斷迴圈,就是我們所說的事件迴圈(Event Loop
)。
小試牛刀
ajax({
url:www.Nealyang.com,
data:prams,
success:() => {
console.log('請求成功!');
},
error:()=>{
console.log('請求失敗~');
}
})
console.log('這是一個同步任務');
- ajax 請求首先進入到
Event Table
,分別註冊了onError
和onSuccess
回調函數。 - 主線程執行同步任務:
console.log('這是一個同步任務');
- 主線程任務執行完畢,看
Event Queue
是否有待執行的 task,這裡是不斷地檢查,只要主線程的task queue
沒有任務執行了,主線程就一直在這等著 - ajax 執行完畢,將回調函數
push
到Event Queue
。(步驟 3、4 沒有先後順序而言) - 主線程“終於”等到了
Event Queue
里有task
可以執行了,執行對應的回調任務。 - 如此往複。
巨集任務(MacroTask)、微任務(MicroTask)
JavaScript
的任務不僅僅分為同步任務和非同步任務,同時從另一個維度,也分為了巨集任務(MacroTask
)和微任務(MicroTask
)。
先說說 MacroTask
,所有的同步任務代碼都是MacroTask
(這麼說其實不是很嚴謹,下麵解釋),setTimeout
、setInterval
、I/O
、UI Rendering
等都是巨集任務。
MicroTask
,為什麼說上述不嚴謹我卻還是強調所有的同步任務都是 MacroTask
呢,因為我們僅僅需要記住幾個 MicroTask
即可,排除法!別的都是 MacroTask
。MicroTask
包括:Process.nextTick
、Promise.then catch finally
(註意我不是說 Promise)、MutationObserver
。
瀏覽器環境下的 Event Loop
當我們梳理完哪些是 MicroTask
,除了那些別的都是 MacroTask
後,哪些是同步任務,哪些又是非同步任務後,這裡就應該徹底的梳理下JavaScript 的執行機制了。
如開篇說到的,執行和運行是不同的,執行要區分環境。所以這裡我們將 Event Loop
的介紹分為瀏覽器和 Node 兩個環境下。
先放圖鎮樓!如果你已經理解了這張圖的意思,那麼恭喜你,你完全可以直接閱讀 Node 環境下的 Event Loop
章節了!
setTimeout、setInterval
setTimeout
setTimeout
就是等多長時間來執行這個回調函數。setInterval
就是每隔多長時間來執行這個回調。
let startTime = new Date().getTime();
setTimeout(()=>{
console.log(new Date().getTime()-startTime);
},1000);
如上代碼,顧名思義,就是等 1s 後再去執行 console
。放到瀏覽器下去執行,OK,如你所願就是如此。
但是這次我們在探討 JavaScript 的執行機制,所以這裡我們得探討下如下代碼:
let startTime = new Date().getTime();
console.log({startTime})
setTimeout(()=>{
console.log(`開始執行回調的相隔時差:${new Date().getTime()-startTime}`);
},1000);
for(let i = 0;i<40000;i++){
console.log(1)
}
如上運行,setTimeout
的回調函數等到 4.7s 以後才執行!而這時候,我們把 setTimeout
的 1s 延遲給刪了:
let startTime = new Date().getTime();
console.log({startTime})
setTimeout(()=>{
console.log(`開始執行回調的相隔時差:${new Date().getTime()-startTime}`);
},0);
for(let i = 0;i<40000;i++){
console.log(1)
}
結果依然是等到 4.7s 後才執行setTimeout 的回調。貌似 setTimeout 後面的延遲並沒有產生任何效果!
其實這麼說,又應該回到上面的那張 JavaScript 執行的流程圖了。
setTimeout
這裡就是簡單的非同步,我們通過上面的圖來分析上述代碼的一步一步執行情況
- 首先
JavaScript
自上而下執行代碼 - 遇到遇到賦值語句、以及第一個
console.log({startTime})
分別作為一個task
,壓入到立即執行棧中被執行。 - 遇到
setTImeout
是一個非同步任務,則註冊相應回調函數。(非同步函數告訴你,js 你先別急,等 1s 後我再將回調函數:console.log(xxx)
放到Task Queue
中) - OK,這時候 JavaScript 則接著往下走,遇到了 40000 個 for 迴圈的 task,沒辦法,1s 後都還沒執行完。其實這個時候上述的回調已經在
Task Queue
中了。 - 等所有的立即執行棧中的 task 都執行完了,在回頭看
Task Queue
中的任務,發現非同步的回調 task 已經在裡面了,所以接著執行。
打個比方
其實上述的不僅僅是 timeout,而是任何非同步,比如網路請求等。
就好比,我六點鐘下班了,可以安排下自己的活動了!
然後收拾電腦(同步任務)、收拾書包(同步任務)、給女朋友打電話說出來吃飯吧(必然是非同步任務),然後女朋友說你等會,我先化個妝,等我畫好了call你。
那我不能幹等著呀,就接著做別的事情,比如那我就在改個 bug 吧,你好了通知我。結果等她一個小時後說我化好妝了,我們出去吃飯吧。不行!我 bug 還沒有解決掉呢?你等會。。。。其實這個時候你的一小時化妝還是 5 分鐘化妝都已經毫無意義了。。。因為哥哥這會沒空~~
如果我 bug 在半個小時就解決完了,沒別的任務需要執行了,那麼就在這等著呀!必須等著!隨時待命!。然後女朋友來電話了,我化完妝了,我們出去吃飯吧,那麼剛好,我們在你的完成了請求或者 timeout 時間到了後我剛好閑著,那麼我必須立即執行了。
setInterval
說完了 setTimeout
,當然不能錯過他的孿生兄弟:setInterval
。對於執行順序來說,setInterval
會每隔指定的時間將註冊的函數置入 Task Queue
,如果前面的任務耗時太久,那麼同樣需要等待。
這裡需要說的是,對於 setInterval(fn,ms)
來說,我們制定沒 xx ms
執行一次 fn
,其實是沒 xx ms
,會有一個fn
進入到 Task Queue
中。一旦 setInterval 的回調函數fn
執行時間超過了xx ms,那麼就完全看不出來有時間間隔了。 仔細回味回味,是不是那麼回事?
Promise
關於 Promise
的用法,這裡就不過過多介紹了,後面會在寫《【THE LAST TIME】徹底吃透 JavaScript 非同步》 一文的時候詳細介紹。這裡我們只說 JavaScript 的執行機制。
如上所說,promise.then
、catch
和 finally
是屬於 MicroTask
。這裡主要是非同步的區分。展開說明之前,我們結合上述說的,再來“扭曲”梳理一下。
為了避免初學者這時候腦子有點混亂,我們暫時忘掉 JavaScript 非同步任務! 我們暫且稱之為待會再執行的同步任務。
有瞭如上約束後,我們可以說,JavaScript 從一開始就自上而下的執行每一個語句(Task
),這時候只能遇到立馬就要執行的任務和待會再執行的任務。對於那待會再執行的任務等到能執行了,也不會立即執行,你得等js 執行完這一趟才行
再打個比方
就像做公交車一樣,公交車不等人呀,公交車路線上有人就會停(農村公交!麽得站牌),但是等公交車來,你跟司機說,我肚子疼要拉x~這時候公交不會等你。你只能拉完以後等公交下一趟再來(大山裡!一個路線就一趟車)。
OK!你拉完了。。。等公交,公交也很快到了!但是,你不能立馬上車,因為這時候前面有個孕婦!有個老人!還有熊孩子,你必須得讓他們先上車,然後你才能上車!
而這些 孕婦、老人、熊孩子所組成的就是傳說中的 MicroTask Queue
,而且,就在你和你的同事、朋友就必須在他們後面上車。
這裡我們沒有非同步的概念,只有同樣的一次迴圈回來,有了兩種隊伍,一種優先上車的隊伍叫做MicroTask Queue
,而你和你的同事這幫壯漢組成的隊伍就是巨集隊伍(MacroTask Queue
)。
一句話理解:一次事件迴圈回來後,開始去執行 Task Queue
中的 task
,但是這裡的 task
有優先順序。所以優先執行 MicroTask Queue
中的 task
,執行完後在執行MacroTask Queue
中的 task
小試牛刀
理論都扯完了,也不知道你懂沒懂。來,期中考試了!
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
沒必要搞個 setTimeout 有加個 Promise,Promise 裡面再整個 setTimeout 的例子。因為只要上面代碼你懂了,無非就是公交再來一趟而已!
如果說了這麼多,還是沒能理解上圖,那麼公眾號內回覆【1】,手摸手指導!
Node 環境下的 Event Loop
Node中的Event Loop
是基於libuv
實現的,而libuv
是 Node 的新跨平臺抽象層,libuv
使用非同步,事件驅動的編程方式,核心是提供i/o
的事件迴圈和非同步回調。libuv
的API
包含有時間,非阻塞的網路,非同步文件操作,子進程等等。
Event Loop就是在libuv
中實現的。所以關於 Node 的 Event Loop
學習,有兩個官方途徑可以學習:
在學習 Node 環境下的 Event Loop
之前呢,我們首先要明確執行環境,Node 和瀏覽器的Event Loop是兩個有明確區分的事物,不能混為一談。nodejs的event是基於libuv,而瀏覽器的event loop則在html5的規範中明確定義。
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
Node 的 Event Loop 分為 6 個階段:
- timers:執行
setTimeout()
和setInterval()
中到期的callback。 - pending callback: 上一輪迴圈中有少數的
I/O
callback會被延遲到這一輪的這一階段執行 - idle, prepare:僅內部使用
- poll: 最為重要的階段,執行
I/O
callback,在適當的條件下會阻塞在這個階段 - check: 執行
setImmediate
的callback - close callbacks: 執行
close
事件的callback,例如socket.on('close'[,fn])
、http.server.on('close, fn)
上面六個階段都不包括 process.nextTick()(下文會介紹)
整體的執行機制如上圖所示,下麵我們具體展開每一個階段的說明
timers 階段
timers 階段會執行 setTimeout
和 setInterval
回調,並且是由 poll 階段控制的。
在 timers 階段其實使用一個最小堆而不是隊列來保存所有的元素,其實也可以理解,因為timeout的callback是按照超時時間的順序來調用的,並不是先進先出的隊列邏輯)。而為什麼 timer 階段在第一個執行階梯上其實也不難理解。在 Node 中定時器指定的時間也是不准確的,而這樣,就能儘可能的準確了,讓其回調函數儘快執行。
以下是官網給出的例子:
const fs = require('fs');
function someAsyncOperation(callback) {
// Assume this takes 95ms to complete
fs.readFile('/path/to/file', callback);
}
const timeoutScheduled = Date.now();
setTimeout(() => {
const delay = Date.now() - timeoutScheduled;
console.log(`${delay}ms have passed since I was scheduled`);
}, 100);
// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
const startCallback = Date.now();
// do something that will take 10ms...
while (Date.now() - startCallback < 10) {
// do nothing
}
});
當進入事件迴圈時,它有一個空隊列(fs.readFile()
尚未完成),因此定時器將等待剩餘毫秒數,當到達95ms時,fs.readFile()
完成讀取文件並且其完成需要10毫秒的回調被添加到輪詢隊列並執行。
當回調結束時,隊列中不再有回調,因此事件迴圈將看到已達到最快定時器的閾值,然後回到timers階段以執行定時器的回調。
在此示例中,您將看到正在調度的計時器與正在執行的回調之間的總延遲將為105毫秒。
pending callbacks 階段
pending callbacks 階段其實是 I/O
的 callbacks 階段。比如一些 TCP 的 error 回調等。
舉個慄子:如果TCP socket ECONNREFUSED
在嘗試connect
時receives
,則某些* nix系統希望等待報告錯誤。 這將在pending callbacks階段執行。
poll 階段
poll 階段主要有兩個功能:
- 執行
I/O
回調 - 處理 poll 隊列(poll queue)中的事件
當時Event Loop 進入到 poll 階段並且 timers 階段沒有任何可執行的 task 的時候(也就是沒有定時器回調),將會有以下兩種情況
- 如果 poll queue 非空,則 Event Loop就會執行他們,知道為空或者達到system-dependent(系統相關限制)
- 如果 poll queue 為空,則會發生以下一種情況
- 如果setImmediate()有回調需要執行,則會立即進入到 check 階段
- 相反,如果沒有setImmediate()需要執行,則 poll 階段將等待 callback 被添加到隊列中再立即執行,這也是為什麼我們說 poll 階段可能會阻塞的原因。
一旦 poll queue 為空,Event Loop就回去檢查timer 階段的任務。如果有的話,則會回到 timer 階段執行回調。
check 階段
check 階段在 poll 階段之後,setImmediate()
的回調會被加入check隊列中,他是一個使用libuv API
的特殊的計數器。
通常在代碼執行的時候,Event Loop 最終會到達 poll 階段,然後等待傳入的鏈接或者請求等,但是如果已經指定了setImmediate()並且這時候 poll 階段已經空閑的時候,則 poll 階段將會被中止然後開始 check 階段的執行。
close callbacks 階段
如果一個 socket 或者事件處理函數突然關閉/中斷(比如:socket.destroy()
),則這個階段就會發生 close
的回調執行。否則他會通過 process.nextTick()
發出。
setImmediate() vs setTimeout()
setImmediate()
和 setTimeout()
非常的相似,區別取決於誰調用了它。
setImmediate
在 poll 階段後執行,即check 階段setTimeout
在 poll 空閑時且設定時間到達的時候執行,在 timer 階段
計時器的執行順序將根據調用它們的上下文而有所不同。 如果兩者都是從主模塊中調用的,則時序將受到進程性能的限制。
例如,如果我們運行以下不在I / O
周期(即主模塊)內的腳本,則兩個計時器的執行順序是不確定的,因為它受進程性能的約束:
// timeout_vs_immediate.js
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
$ node timeout_vs_immediate.js
timeout
immediate
$ node timeout_vs_immediate.js
immediate
timeout
如果在一個I/O
周期內移動這兩個調用,則始終首先執行立即回調:
// timeout_vs_immediate.js
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
$ node timeout_vs_immediate.js
immediate
timeout
$ node timeout_vs_immediate.js
immediate
timeout
所以與setTimeout()
相比,使用setImmediate()
的主要優點是,如果在I / O
周期內安排了任何計時器,則setImmediate()
將始終在任何計時器之前執行,而與存在多少計時器無關。
nextTick queue
可能你已經註意到process.nextTick()
並未顯示在圖中,即使它是非同步API的一部分。 所以他擁有一個自己的隊列:nextTickQueue
。
這是因為process.nextTick()
從技術上講不是Event Loop的一部分。 相反,無論當前事件迴圈的當前階段如何,都將在當前操作完成之後處理nextTickQueue
。
如果存在 nextTickQueue
,就會清空隊列中的所有回調函數,並且優先於其他 microtask
執行。
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
process.nextTick(() => {
console.log('nextTick')
})
})
})
})
// nextTick=>nextTick=>nextTick=>nextTick=>timer1=>promise1
process.nextTick() vs setImmediate()
從使用者角度而言,這兩個名稱非常的容易讓人感覺到困惑。
process.nextTick()
在同一階段立即觸發setImmediate()
在事件迴圈的以下迭代或“tick”中觸發
貌似這兩個名稱應該呼喚下!的確~官方也這麼認為。但是他們說這是歷史包袱,已經不會更改了。
這裡還是建議大家儘可能使用setImmediate。因為更加的讓程式可控容易推理。
至於為什麼還是需要 process.nextTick
,存在即合理。這裡建議大家閱讀官方文檔:why-use-process-nexttick。
Node與瀏覽器的 Event Loop 差異
一句話總結其中:瀏覽器環境下,microtask的任務隊列是每個macrotask執行完之後執行。而在Node.js中,microtask會在事件迴圈的各個階段之間執行,也就是一個階段執行完畢,就會去執行microtask隊列的任務。
上圖來自浪里行舟
最後
來~期末考試了
console.log('1');
setTimeout(function() {
console.log('2');
process.nextTick(function() {
console.log('3');
})
new Promise(function(resolve) {
console.log('4');
resolve();
}).then(function() {
console.log('5')
})
})
process.nextTick(function() {
console.log('6');
})
new Promise(function(resolve) {
console.log('7');
resolve();
}).then(function() {
console.log('8')
})
setTimeout(function() {
console.log('9');
process.nextTick(function() {
console.log('10');
})
new Promise(function(resolve) {
console.log('11');
resolve();
}).then(function() {
console.log('12')
})
})
評論區留下你的答案吧~~老鐵!
參考文獻
- Tasks, microtasks, queues and schedules
- libuv 文檔
- The Node.js Event Loop, Timers, and process.nextTick()
- node 官網
- async/await 在chrome 環境和 node 環境的 執行結果不一致,求解?
- 更快的非同步函數和 Promise
- 一次弄懂Event Loop(徹底解決此類面試問題)
- 這一次,徹底弄懂 JavaScript 執行機制
- 不要混淆nodejs和瀏覽器中的event loop
學習交流
關註公眾號: 【全棧前端精選】 每日獲取好文推薦。
公眾號內回覆 【1】,加入全棧前端學習群,一起交流。