面試一定會問到的-js事件迴圈

小黎也發表於2019-10-16

最近在看js事件迴圈,事件迴圈是js執行的核心,js 是單執行緒的, js 的非同步事件就是依賴於事件迴圈機制,網上找了些資料,發現騰訊雲這篇 js事件迴圈 寫的很詳細,下文基於這一篇文章,外加上自己的一些總結。

事件迴圈

首先,我們來解釋下事件迴圈是個什麼東西: 就我們所知,瀏覽器的js是單執行緒的,也就是說,在同一時刻,最多也只有一個程式碼段在執行,可是瀏覽器又能很好的處理非同步請求,那麼到底是為什麼呢?

關於執行中的執行緒:

  • 主執行緒:也就是 js 引擎執行的執行緒,這個執行緒只有一個,頁面渲染、函式處理都在這個主執行緒上執行。
  • 工作執行緒:也稱幕後執行緒,這個執行緒可能存在於瀏覽器或js引擎內,與主執行緒是分開的,處理檔案讀取、網路請求等非同步事件。

我們來看一張圖(這張圖來自於http://www.zcfy.cc/article/node-js-at-scale-understanding-the-node-js-event-loop-risingstack-1652.html)

面試一定會問到的-js事件迴圈

從上圖我們可以看出,js主執行緒它是有一個執行棧的,所有的js程式碼都會在執行棧裡執行。我們看看瀏覽器上的執行棧

面試一定會問到的-js事件迴圈

在執行程式碼過程中,如果遇到一些非同步程式碼(比如setTimeout,ajax,promise.then以及使用者點選等操作),那麼瀏覽器就會將這些程式碼放到另一個執行緒(在這裡我們叫做幕後執行緒)中去執行,在前端由瀏覽器底層執行,在 node 端由 libuv 執行,這個執行緒的執行不阻塞主執行緒的執行,主執行緒繼續執行棧中剩餘的程式碼。

當幕後執行緒(background thread)裡的程式碼執行完成後(比如setTimeout時間到了,ajax請求得到響應),該執行緒就會將它的回撥函式放到任務佇列(又稱作事件佇列、訊息佇列)中等待執行。而當主執行緒執行完棧中的所有程式碼後,它就會檢查任務佇列是否有任務要執行,如果有任務要執行的話,那麼就將該任務放到執行棧中執行。如果當前任務佇列為空的話,它就會一直迴圈等待任務到來。因此,這叫做事件迴圈。

任務佇列

那麼,問題來了。如果任務佇列中,有很多個任務的話,那麼要先執行哪一個任務呢? 其實(正如上圖所示),js是有兩個任務佇列的,一個叫做 Macrotask Queue(Task Queue) 大任務, 一個叫做 Microtask Queue 小任務

Macrotask 常見的任務:

  • setTimeout
  • setInterval
  • setImmediate
  • I/O
  • 使用者互動操作,UI渲染

Microtask 常見的任務:

  • Promise(重點)
  • process.nextTick(nodejs)
  • Object.observe(不推薦使用)

那麼,兩者有什麼具體的區別呢?或者說,如果兩種任務同時出現的話,應該選擇哪一個呢?

其實事件迴圈執行流程如下:

  1. 檢查 Macrotask 佇列是否為空,若不為空,則進行下一步,若為空,則跳到3
  2. 從 Macrotask 佇列中取隊首(在佇列時間最長)的任務進去執行棧中執行(僅僅一個),執行完後進入下一步
  3. 檢查 Microtask 佇列是否為空,若不為空,則進入下一步,否則,跳到1(開始新的事件迴圈)
  4. 從 Microtask 佇列中取隊首(在佇列時間最長)的任務進去事件佇列執行,執行完後,跳到3 其中,在執行程式碼過程中新增的microtask任務會在當前事件迴圈週期內執行,而新增的macrotask任務只能等到下一個事件迴圈才能執行了。

簡而言之,一次事件迴圈只執行處於 Macrotask 隊首的任務,執行完成後,立即執行 Microtask 佇列中的所有任務。

我們先來看一段程式碼

 console.log(1)
setTimeout(function() {
  //settimeout1
  console.log(2)
}, 0);
const intervalId = setInterval(function() {
  //setinterval1
  console.log(3)
}, 0)
setTimeout(function() {
  //settimeout2
  console.log(10)
  new Promise(function(resolve) {
    //promise1
    console.log(11)
    resolve()
  })
  .then(function() {
    console.log(12)
  })
  .then(function() {
    console.log(13)
    clearInterval(intervalId)
  })
}, 0);

//promise2
Promise.resolve()
  .then(function() {
    console.log(7)
  })
  .then(function() {
    console.log(8)
  })
console.log(9)
複製程式碼

你覺得結果應該是什麼呢? 我在node環境和chrome控制檯輸出的結果如下:

1
9
7
8
2
3
10
11
12
13
複製程式碼

在上面的例子中

  • 第一次事件迴圈:
  1. console.log(1)被執行,輸出1
  2. settimeout1執行,加入macrotask佇列
  3. setinterval1執行,加入macrotask佇列
  4. settimeout2執行,加入macrotask佇列
  5. promise2執行,它的兩個then函式加入microtask佇列
  6. console.log(9)執行,輸出9
  7. 根據事件迴圈的定義,接下來會執行新增的microtask任務,按照進入佇列的順序,執行console.log(7)和console.log(8),輸出7和8 microtask佇列為空,回到第一步,進入下一個事件迴圈,此時macrotask佇列為: settimeout1,setinterval1,settimeout2
  • 第二次事件迴圈:

從macrotask佇列裡取位於隊首的任務(settimeout1)並執行,輸出2 microtask佇列為空,回到第一步,進入下一個事件迴圈,此時macrotask佇列為: setinterval1,settimeout2

  • 第三次事件迴圈:

從macrotask佇列裡取位於隊首的任務(setinterval1)並執行,輸出3,然後又將新生成的setinterval1加入macrotask佇列 microtask佇列為空,回到第一步,進入下一個事件迴圈,此時macrotask佇列為: settimeout2,setinterval1

  • 第四次事件迴圈:

從macrotask佇列裡取位於隊首的任務(settimeout2)並執行,輸出10,並且執行new Promise內的函式(new Promise內的函式是同步操作,並不是非同步操作),輸出11,並且將它的兩個then函式加入microtask佇列 從microtask佇列中,取隊首的任務執行,直到為空為止。因此,兩個新增的microtask任務按順序執行,輸出12和13,並且將setinterval1清空。

此時,microtask佇列和macrotask佇列都為空,瀏覽器會一直檢查佇列是否為空,等待新的任務加入佇列。 在這裡,大家可以會想,在第一次迴圈中,為什麼不是macrotask先執行?因為按照流程的話,不應該是先檢查macrotask佇列是否為空,再檢查microtask佇列嗎?

原因:因為一開始js主執行緒中跑的任務就是macrotask任務,而根據事件迴圈的流程,一次事件迴圈只會執行一個macrotask任務,因此,執行完主執行緒的程式碼後,它就去從microtask佇列裡取隊首任務來執行。

注意: 由於在執行microtask任務的時候,只有當microtask佇列為空的時候,它才會進入下一個事件迴圈,因此,如果它源源不斷地產生新的microtask任務,就會導致主執行緒一直在執行microtask任務,而沒有辦法執行macrotask任務,這樣我們就無法進行UI渲染/IO操作/ajax請求了,因此,我們應該避免這種情況發生。在nodejs裡的process.nexttick裡,就可以設定最大的呼叫次數,以此來防止阻塞主執行緒。

async/await 又是如何處理的呢 ?

大家看看這段程式碼在瀏覽器上的輸出是什麼?

async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async1 end');
}
async function async2() {
    console.log('async2');
}
async1();
new Promise(function(resolve) {
    console.log('promise1');
    resolve();
}).then(function() {
    console.log('promise2');
});
console.log('script end');
複製程式碼

這段程式碼多了 async/await 只要我們弄懂這個非同步處理的原理,就可以知道它們的執行順序了。

async/await: 這哥倆個其實是 Promise 和 Generator 的語法糖,所以我們把它們轉成我們熟悉的 Promise

async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async1 end');
}
// 其實就是
async function async1() {
    console.log('async1 start');
    Promise.resolve(async2()).then(()=>console.log('async1 end'))
}
複製程式碼

那我們在看看轉換後的整體程式碼

async function async1() {
    console.log('async1 start');
    Promise.resolve(async2()).then(()=>console.log('async1 end'))
}
async function async2() {
    console.log('async2');
}
async1();
new Promise(function(resolve) {
    console.log('promise1');
    resolve();
}).then(function() {
    console.log('promise2');
});
console.log('script end');
複製程式碼

這下就很明白了吧,輸出的結果如下

/** 
 * async1 start
 * async2
 * promise1
 * script end
 * async1 end
 * promise2
 * */
複製程式碼

定時器問題

以此,我們來引入一個新的問題,定時器的問題。定時器是否是真實可靠的呢?比如我執行一個命令:setTimeout(task, 100),他是否就能準確的在100毫秒後執行呢?其實根據以上的討論,我們就可以得知,這是不可能的。

我們看這個栗子

const s = new Date().getSeconds();

setTimeout(function() {
  // 輸出 "2",表示回撥函式並沒有在 500 毫秒之後立即執行
  console.log("Ran after " + (new Date().getSeconds() - s) + " seconds");
}, 500);

while(true) {
  if(new Date().getSeconds() - s >= 2) {
    console.log("Good, looped for 2 seconds");
    break;
  }
}
複製程式碼

如果不知道事件迴圈機制,那麼想當然就認為 setTimeout 中的事件會在 500 毫秒後執行,但實際上是在 2 秒後才執行,原因大家應該都知道了,主執行緒一直有任務在執行,直到 2 秒後,主執行緒中的任務才執行完成,這才去執行 macrotask 中的 setTimeout 回撥任務。

因為你執行 setTimeout(task,100) 後,其實只是確保這個任務,會在100毫秒後進入macrotask佇列,但並不意味著他能立刻執行,可能當前主執行緒正在進行一個耗時的操作,也可能目前microtask佇列有很多個任務,所以用 setTimeout 作為倒數計時其實並不會保證準確。

阻塞還是非阻塞

關於 js 阻塞還是非阻塞的問題,我覺得可以這麼理解,不夠在這之前,我們先理解下同步、非同步、阻塞還是非阻塞的解釋,在網上看到一段描述的非常好,引用下

同步阻塞:小明一直盯著下載進度條,到 100% 的時候就完成。

同步非阻塞:小明提交下載任務後就去幹別的,每過一段時間就去瞄一眼進度條,看到 100% 就完成。(輪詢)

非同步阻塞:小明換了個有下載完成通知功能的軟體,下載完成就“叮”一聲。不過小明仍然一直等待“叮”的聲音(看起來很傻,不是嗎最蠢)

非同步非阻塞:仍然是那個會“叮”一聲的下載軟體,小明提交下載任務後就去幹別的,聽到“叮”的一聲就知道完成了。(最機智)

我們的解釋:

  1. js 核心還是同步阻塞的,比如看這段程式碼
while (true) {
    if (new Date().getSeconds() - s >= 2) {
        console.log("Good, looped for 2 seconds");
        break;
    }
}
console.log('end')
複製程式碼

console.log('end') 的執行需要在 while 迴圈結束後才能執行,如果迴圈一直沒結束,那麼執行緒就被阻塞了。

  1. 而對於 js 的非同步事件,因為有事件迴圈機制,非同步事件就是由事件驅動非同步非阻塞的,上面的栗子已經很好證明了。 所以 nodejs 適合處理大併發,因為有事件迴圈和任務佇列機制,非同步操作都由工作程式處理(libuv),js 主執行緒可以繼續處理新的請求。 缺點也很明顯,因為是單執行緒,所以對計算密集型的就會比較吃力,不過可以通過叢集的模式解決這個問題。

參考連結

www.zcfy.cc/article/nod…

cloud.tencent.com/developer/a…

developer.mozilla.org/zh-CN/docs/…

相關文章