Javascript 執行機制

已登出發表於2022-01-17

1. 單執行緒的JavaScript

JavaScript是單執行緒的語言這,由它的用途決定的,作為瀏覽器的指令碼語言,主要負責和使用者互動,操作DOM。
假如JavaScript是多執行緒的,有兩個執行緒同時操作一個DOM節點,一個負責刪除DOM節點,一個在DOM節點上新增內容,瀏覽器該以哪個執行緒為標準呢?
所以,JavaScript的用途決定它只能是單執行緒的,過去是,將來也不會變。
HTML5的Web Worker允許JavaScript主執行緒建立多個子執行緒,但是這些子執行緒完全受主執行緒的控制,且不可操作DOM節點,所以JavaScript單執行緒的本質並沒有發生改變。

2. 同步任務和非同步任務

JavaScript是單執行緒語言,就意味著任務需要排隊執行,只有前一個執行完成,後一個才可以執行。
如果前一個任務非常耗時呢?比如操作IO裝置、網路請求等,後面的任務就會被阻塞,頁面就會被卡住,甚至崩潰,使用者體驗非常差。
如果JavaScript的主執行緒在遇到這些耗時的任務時,將其掛起,先執行後面的任務,等掛起的任務有結果以後再回頭執行,這樣就可以解決耗時任務阻塞主執行緒的問題了。
於是,所有的任務就可以分為兩種,同步任務和非同步任務,同步任務放在主執行緒中執行,非同步任務被掛起,不進入主執行緒執行(讓主執行緒阻塞等待),當其有結果了,再放入主執行緒中執行。

3. 任務佇列和Event Loop

3.1 任務佇列

任務佇列是一個事件佇列,也可以理解成訊息佇列,當掛起的非同步任務就緒以後就會在任務佇列中放置相應的事件,表示該任務可以進入主執行緒中執行了。
任務佇列中的事件,除了IO裝置的事件,還有網路請求,滑鼠點選、滾動等,只要為事件指定過回撥函式,這些事件發生時就會進入任務佇列,等待主執行緒來讀取,然後執行相應的回撥函式。
回撥函式其實就是被掛起來的非同步任務,比如:Ajax請求,請求成功或失敗以後執行的回撥函式就是非同步任務。
任務佇列是一個先進先出的資料結構,排在前面的事件,只要主執行緒一空,就會優先被讀取。

3.2 Event Loop

主執行緒從任務佇列讀取事件,這個過程是迴圈不斷的,所以JavaScript這種執行機制又稱為Event Loop(事件迴圈)

4. 巨集任務和微任務

非同步任務可進一步劃分為巨集任務和微任務,相應的任務佇列也有兩種,分別為巨集任務佇列和微任務佇列。

4.1 巨集任務

setTimeout、setInterval、setImmediate會產生巨集任務

4.2 微任務

requestAnimationFrame、IO、讀取資料、互動事件、UI render、Promise.then、MutationObserve、process.nextTick會產生微任務

4.3 瀏覽器中的JavaScript指令碼執行過程

4.3.1 過程描述

a. JavaScript指令碼進入主執行緒, 開始執行
b. 執行過程中如果遇到巨集任務和微任務,分別將其掛起,只有當任務就緒時將事件放入相應的任務佇列
c. 指令碼執行完成,執行棧清空
d. 去微任務佇列依次讀取事件,並將相應的回撥函式放入執行棧執行,如果執行過程中遇到巨集任務和微任務,處理方式同 b, 直到微任務佇列為空
e. 瀏覽器執行渲染動作, GUI渲染執行緒接管,直到渲染結束
f. JS執行緒接管,去巨集任務佇列依次讀取事件,並將相應的回撥函式放入執行棧, 開始下一個巨集任務的執行,過程為b -> c -> d -> e -> f, 如此迴圈
g. 直到執行棧、巨集任務佇列、微任務佇列都為空,指令碼執行結束

4.3.2 示例

4.3.2.1 示例一

// 指令碼

console.log(1)

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

const p = new Promise((resolve) => {
  setTimeout(() => {
    console.log(3)
    resolve()
  }, 1000)
  console.log(4)
})

p.then(() => {
  console.log(5)
})

console.log(6)

執行過程

a. 指令碼放入執行棧開始實行
b. 執行到console.log(1), 輸入1
c. 執行到setTimeout,遇到巨集任務,將其掛起,由於延時 0ms,將在 4ms後在巨集任務佇列產生一個定時事件, 我們叫定時A
d. 程式繼續向下執行,執行new Promise(),並執行其引數,遇到第二個定時任務(巨集任務),叫它定時B,並將其掛起,執行console.log(4), 輸出4
e. 遇到微任務p.then(), 將其掛起
f. 向下執行遇到console.log(6), 輸出6
g. 執行棧清空,讀取微任務佇列,發現為空,因為p.then()含沒有就緒,它的就緒依賴與第一個定時任務(定時A)的執行
h. 執行棧為空,微任務佇列為空,執行瀏覽器的渲染動作
i. 讀取巨集任務佇列,讀取第一個就緒的巨集任務,為定時任務A,將其回撥函式放入執行棧開始執行,執行console.log(2), 輸入2
j. 執行棧清空,微任務佇列為空,渲染
k. 開始執行下一個就緒的巨集任務,定時任務B,並將其回撥函式放入執行棧執行,執行console.log(3), 輸出3,並執行resolve(), p.then()就緒,在微任務佇列放入相應的事件
o. 執行棧清空,讀取微任務佇列,發現不為空,讀取第一個就緒的事件,並將其對應的回撥函式放入執行棧執行,執行console.log(5), 輸出5
p. 執行棧清空,微任務佇列為空,渲染,然後發現巨集任務佇列為空,本次指令碼執行徹底結束
輸出結果為: 1 4 6 2 3 5

4.3.2.2 示例二

async function async1 () {
  console.log('async1_1')
  await async2()
  console.log('async1_2')
}
async function async2 () {
  console.log('async2')
}
console.log('script start')
setTimeout(() => {
  console.log('setTimeout')
}, 0)
async1()
new Promise(resolve => {
  console.log('promise executor')
  resolve()
}).then(() => {
  console.log('promise then')
})
console.log('script end')

說明

函式前加async,實際上返回的是一個promise,比如這裡的async2函式,返回的是一個立即resoved  promise
await會將後面的同步程式碼執行完成(async2),然後讓出執行緒,將非同步任務(Promise.then)掛起,這裡的立即resolved promise,所以會在微任務佇列新增一個事件,且排在下面的Promise.then之前

輸出結果

如果上一個示例看懂了,再飢餓和該示例的說明資訊,答案就呼之欲出了:
script start => async1_1 => async2 => promise executor => script end => async1_2 => promise then => setTimeout

4.3.3 外鏈

外鏈

4.3.4 總結

如果把JavaScript指令碼也當作初始的巨集任務,那麼JavaScript在瀏覽器端的執行過程就是這樣:
先執行一個巨集任務, 然後執行所有的微任務
再執行一個巨集任務,然後執行所有的微任務
...
如此反覆,執行執行棧和任務佇列為空

4.4 node.js中JavaScript指令碼的執行過程

JavaScript指令碼執行過程在node.js和瀏覽器中有些不同, 造成這些差異的原因在於,瀏覽器中只有一個巨集任務佇列,但是node.js中有好幾個巨集任務佇列,而且這些巨集任務佇列還有執行的先後順序,而微任務時穿插在這些巨集任務之間執行的

4.4.1 執行順序

  各個事件型別, 實行順序自上而下
   ┌───────────────────────┐
┌─>│        timers         │<————— 執行 setTimeout()、setInterval() 的回撥
│  └──────────┬────────────┘
|             |<-- 先執行process.nextTick, 再執行MicroTask Queue 的回撥
│  ┌──────────┴────────────┐
│  │     pending callbacks │<————— 執行由上一個 Tick 延遲下來的 I/O 回撥
│  └──────────┬────────────┘
|             |<-- 先執行process.nextTick, 再執行MicroTask Queue 的回撥
│  ┌──────────┴────────────┐
│  │     idle, prepare     │<————— 內部呼叫(可忽略)
│  └──────────┬────────────┘     
|             |<-- 先執行process.nextTick, 再執行MicroTask Queue 的回撥
|             |                   ┌───────────────┐
│  ┌──────────┴────────────┐      │   incoming:   │ - (執行幾乎所有的回撥,除了 close callbacks 以
|  |                       |      |               |     及 timers 排程的回撥和 setImmediate() 排程
|  |         poll          |<-----|   connections,|        的回撥,在恰當的時機將會阻塞在此階段)
│  │                       │      |               │ 
│  └──────────┬────────────┘      │   data, etc.  │ 
│             |                   |               | 
|             |                   └───────────────┘
|             |<-- 先執行process.nextTick, 再執行MicroTask Queue 的回撥
|  ┌──────────┴────────────┐      
│  │        check          │<————— setImmediate() 的回撥將會在這個階段執行
│  └──────────┬────────────┘
|             |<-- 先執行process.nextTick, 再執行MicroTask Queue 的回撥
│  ┌──────────┴────────────┐
└──┤    close callbacks    │<————— socket.on('close', ...)
   └───────────────────────┘

4.4.2 示例

4.4.2.1 基本示例

console.log(1)

setTimeout(() => {
  console.log('timer1')
  Promise.resolve().then(() => {
    console.log('promise1')
  })
}, 0)

setTimeout(() => {
  console.log('timer2')
  Promise.resolve().then(() => {
    console.log('promise2')
  })
}, 0)

console.log(2)
這段程式碼在瀏覽器中的執行結果為:1 2 timer1 promise1 timer2 promise2
在node.js中的執行結果則為:1 2 timer1 timer2 promise1 promise2

4.4.2.2 setTimeout和setImmediate的順序

它們兩個順序從上圖看顯而易見,timers佇列在check佇列執行執行,但是有個前提,事件已經就緒
setTimeout(() => {
  console.log('timeout')
}, 0)

setImmediate(() => {
  console.log('immediate')
})
以上程式碼在node.js中的執行結果為:immediate timeout,原因如下:
在程式執行時timer事件未就緒,所以第一次去讀timer佇列時,佇列為空,繼續向下執行,在check佇列讀取到了就緒的事件,所以先執行immediate,再執行timeout,因為即使setTimeout的延時時間未 0,但是node.js一般會設定為 1ms, 所以,當node準備Event Loop的時間大於 1ms時,就會先輸出timeout,後輸出immediate,否則先輸出immediate後輸出timeout
const fs = require('fs')

// 讀取檔案
fs.readFile('xx.txt', () => {
  setTimeout(() => {
    console.log('timeout')
  })

  setImmediate(() => {
    console.log('immediate')
  })
})
以上程式碼的輸出順序一定為:immediate timeout, 原因如下:
setTimeout和setImmediate都寫在I/O callback中,意味著處於poll階段,然後是check階段,所以,此時無論setTimeout就緒多快(1ms),都會優先執行setImmediate,本質上,從poll階段開始執行,而不是一個Tick初始階段。

相關文章