詳解JS執行機制和Event Loop

whynotgonow發表於2018-01-22

1 JS執行機制詳解

1.1 單執行緒的JS

javascript是一門單執行緒語言,在最新的HTML5中提出了Web-Worker,但javascript是單執行緒這一核心仍未改變。所以一切javascript版的"多執行緒"都是用單執行緒模擬出來的,一切javascript多執行緒都是紙老虎!

1.2 Event Loop

既然js是單執行緒,後一個任務會等前一個任務執行完成後才會執行,如果前一個任務執行時間過長後面的任務一直得不到執行,就會引起阻塞。那麼問題來了,假如我們想瀏覽新聞,但是新聞包含的超清圖片載入很慢,難道我們的網頁要一直卡著直到圖片完全顯示出來?因此我們會將任務分為兩類:

  • 同步任務
  • 非同步任務

當我們開啟網站時,網頁的渲染過程就是一大堆同步任務,比如頁面骨架和頁面元素的渲染。而像載入圖片音樂之類佔用資源大耗時久的任務,就是非同步任務。具體邏輯見下面的導圖:

js執行機制

文字描述

  • 同步和非同步任務分別進入不同的執行"場所",同步的進入主執行緒,非同步的進入Event Table並註冊函式。
  • 當指定的事情完成時,Event Table會將這個函式移入Event Queue。
  • 主執行緒內的任務執行完畢為空,會去Event Queue讀取對應的函式,進入主執行緒執行。
  • 上述過程會不斷重複,也就是常說的Event Loop(事件迴圈)。準確的講,event loop是實現非同步的一種機制。

上圖中Event Queue 包括 macro task queue 和 micro task queue,下一小節我們會詳細解釋一下。 上程式碼我們體會一下這個流程:

console.log('1');
setTimeout(function () {
    console.log('timeout');
});
console.log('2');
複製程式碼

上面的程式碼解釋

  • console.log('1');console.log('2');是同步任務會放到主執行緒中,setTimeout宣告的回撥函式會放到Event Table。主執行緒內的任務(console.log('1');console.log('2');)執行完畢為空,會去Event Queue讀取console.log('timeout');,進入主執行緒執行。所以執行的結果為1 2 timeout

1.3 Evnet Loop 中的macro task 和 micro task

1.3.1 定義

  • macro-task(巨集任務):包括整體程式碼script,setTimeout,setInterval, setImmediate(node環境下)。
  • micro-task(微任務):Promise,process.nextTick

下面兩張圖為Event Loop 和 macro-task 及 micro-task的關係

event loop & macro task & micro task1

event loop & macro task & micro task2

導圖解釋

  • 不同型別的任務會進入對應的Event Queue,比如setTimeout和setInterval會進入macro task Queue, Promise 會進入 micro task Queue。
  • 事件迴圈的順序,決定js程式碼的執行順序。
  • 進入整體程式碼(巨集任務)後,開始第一次迴圈。接著執行所有的微任務。然後再次從巨集任務開始,找到其中一個任務佇列執行完畢,再執行所有的微任務。

1.3.2 e.g.

看到這麼多的定義和導圖,我們來段程式碼屢一下:

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')
})
複製程式碼
  • 整體script作為第一個巨集任務進入主執行緒,遇到console.log,輸出1。
  • 遇到setTimeout,其回撥函式被分發到 macro task Queue中。
  • 遇到process.nextTick(),其回撥函式被分發到micro task Queue中。我們記為process1。
  • 遇到Promise,new Promise直接執行,輸出7。then被分發到micro task Queue中。我們記為then1。
macro task Queue macro task Queue
setTimeout process1
- then1
  • 我們發現了process1和then1兩個微任務。
  • 執行process1,輸出6。
  • 執行then1,輸出8。
  • 好了,第一輪事件迴圈正式結束,這一輪的結果是輸出1,7,6,8。那麼第二輪時間迴圈從setTimeout巨集任務開始:
  • 遇到console.log,輸出2。
  • 遇到process.nextTick(),同樣將其分發到micro task Queue中,記為process2。new Promise立即執行輸出4,then也分發到macro task Queue中,記為then2。
macro task Queue macro task Queue
- process2
- then2
  • 我們發現了process2和then2兩個微任務。
  • 執行process2,輸出3。
  • 執行then2,輸出5。
  • 好了,第一輪事件迴圈正式結束,這一輪的結果是輸出2,4,3,5。迴圈結束。最終的結果為1 7 6 8 2 4 3 5

1.4 總結

  • javascript是一門單執行緒語言
  • 事件迴圈是js實現非同步的一種方法,也是js的執行機制。

2 Node中的Event Loop

2.1 node中Event Loop執行順序

2.1.1 node中Event Loop的執行順序的簡單介紹

下圖為node中Event Loop的執行順序的簡略圖

node中Event Loop執行順序的簡略圖

note

  • timers: 執行被setTimeout() 和 setInterval()註冊的回撥函式.
  • I/O callbacks: 執行除了 close事件的回撥、 被 timers和setImmediate()註冊的回撥.
  • idle, prepare: node內部執行
  • poll: 輪詢獲取新的 I/O 事件; node有可能會在這個地方阻塞.
  • check: 在這裡呼叫setImmediate() 註冊的回撥.
  • close: 執行close事件的回撥

2.1.2 詳解poll階段

1.poll階段的功能

  • 執行剛剛過期的計時器的指令碼。
  • 在輪詢佇列中處理事件。

2.poll階段的處理流程

下面我用if else的方式描述一下poll階段的處理邏輯,如下:


if ('事件迴圈進入到 poll 階段 ' && '沒有timers註冊的scripts') {
    if ('poll 佇列 不為空') {
        console.log('迴圈遍歷它的回撥佇列,以同步執行它們,直到佇列耗盡,或者達到系統依賴的最大值');
    } else {
        if ('存在setImmediate()註冊的scripts') {
            console.log('結束poll phase 進入到check phase 執行這些註冊的scripts');
        } else {
            console.log('事件迴圈將等待被新增到佇列中的回撥,然後立即執行它們');
        }
    }
}
console.log('一旦輪詢佇列為空,事件迴圈將檢查有無到期的計時器。如果有一個或多個計時器準備就緒,事件迴圈將返回到計時器階段,以執行這些計時器的回撥。');
複製程式碼

3.比較setImmediate() 和 setTimeout()

setImmediate()setTimeout()很相似的,它們何時被呼叫,決定了它們的行為方式的不同。

  • setImmediate 用於在當前輪詢階段完成後執行指令碼
  • setTimeout用於把註冊的指令碼在最小閾值結束後執行。

它們執行的順序將根據呼叫它們的上下文而變化。如果兩個都是從主模組中呼叫,那麼它們將受到程式效能的約束(這可能會受到其他應用程式的影響)。

例如,如果我們執行的指令碼不是在I/O迴圈中(即主模組),那麼執行兩個定時器的順序是不確定的,因為它受過程效能的約束:

setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});
// 列印結果的先後順序是不確定的,有時`timeout`在前,有時'immediate'在前
複製程式碼

但是,如果把這段程式碼放到I/O迴圈的回撥中,immediate總是先被列印出來,如下:

const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});
// 在一個I/O週期內,在任何計時器的情況下,setImmediate的回撥,因為在一個I/O週期內,I/O callback 的下一個階段為setImmediate的回撥。
複製程式碼

2.2 node的Event Loop實現

如下圖:

node中的EventLoop

說明

    1. Node的Event Loop分階段,階段有先後,依次是:
    • expired timers and intervals,即到期的setTimeout/setInterval
    • I/O events,包含檔案,網路等等
    • immediates,通過setImmediate註冊的函式
    • close handlers,close事件的回撥,比如TCP連線斷開
    1. 同步任務及每個階段之後都會清空microtask佇列
    • 優先清空next tick queue,即通過process.nextTick註冊的函式
    • 再清空other queue,常見的如Promise
    1. node會清空當前所處階段的佇列,即執行所有task

我們在回頭看一下,下面的程式碼:

setTimeout(() => {
  console.log('timeout');
}, 0);

setImmediate(() => {
  console.log('immediate');
});
複製程式碼

可以看出由於兩個setTimeout延時相同,被合併入了同一個expired timers queue,而一起執行了。所以,只要將第二個setTimeout的延時改成超過2ms(1ms無效,因為最小間隔為1s),就可以保證這兩個setTimeout不會同時過期,也能夠保證輸出結果的一致性。

我們在回頭看一下,上面提到的另外一段程式碼:

const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});
複製程式碼

為何這樣的程式碼能保證setImmediate的回撥優先於setTimeout的回撥執行呢?因為當兩個回撥同時註冊成功後,當前node的Event Loop正處於I/O queue階段,而下一個階段是immediates queue,所以能夠保證即使setTimeout已經到期,也會在setImmediate的回撥之後執行。

3 補充

由於水平有限,理解的程度可能會有偏差,歡迎大家指正。

4 參考文章

相關文章