Event Loop


JavaScript是一門單線程的非阻塞腳本語言,Event Loop就是為了解決JavaScript異步編程的一種解決方案。

第一個問題:JavaScript的誕生就是為了處理瀏覽器網頁的交互(DOM操作的處理、UI動畫等), 設計成單線程的原因就是不想讓瀏覽器變得太復雜,因為多線程需要共享資源、且有可能修改彼此的運行結果(兩個線程修改了同一個DOM節點就會產生不必要的麻煩),這對於一種網頁腳本語言來說這就太復雜了。

第二個問題:JavaScript是單線程的但它所運行的宿主環境—瀏覽器是多線程,瀏覽器提供了各種線程供Event Loop調度來協調JS單線程運行時不會阻塞。

小結

代碼執行開啟一個全局調用棧(主棧)提供代碼運行的環境,在執行過程中同步任務的代碼立即執行,遇到異步任務根據這個異步事件的類型,將異步的回調注冊添加到對應的宏任務隊列或者微任務隊列中。並且在當前執行棧為空的時候,主線程會查看微任務隊列是否有事件存在。如果不存在,那么再去宏任務隊列中取出一個事件並把對應的回到加入當前執行棧;如果存在,則會依次執行隊列中事件對應的回調,直到微任務隊列為空,然后去宏任務隊列中取出最前面的一個事件,把對應的回調加入當前執行棧...如此反復,進入循環。

當前執行棧執行完畢時會立刻先處理所有微任務隊列中的事件,然后再去宏任務隊列中取出一個事件。同一次事件循環中,微任務永遠在宏任務之前執行

瀏覽器中的Event Loop

Javascript 有一個 main thread 主線程和 call-stack 調用棧(執行棧),所有的任務都會被放到調用棧等待主線程執行。

同步任務和異步任務

Javascript單線程任務被分為同步任務異步任務,同步任務會在調用棧中按照順序等待主線程依次執行,異步任務會在異步任務有了結果后,將注冊的回調函數放入任務隊列中等待主線程空閑的時候(調用棧被清空),被讀取到棧內等待主線程的執行。

img

Heap(堆), 動態分配的內存,大小不定也不會自動釋放,存放引用類型,指那些可能由多個值構成的對象,保存在堆內存中,包含引用類型的變量,實際上保存的不是變量本身,而是指向該對象的指針。可以簡單理解為存儲代碼塊。

stack(棧)采用的是后進先出的規則,當函數執行的時候,會被添加到棧的頂部,當執行棧執行完成后,就會從棧頂移出,直到棧內被清空。同時存儲基本類型值。

Queue(任務隊列)可以叫做任務隊列異步隊列,任務隊列里存放各種異步操作所注冊的回調,里面分為兩種任務類型,宏任務(macroTask)和微任務(microTask)。

MacroTask與MicroTask

JavaScript中,任務被分為兩種,一種宏任務(MacroTask)也叫Task,一種叫微任務(MicroTask)。

MacroTask(宏任務)

  • script全部代碼、setTimeoutsetIntervalsetImmediate(瀏覽器暫時不支持,只有IE10支持,具體可見MDN)、I/OUI Rendering

MicroTask(微任務)

  • Process.nextTick(Node獨有)PromiseObject.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后面不會立即調用。
  • 繼續執行同步代碼,打印Promisescript end,將then函數放入微任務隊列中等待執行。
  • 同步執行完成之后,檢查微任務隊列是否為null,然后按照先入先出規則,依次執行。
  • 然后先執行打印promise1,此時then的回調函數返回undefinde,此時又有then的鏈式調用,又放入微任務隊列中,再次打印promise2
  • 再回到await的位置執行返回的 Promiseresolve 函數,這又會把 resolve 丟到微任務隊列中,打印async1 end
  • 微任務隊列為空時,執行宏任務,打印setTimeout

谷歌(金絲雀73版本)

  • 如果傳遞給 await 的值已經是一個 Promise,那么這種優化避免了再次創建 Promise 包裝器,在這種情況下,我們從最少三個 microtick 到只有一個 microtick
  • 引擎不再需要為 await 創造 throwaway Promise - 在絕大部分時間。
  • 現在 promise 指向了同一個 Promise,所以這個步驟什么也不需要做。然后引擎繼續像以前一樣,創建 throwaway Promise,安排 PromiseReactionJobmicrotask 隊列的下一個 tick 上恢復異步函數,暫停執行該函數,然后返回給調用者。

Async/Await 語法分析

async function f() {
  await p
  console.log('ok')
}
復制代碼

簡化理解為:

function f() {
  return RESOLVE(p).then(() => {
    console.log('ok')
  })
}
復制代碼
  • 如果 RESOLVE(p) 對於 ppromise 直接返回 p 的話,那么 pthen 方法就會被馬上調用,其回調就立即進入 job 隊列。
  • 而如果 RESOLVE(p) 嚴格按照標准,應該是產生一個新的 promise,盡管該 promise確定會 resolvep,但這個過程本身是異步的,也就是現在進入 job 隊列的是新 promiseresolve過程,所以該 promisethen 不會被立即調用,而要等到當前 job 隊列執行到前述 resolve 過程才會被調用,然后其回調(也就是繼續 await 之后的語句)才加入 job 隊列,所以時序上就晚了。

案例分析

【面試題】734- 從一道面試題談談對 EventLoop 的理解 - 雲+社區 - 騰訊雲 (tencent.com)

NodeJS的Event Loop

img

Node中的Event Loop是基於libuv實現的,而libuvNode 的新跨平台抽象層,libuv使用異步,事件驅動的編程方式,核心是提供i/o的事件循環和異步回調。libuv的API包含有時間,非阻塞的網絡,異步文件操作,子進程等等。 Event Loop就是在libuv中實現的。

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

NodeEvent loop一共分為6個階段,每個細節具體如下:

  • timers: 執行setTimeoutsetInterval中到期的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

執行setTimeoutsetInterval中到期的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毫秒。

以下是我測試時間:

img

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階段的兩種情況。

從下圖可能更好理解:

img

setImmediate() 的setTimeout()的區別

setImmediatesetTimeout()是相似的,但根據它們被調用的時間以不同的方式表現。

  • 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
來源:掘金
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出處。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM