你真的瞭解Event Loop(事件環)嗎?

Keely發表於2019-03-03

JS是單執行緒的

JavaScript語言最大特點就是單執行緒,但是這裡的單執行緒指的是主執行緒是單執行緒的。那為什麼js要單執行緒呢? 因為,JS主要用於操作DOM,如果是有兩個執行緒,一個在DOM上新增內容,一個在DOM上刪除內容,此時瀏覽器該以哪個為準呢? 所以為了避免複雜性,JavaScript從誕生起就是單執行緒的。

同步和非同步

同步和非同步關注的是訊息通知機制

  • 1)同步在發出呼叫後,沒有結果前是不返回的,一旦呼叫返回,就得到返回值。呼叫者會主動等待這個呼叫結果。
  • 2)非同步是發出呼叫後,呼叫者不會立刻得到結果,而是被呼叫者通過狀態或回撥函式來處理這個呼叫。

任務佇列

  • 因為JavaScript是單執行緒的。就意味著所有任務都需要排隊,前一個任務結束,後一個任務才能執行。前一個任務耗時很長,後一個任務也得一直等著。但是IO裝置(比如ajax網路請求)很慢,CPU一直初一顯得狀態,這樣就很不合理了。
  • 所以,其實主執行緒完全可以不管IO裝置,掛起處於等待中的任務,先執行排在後面的任務。等到IO裝置返回了結果,再回過頭,把掛起的任務繼續執行下去。於是有了同步任務非同步任務

同步任務是指在主執行緒上執行的任務,只有前一個任務執行完畢,下一個任務才能執行。 非同步任務是指不進入主執行緒,而是進入任務佇列(task queue)的任務,只有主執行緒任務執行完畢,任務佇列的任務才會進入主執行緒執行。

瀏覽器中Event Loop

event loop

從上圖看到:

  1. 主執行緒執行的時候產生堆(heap)和棧(stack)
  2. 棧中的程式碼呼叫各種外部API,它們在"任務佇列"中加入各種事件(click,load,done)
  3. 只要棧中的程式碼執行完畢,主執行緒就會去讀取"任務佇列",將佇列中的事件放到執行棧中依次執行。
  4. 主執行緒繼續執行,當再呼叫外部API時又加入到任務佇列中,等主執行緒執行完畢又會接著將任務佇列中的事件放到主執行緒中。
  5. 上面整個過程是迴圈不斷的。

Node 的 Event Loop

Node.js也是單執行緒的Event Loop,但是它的執行機制不同於瀏覽器環境。

node的event loop

根據上圖,Node.js的執行機制如下:

  1. 寫的JavaScript指令碼會交給V8引擎解析
  2. 解析後的程式碼,呼叫Node API,Node會交給Libuv庫處理
  3. Libuv庫將不同的任務分配給不同的執行緒,形成一個Event Loop(事件迴圈),以非同步的方式將任務的執行結果返回給V8引擎
  4. V8引擎再將結果返回給使用者

除了setTimeoutsetInterval這兩個方法,Node.js還提供了另外兩個與"任務佇列"有關的方法:process.nextTicksetImmediate

process.nextTick方法可以在當前"執行棧"的尾部----下一次Event Loop(主執行緒讀取"任務佇列")之前----觸發回撥函式。也就是說,它指定的任務總是發生在所有非同步任務之前。setImmediate方法則是在當前"任務佇列"的尾部新增事件,也就是說,它指定的任務總是在下一次Event Loop時執行,這與setTimeout(fn, 0)很像。

英文原文: When Node.js starts, it initializes the event loop, processes the provided input script (or drops into the REPL, which is not covered in this document) which may make async API calls, schedule timers, or call process.nextTick(), then begins processing the event loop.

當Node.js啟動時會初始化event loop, 每一個event loop都會包含按如下順序六個迴圈階段

   ┌───────────────────────┐
┌─>│        timers         │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     I/O callbacks     │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
│  │     idle, prepare     │
│  └──────────┬────────────┘      ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │
│  │         poll          │<─────┤  connections, │
│  └──────────┬────────────┘      │   data, etc.  │
│  ┌──────────┴────────────┐      └───────────────┘
│  │        check          │
│  └──────────┬────────────┘
│  ┌──────────┴────────────┐
└──┤    close callbacks    │
   └───────────────────────┘
複製程式碼
  • timers 階段: 這個階段執行setTimeout(callback) and setInterval(callback)預定的callback;
  • I/O callbacks 階段: 執行除了 close事件的callbacks、被timers(定時器,setTimeout、setInterval等)設定的callbacks、setImmediate()設定的callbacks之外的callbacks;
  • idle, prepare 階段: 僅node內部使用;
  • poll 階段: 獲取新的I/O事件, 適當的條件下node將阻塞在這裡;
  • check 階段: 執行setImmediate() 設定的callbacks;
  • close callbacks 階段: 比如socket.on(‘close’, callback)的callback會在這個階段執行.

每一個階段都有一個裝有callbacks的fifo queue(佇列),當event loop執行到一個指定階段時,node將執行該階段的fifo queue(佇列),當佇列callback執行完或者執行callbacks數量超過該階段的上限時,event loop會轉入下一下階段. **注意上面六個階段都不包括 process.nextTick()。**process.nextTick()不在event loop的任何階段執行,而是在各個階段切換的中間執行,即從一個階段切換到下個階段前執行。

巨集任務和微任務

任務可分為巨集任務和微任務

常見的巨集任務和微任務:

  1. macro-task(巨集任務): setTimeout, setInterval, setImmediate, I/O
  2. micro-task(微任務):process.nextTick, 原生Promise(有些實現的promisethen方法放到了巨集任務中),Object.observe(已廢棄), MutationObserver

看下面的例子:

console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');
複製程式碼

上面程式碼的執行順序是什麼呢?這是為什麼呢?

script start
script end
promise1
promise2
setTimeout
複製程式碼

Task 是嚴格按照時間順序壓棧和執行的,所以瀏覽器能夠使得 JavaScript 內部任務與 DOM 任務能夠有序的執行。當一個 task 執行結束後,在下一個 task 執行開始前,瀏覽器可以對頁面進行重新渲染。每一個 task 都是需要分配的,例如從使用者的點選操作到一個點選事件,渲染HTML文件,同時還有上面例子中的 setTimeout

基於前面描述的event loopsetTimeout 它會在延遲時間結束後分配一個新的 taskevent loop 中,而不是立即執行,所以 setTimeout 的回撥函式會等待前面的 task 都執行結束後再執行。這就是為什麼 setTimeout 會輸出在 script end 之後,因為 script end 是第一個 task 的其中一部分,而 setTimeout 則是一個新的 task

微任務通常來說就是需要在當前 task 執行結束後立即執行的任務,例如需要對一系列的任務做出迴應,或者是需要非同步的執行任務而又不需要分配一個新的 task,這樣便可以減小一點效能的開銷。

微任務任務佇列是一個與 task 任務佇列相互獨立的佇列,微任務將會在每一個 task 任務執行結束之後執行。每一個 task 中產生的 微任務 都將會新增到 微任務 佇列中,微任務 中產生的 微任務 將會新增至當前佇列的尾部,並且 微任務 會按序的處理完佇列中的所有任務。

  每當一個 Promise 被決議(或是被拒絕),便會將其回撥函式新增至 微任務佇列中作為一個新的 微任務。這也保證了 Promise 可以非同步的執行。所以當我們呼叫 .then(resolve, reject)的時候,會立即生成一個新的 微任務新增至佇列中,這就是為什麼上面的 promise1promise2 會輸出在 script end 之後,因為 微任務佇列中的任務必須等待當前 task 執行結束後再執行,而 promise1promise2 輸出在 setTimeout 之前,這是因為 setTimeout 是一個新的 task,而 微任務執行在當前 task 結束之後,下一個 task 開始之前。

參考:

  1. The Node.js Event Loop, Timers, and process.nextTick()
  2. JavaScript 執行機制詳解:再談Event Loop
  3. The Node.js Event Loop, Timers, and process.nextTick()
  4. 深入理解 JavaScript 事件迴圈(二)— task and microtask

相關文章