js中的事件循環(Event Loop)機制


一,關於線程

javascript從誕生之日起就是一門單線程的非阻塞的腳本語言。這是由其最初的用途來決定的:與瀏覽器交互。

單線程意味着,javascript代碼在執行的任何時候,都只有一個主線程來處理所有的任務。

而非阻塞則是當代碼需要進行一項異步任務(無法立刻返回結果,需要花一定時間才能返回的任務,如I/O事件)的時候,主線程會掛起(pending)這個任務,然后在異步任務返回結果的時候再根據一定規則去執行相應的回調。

JavaScript 的單線程,與它的用途有關。作為瀏覽器腳本語言,JavaScript 的主要用途是與用戶互動,以及操作 DOM。這決定了它只能是單線程,否則會帶來很復雜的同步問題。比如,假定 JavaScript 同時有兩個線程,一個線程在某個 DOM 節點上添加內容,另一個線程刪除了這個節點,這時瀏覽器應該以哪個線程為准?

所以,為了避免復雜性,從一誕生,JavaScript 就是單線程,這已經成了這門語言的核心特征,將來也不會改變。

為了利用多核 CPU 的計算能力,HTML5 提出 Web Worker 標准。允許 JavaScript 腳本創建多個線程,但是子線程完全受主線程控制,且不得操作 DOM。所以,這個新標准並沒有改變 JavaScript 單線程的本質。

二,瀏覽器環境下的事件循環機制

1,任務隊列

js引擎遇到一個異步事件后並不會一直等待其返回結果,而是會將這個事件掛起,繼續執行執行棧中的其他任務。當一個異步事件返回結果后,js會將這個事件加入與當前執行棧不同的另一個隊列,我們稱之為任務隊列。被放入任務隊列不會立刻執行其回調,而是等待當前執行棧中的所有任務都執行完畢, 主線程處於閑置狀態時,主線程會去查找事件隊列是否有任務。如果有,那么主線程會從中取出排在第一位的事件,並把這個事件對應的回調放入執行棧中,然后執行其中的同步代碼...,如此反復,這樣就形成了一個無限的循環。這就是這個過程被稱為“事件循環(Event Loop)”的原因。

任務隊列本質:

- 所有同步任務都在主線程上執行,形成一個**執行棧**(execution context stack)。
- 主線程之外,還存在一個”**任務隊列**”(task queue)。只要異步任務有了運行結果,就在”任務隊列”之中放置一個事件。
- 一旦”執行棧”中的所有同步任務執行完畢,系統就會讀取”任務隊列”,看看里面有哪些事件。那些對應的異步任務,於是結束等待狀態,進入執行棧,開始執行。
- 主線程不斷重復上面的第三步。

下圖中的stack表示我們所說的執行棧,web apis則是代表一些異步事件,而callback queue即任務隊列

 

 

 

 

2,宏任務與微任務

以上的事件循環過程是一個宏觀的表述,實際上因為異步任務之間並不相同,因此他們的執行優先級也有區別。不同的異步任務被分為兩類:微任務(micro task)和宏任務(macro task)。

## 宏任務

(macro)task(又稱之為宏任務),可以理解是每次執行棧執行的代碼就是一個宏任務(包括每次從事件隊列中獲取一個事件回調並放到執行棧中執行)。

瀏覽器為了能夠使得 JS 內部(macro)task 與 DOM 任務能夠有序的執行,**會在一個(macro)task 執行結束后,在下一個(macro)task 執行開始前,對頁面進行重新渲染**,流程如下:

(macro)task->渲染->(macro)task->...

(macro)task 主要包含:
script(整體代碼)
setTimeout
setInterval
I/O
UI 交互事件
postMessage
MessageChannel
setImmediate(Node.js 環境)

## 微任務

microtask(又稱為微任務),**可以理解是在當前 task 執行結束后立即執行的任務**。也就是說,在當前 task 任務后,下一個 task 之前,在渲染之前。

所以它的響應速度相比 setTimeout(setTimeout 是 task)會更快,因為無需等渲染。也就是說,在某一個 macrotask 執行完后,就會將在它執行期間產生的所有 microtask 都執行完畢(在渲染前)。

microtask 主要包含:
Promise.then
MutaionObserver
process.nextTick(Node.js 環境)

 

 

## 運行機制

在事件循環中,每進行一次循環操作稱為 tick,每一次 tick 的任務是比較復雜的,但關鍵步驟如下:

- 執行一個宏任務(棧中沒有就從事件隊列中獲取)
- 執行過程中如果遇到微任務,就將它添加到微任務的任務隊列中
- 宏任務執行完畢后,立即執行當前微任務隊列中的所有微任務(依次執行)
- 當前宏任務執行完畢,開始檢查渲染,然后 GUI 線程接管渲染
- 渲染完畢后,JS 線程繼續接管,開始下一個宏任務(從事件隊列中獲取)

## await 做了什么

從字面意思上看 await 就是等待,await 等待的是一個表達式,這個表達式的返回值可以是一個 promise 對象也可以是其他值。

很多人以為 await 會一直等待之后的表達式執行完之后才會繼續執行后面的代碼,實際上 await 是一個讓出線程的標志。await 后面的表達式會先執行一遍,將 await 后面的代碼加入到 microtask 中,然后就會跳出整個 async 函數來執行后面的代碼

由於因為 async await 本身就是 promise+generator 的語法糖。所以 await 后面的代碼是 microtask。

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');
})
}

 

三,node環境下的事件循環機制

在node中,事件循環表現出的狀態與瀏覽器中大致相同。不同的是node中有一套自己的模型。node中事件循環的實現是依靠的libuv引擎。我們知道node選擇chrome v8引擎作為js解釋器,v8引擎將js代碼分析后去調用對應的node api,而這些api最后則由libuv引擎驅動,執行對應的任務,並把不同的事件放在不同的隊列中等待主線程執行。 因此實際上node中的事件循環存在於libuv引擎中。

下面是一個libuv引擎中的事件循環的模型:

 

 

從上面這個模型中,我們可以大致分析出node中的事件循環的順序:

外部輸入數據-->輪詢階段(poll)-->檢查階段(check)-->關閉事件回調階段(close callback)-->定時器檢測階段(timer)-->I/O事件回調階段(I/O callbacks)-->閑置階段(idle, prepare)-->輪詢階段...

以上各階段的名稱是根據我個人理解的翻譯,為了避免錯誤和歧義,下面解釋的時候會用英文來表示這些階段。

這些階段大致的功能如下:

  • timers: 這個階段執行定時器隊列中的回調如 setTimeout() 和 setInterval()
  • I/O callbacks: 這個階段執行幾乎所有的回調。但是不包括close事件,定時器和setImmediate()的回調。
  • idle, prepare: 這個階段僅在內部使用,可以不必理會。
  • poll: 等待新的I/O事件,node在一些特殊情況下會阻塞在這里。
  • check: setImmediate()的回調會在這個階段執行。
  • close callbacks: 例如socket.on('close', ...)這種close事件的回調。

下面我們來按照代碼第一次進入libuv引擎后的順序來詳細解說這些階段:

poll階段

當個v8引擎將js代碼解析后傳入libuv引擎后,循環首先進入poll階段。poll階段的執行邏輯如下: 先查看poll queue中是否有事件,有任務就按先進先出的順序依次執行回調。 當queue為空時,會檢查是否有setImmediate()的callback,如果有就進入check階段執行這些callback。但同時也會檢查是否有到期的timer,如果有,就把這些到期的timer的callback按照調用順序放到timer queue中,之后循環會進入timer階段執行queue中的 callback。 這兩者的順序是不固定的,收到代碼運行的環境的影響。如果兩者的queue都是空的,那么loop會在poll階段停留,直到有一個i/o事件返回,循環會進入i/o callback階段並立即執行這個事件的callback。

值得注意的是,poll階段在執行poll queue中的回調時實際上不會無限的執行下去。有兩種情況poll階段會終止執行poll queue中的下一個回調:1.所有回調執行完畢。2.執行數超過了node的限制。

check階段

check階段專門用來執行setImmediate()方法的回調,當poll階段進入空閑狀態,並且setImmediate queue中有callback時,事件循環進入這個階段。

close階段

當一個socket連接或者一個handle被突然關閉時(例如調用了socket.destroy()方法),close事件會被發送到這個階段執行回調。否則事件會用process.nextTick()方法發送出去。

timer階段

這個階段以先進先出的方式執行所有到期的timer加入timer隊列里的callback,一個timer callback指得是一個通過setTimeout或者setInterval函數設置的回調函數。

I/O callback階段

如上文所言,這個階段主要執行大部分I/O事件的回調,包括一些為操作系統執行的回調。例如一個TCP連接生錯誤時,系統需要執行回調來獲得這個錯誤的報告。

四,setTimeOut、setImmediate、process.nextTick()的比較

## setTimeout()

將事件插入到了事件隊列,必須等到當前代碼(執行棧)執行完,主線程才會去執行它指定的回調函數。
當主線程時間執行過長,無法保證回調會在事件指定的時間執行。
瀏覽器端每次 setTimeout 會有 4ms 的延遲,當連續執行多個 setTimeout,有可能會阻塞進程,造成性能問題。

## setImmediate()

事件插入到事件隊列尾部,主線程和事件隊列的函數執行完成之后立即執行。和 setTimeout(fn,0)的效果差不多。
服務端 node 提供的方法。瀏覽器端最新的 api 也有類似實現:window.setImmediate,但支持的瀏覽器很少。

## process.nextTick()

插入到事件隊列尾部,但在下次事件隊列之前會執行。也就是說,它指定的任務總是發生在所有異步任務之前,當前主線程的末尾。
大致流程:當前”執行棧”的尾部–>下一次 Event Loop(主線程讀取”任務隊列”)之前–>觸發 process 指定的回調函數。
服務器端 node 提供的辦法。用此方法可以用於處於異步延遲的問題。
可以理解為:此次不行,預約下次優先執行。

 

五,常見考題

考題一:

//請寫出輸出內容
async function async1() {
  console.log("async1 start");
  await async2();
  console.log("async1 end");
}
async function async2() {
  console.log("async2");
}

console.log("script start"); // 1

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

async1();

new Promise(function (resolve) {
  console.log("promise1");
  resolve();
}).then(function () {
  console.log("promise2");
});
console.log("script end");

  

答案:

script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

 

考題二:將 async2 中的函數也變成了 Promise 函數

async function async1() {
  console.log("async1 start");
  await async2();
  console.log("async1 end");
}
async function async2() {
  //async2做出如下更改:
  new Promise(function (resolve) {
    console.log("promise1");
    resolve();
  }).then(function () {
    console.log("promise2");  
  });
}
console.log("script start");

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

new Promise(function (resolve) {
  console.log("promise3");
  resolve();
}).then(function () {
  console.log("promise4");
});

console.log("script end");

  

答案:

script start
async1 start
promise1
promise3
script end
promise2
async1 end
promise4
setTimeout

 

考題三:將 async1 中 await 后面的代碼和 async2 的代碼都改為異步的

async function async1() {
  console.log("async1 start");
  await async2();
  //更改如下:
  setTimeout(function () {          
    console.log("setTimeout1");    
  }, 0);
}
async function async2() {
  //更改如下:
  setTimeout(function () {
    console.log("setTimeout2");
  }, 0);
}
console.log("script start");

setTimeout(function () {
  console.log("setTimeout3");
}, 0);
async1();

new Promise(function (resolve) {
  console.log("promise1");
  resolve();
}).then(function () {
  console.log("promise2");
});
console.log("script end");

  

答案:

script start
async1 start
promise1
script end
promise2
setTimeout3
setTimeout2
setTimeout1

 

考題四:

async function a1 () {
    console.log('a1 start')
    await a2()
    console.log('a1 end')
}
async function a2 () {
    console.log('a2')
}

console.log('script start')

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

Promise.resolve().then(() =>{
    console.log('promise1')
})

a1()

let promise2 = new Promise((resolve) =>{
    resolve('promise2.then')
    console.log('promise2')
})

promise2.then((res) =>{
    console.log(res)
    Promise.resolve().then(() =>{
        console.log('promise3')
    })
})
console.log('script end')

  

答案:

script start
a1 start
a2
promise2
script end
promise1
a1 end
promise2.then
promise3
setTimeout

 

以上題目,你都做對了嗎,只要理解了事件循環,宏任務和微任務,這些題目萬變不離其宗~


免責聲明!

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



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