JavaScript是一門單線程的非阻塞腳本語言,Event Loop就是為了解決JavaScript異步編程的一種解決方案。
第一個問題:JavaScript的誕生就是為了處理瀏覽器網頁的交互(DOM操作的處理、UI動畫等), 設計成單線程的原因就是不想讓瀏覽器變得太復雜,因為多線程需要共享資源、且有可能修改彼此的運行結果(兩個線程修改了同一個DOM節點就會產生不必要的麻煩),這對於一種網頁腳本語言來說這就太復雜了。
第二個問題:JavaScript是單線程的但它所運行的宿主環境—瀏覽器是多線程,瀏覽器提供了各種線程供Event Loop調度來協調JS單線程運行時不會阻塞。
小結
代碼執行開啟一個全局調用棧(主棧)提供代碼運行的環境,在執行過程中同步任務的代碼立即執行,遇到異步任務根據這個異步事件的類型,將異步的回調注冊添加到對應的宏任務隊列或者微任務隊列中。並且在當前執行棧為空的時候,主線程會查看微任務隊列是否有事件存在。如果不存在,那么再去宏任務隊列中取出一個事件並把對應的回到加入當前執行棧;如果存在,則會依次執行隊列中事件對應的回調,直到微任務隊列為空,然后去宏任務隊列中取出最前面的一個事件,把對應的回調加入當前執行棧...如此反復,進入循環。
當前執行棧執行完畢時會立刻先處理所有微任務隊列中的事件,然后再去宏任務隊列中取出一個事件。同一次事件循環中,微任務永遠在宏任務之前執行。
瀏覽器中的Event Loop
Javascript
有一個 main thread
主線程和 call-stack
調用棧(執行棧),所有的任務都會被放到調用棧等待主線程執行。
同步任務和異步任務
Javascript
單線程任務被分為同步任務和異步任務,同步任務會在調用棧中按照順序等待主線程依次執行,異步任務會在異步任務有了結果后,將注冊的回調函數放入任務隊列中等待主線程空閑的時候(調用棧被清空),被讀取到棧內等待主線程的執行。
Heap(堆), 動態分配的內存,大小不定也不會自動釋放,存放引用類型,指那些可能由多個值構成的對象,保存在堆內存中,包含引用類型的變量,實際上保存的不是變量本身,而是指向該對象的指針。可以簡單理解為存儲代碼塊。
stack(棧)采用的是后進先出的規則,當函數執行的時候,會被添加到棧的頂部,當執行棧執行完成后,就會從棧頂移出,直到棧內被清空。同時存儲基本類型值。
Queue(任務隊列)可以叫做任務隊列或異步隊列,任務隊列里存放各種異步操作所注冊的回調,里面分為兩種任務類型,宏任務(macroTask
)和微任務(microTask
)。
MacroTask與MicroTask
在JavaScript
中,任務被分為兩種,一種宏任務(MacroTask
)也叫Task
,一種叫微任務(MicroTask
)。
MacroTask(宏任務)
script
全部代碼、setTimeout
、setInterval
、setImmediate
(瀏覽器暫時不支持,只有IE10支持,具體可見MDN
)、I/O
、UI Rendering
。
MicroTask(微任務)
Process.nextTick(Node獨有)
、Promise
、Object.observe(廢棄)
、MutationObserver
(具體使用方式查看這里)
案例解析
console.log('script start')
async function async1() {
await async2()
console.log('async1 end')
}
async function async2() {
console.log('async2 end')
}
async1()
setTimeout(function() {
console.log('setTimeout')
}, 0)
new Promise(resolve => {
console.log('Promise')
resolve()
})
.then(function() {
console.log('promise1')
})
.then(function() {
console.log('promise2')
})
console.log('script end')
詳細過程:
73以下版本
- 首先,打印
script start
,調用async1()
時,返回一個Promise
,所以打印出來async2 end
。 - 每個
await
,會新產生一個promise
,但這個過程本身是異步的,所以該await
后面不會立即調用。 - 繼續執行同步代碼,打印
Promise
和script end
,將then
函數放入微任務隊列中等待執行。 - 同步執行完成之后,檢查微任務隊列是否為
null
,然后按照先入先出規則,依次執行。 - 然后先執行打印
promise1
,此時then
的回調函數返回undefinde
,此時又有then
的鏈式調用,又放入微任務隊列中,再次打印promise2
。 - 再回到
await
的位置執行返回的Promise
的resolve
函數,這又會把resolve
丟到微任務隊列中,打印async1 end
。 - 當微任務隊列為空時,執行宏任務,打印
setTimeout
。
谷歌(金絲雀73版本)
- 如果傳遞給
await
的值已經是一個Promise
,那么這種優化避免了再次創建Promise
包裝器,在這種情況下,我們從最少三個microtick
到只有一個microtick
。 - 引擎不再需要為
await
創造throwaway Promise
- 在絕大部分時間。 - 現在
promise
指向了同一個Promise
,所以這個步驟什么也不需要做。然后引擎繼續像以前一樣,創建throwaway Promise
,安排PromiseReactionJob
在microtask
隊列的下一個tick
上恢復異步函數,暫停執行該函數,然后返回給調用者。
Async/Await 語法分析
async function f() {
await p
console.log('ok')
}
復制代碼
簡化理解為:
function f() {
return RESOLVE(p).then(() => {
console.log('ok')
})
}
復制代碼
- 如果
RESOLVE(p)
對於p
為promise
直接返回p
的話,那么p
的then
方法就會被馬上調用,其回調就立即進入job
隊列。 - 而如果
RESOLVE(p)
嚴格按照標准,應該是產生一個新的promise
,盡管該promise
確定會resolve
為p
,但這個過程本身是異步的,也就是現在進入job
隊列的是新promise
的resolve
過程,所以該promise
的then
不會被立即調用,而要等到當前job
隊列執行到前述resolve
過程才會被調用,然后其回調(也就是繼續await
之后的語句)才加入job
隊列,所以時序上就晚了。
案例分析
【面試題】734- 從一道面試題談談對 EventLoop 的理解 - 雲+社區 - 騰訊雲 (tencent.com)
NodeJS的Event Loop
Node
中的Event Loop
是基於libuv
實現的,而libuv
是 Node
的新跨平台抽象層,libuv使用異步,事件驅動的編程方式,核心是提供i/o
的事件循環和異步回調。libuv的API
包含有時間,非阻塞的網絡,異步文件操作,子進程等等。 Event Loop
就是在libuv
中實現的。
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
Node
的Event loop
一共分為6個階段,每個細節具體如下:
-
timers
: 執行setTimeout
和setInterval
中到期的callback
。 -
pending callback
: 上一輪循環中少數的callback
會放在這一階段執行。 -
idle, prepare
: 僅在內部使用。 -
poll
: 最重要的階段,執行pending callback
,在適當的情況下回阻塞在這個階段。 -
check
: 執行setImmediate
(setImmediate()
是將事件插入到事件隊列尾部,主線程和事件隊列的函數執行完成之后立即執行setImmediate
指定的回調函數)的callback
。 -
close callbacks
: 執行close
事件的callback
,例如socket.on('close'[,fn])
或者http.server.on('close, fn)
。 -
《Node 官方講解 Event Loop》:nodejs.org/zh-cn/docs/…
具體細節如下:
timers
執行setTimeout
和setInterval
中到期的callback
,執行這兩者回調需要設置一個毫秒數,理論上來說,應該是時間一到就立即執行callback回調,但是由於system
的調度可能會延時,達不到預期時間。
以下是官網文檔解釋的例子:
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
此階段執行某些系統操作(例如TCP錯誤類型)的回調。 例如,如果TCP socket ECONNREFUSED
在嘗試connect時receives,則某些* nix系統希望等待報告錯誤。 這將在pending callbacks
階段執行。
poll
該poll階段有兩個主要功能:
- 執行
I/O
回調。 - 處理輪詢隊列中的事件。
當事件循環進入poll
階段並且在timers
中沒有可以執行定時器時,將發生以下兩種情況之一
- 如果
poll
隊列不為空,則事件循環將遍歷其同步執行它們的callback
隊列,直到隊列為空,或者達到system-dependent
(系統相關限制)。
如果poll
隊列為空,則會發生以下兩種情況之一
- 如果有
setImmediate()
回調需要執行,則會立即停止執行poll
階段並進入執行check
階段以執行回調。 - 如果沒有
setImmediate()
回到需要執行,poll階段將等待callback
被添加到隊列中,然后立即執行。
當然設定了 timer 的話且 poll 隊列為空,則會判斷是否有 timer 超時,如果有的話會回到 timer 階段執行回調。
check
此階段允許人員在poll階段完成后立即執行回調。
如果poll
階段閑置並且script
已排隊setImmediate()
,則事件循環到達check階段執行而不是繼續等待。
setImmediate()
實際上是一個特殊的計時器,它在事件循環的一個單獨階段運行。它使用libuv API
來調度在poll
階段完成后執行的回調。
通常,當代碼被執行時,事件循環最終將達到poll
階段,它將等待傳入連接,請求等。
但是,如果已經調度了回調setImmediate()
,並且輪詢階段變為空閑,則它將結束並且到達check
階段,而不是等待poll
事件。
console.log('start')
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(() => {
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
Promise.resolve().then(function() {
console.log('promise3')
})
console.log('end')
復制代碼
如果node
版本為v11.x
, 其結果與瀏覽器一致。
start
end
promise3
timer1
promise1
timer2
promise2
復制代碼
具體詳情可以查看《又被node的eventloop坑了,這次是node的鍋》。
如果v10版本上述結果存在兩種情況:
- 如果time2定時器已經在執行隊列中了
start
end
promise3
timer1
timer2
promise1
promise2
復制代碼
- 如果time2定時器沒有在執行對列中,執行結果為
start
end
promise3
timer1
promise1
timer2
promise2
復制代碼
具體情況可以參考poll
階段的兩種情況。
從下圖可能更好理解:
setImmediate() 的setTimeout()的區別
setImmediate
和setTimeout()
是相似的,但根據它們被調用的時間以不同的方式表現。
setImmediate()
設計用於在當前poll
階段完成后check階段執行腳本 。setTimeout()
安排在經過最小(ms)后運行的腳本,在timers
階段執行。
舉個例子
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
復制代碼
執行定時器的順序將根據調用它們的上下文而有所不同。 如果從主模塊中調用兩者,那么時間將受到進程性能的限制。
其結果也不一致
如果在I / O
周期內移動兩個調用,則始終首先執行立即回調:
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
復制代碼
其結果可以確定一定是immediate => timeout
。
主要原因是在I/O階段
讀取文件后,事件循環會先進入poll
階段,發現有setImmediate
需要執行,會立即進入check
階段執行setImmediate
的回調。
然后再進入timers
階段,執行setTimeout
,打印timeout
。
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
復制代碼
Process.nextTick()
process.nextTick()
雖然它是異步API的一部分,但未在圖中顯示。這是因為process.nextTick()
從技術上講,它不是事件循環的一部分。
process.nextTick()
方法將callback
添加到next tick
隊列。 一旦當前事件輪詢隊列的任務全部完成,在next tick
隊列中的所有callbacks
會被依次調用。
換種理解方式:
- 當每個階段完成后,如果存在
nextTick
隊列,就會清空隊列中的所有回調函數,並且優先於其他microtask
執行。
例子
let bar;
setTimeout(() => {
console.log('setTimeout');
}, 0)
setImmediate(() => {
console.log('setImmediate');
})
function someAsyncApiCall(callback) {
process.nextTick(callback);
}
someAsyncApiCall(() => {
console.log('bar', bar); // 1
});
bar = 1;
復制代碼
在NodeV10中上述代碼執行可能有兩種答案,一種為:
bar 1
setTimeout
setImmediate
復制代碼
另一種為:
bar 1
setImmediate
setTimeout
復制代碼
無論哪種,始終都是先執行process.nextTick(callback)
,打印bar 1
。
作者:光光同學
鏈接:https://juejin.cn/post/6844903764202094606
來源:掘金
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。