【THE LAST TIME】徹底吃透 JavaScript 執行機制


前言

The last time, I have learned

【THE LAST TIME】一直是我想寫的一個系列,旨在厚積薄發,重溫前端。

也是給自己的查缺補漏和技術分享。

歡迎大家多多評論指點吐槽。

系列文章均首發於公眾號【全棧前端精選】,筆者文章集合詳見Nealyang/personalBlog。目錄皆為暫定

執行 & 運行

首先我們需要聲明下,JavaScript 的執行和運行是兩個不同概念的,執行,一般依賴於環境,比如 node、瀏覽器、Ringo 等, JavaScript 在不同環境下的執行機制可能並不相同。而今天我們要討論的 Event Loop 就是 JavaScript 的一種執行方式。所以下文我們還會梳理 node 的執行方式。而運行呢,是指JavaScript 的解析引擎。這是統一的。

關於 JavaScript

此篇文章中,這個小標題下,我們只需要牢記一句話: JavaScript 是單線程語言 ,無論HTML5 里面 Web-Worker 還是 node 里面的cluster都是“紙老虎”,而且 cluster 還是進程管理相關。這里讀者注意區分:進程和線程。

既然 JavaScript 是單線程語言,那么就會存在一個問題,所有的代碼都得一句一句的來執行。就像我們在食堂排隊打飯,必須一個一個排隊點菜結賬。那些沒有排到的,就得等着~

概念梳理

在詳解執行機制之前,先梳理一下 JavaScript 的一些基本概念,方便后面我們說到的時候大伙兒心里有個印象和大概的輪廓。

事件循環(Event Loop)

什么是 Event Loop?

其實這個概念還是比較模糊的,因為他必須得結合着運行機制來解釋。

JavaScript 有一個主線程 main thread,和調用棧 call-stack 也稱之為執行棧。所有的任務都會放到調用棧中等待主線程來執行。

暫且,我們先理解為上圖的大圈圈就是 Event Loop 吧!並且,這個圈圈,一直在轉圈圈~ 也就是說,JavaScriptEvent Loop 是伴隨着整個源碼文件生命周期的,只要當前 JavaScript 在運行中,內部的這個循環就會不斷地循環下去,去尋找 queue 里面能執行的 task

任務隊列(task queue)

task,就是任務的意思,我們這里理解為每一個語句就是一個任務

console.log(1);
console.log(2);

如上語句,其實就是就可以理解為兩個 task

queue 呢,就是FIFO的隊列!

所以 Task Queue 就是承載任務的隊列。而 JavaScriptEvent Loop 就是會不斷地過來找這個 queue,問有沒有 task 可以運行運行。

同步任務(SyncTask)、異步任務(AsyncTask)

同步任務說白了就是主線程來執行的時候立即就能執行的代碼,比如:

console.log('this is THE LAST TIME');
console.log('Nealyang');

代碼在執行到上述 console 的時候,就會立即在控制台上打印相應結果。

而所謂的異步任務就是主線程執行到這個 task 的時候,“唉!你等會,我現在先不執行,等我 xxx 完了以后我再來等你執行” 注意上述我說的是等你來執行。

說白了,異步任務就是你先去執行別的 task,等我這 xxx 完之后再往 Task Queue 里面塞一個 task 的同步任務來等待被執行

setTimeout(()=>{
  console.log(2)
});
console.log(1);

如上述代碼,setTimeout 就是一個異步任務,主線程去執行的時候遇到 setTimeout 發現是一個異步任務,就先注冊了一個異步的回調,然后接着執行下面的語句console.log(1),等上面的異步任務等待的時間到了以后,在執行console.log(2)。具體的執行機制會在后面剖析。

  • 主線程自上而下執行所有代碼
  • 同步任務直接進入到主線程被執行,而異步任務則進入到 Event Table 並注冊相對應的回調函數
  • 異步任務完成后,Event Table 會將這個函數移入 Event Queue
  • 主線程任務執行完了以后,會從Event Queue中讀取任務,進入到主線程去執行。
  • 循環如上

上述動作不斷循環,就是我們所說的事件循環(Event Loop)。

小試牛刀

ajax({
    url:www.Nealyang.com,
    data:prams,
    success:() => {
        console.log('請求成功!');
    },
    error:()=>{
        console.log('請求失敗~');
    }
})
console.log('這是一個同步任務');
  • ajax 請求首先進入到 Event Table ,分別注冊了onErroronSuccess回調函數。
  • 主線程執行同步任務:console.log('這是一個同步任務');
  • 主線程任務執行完畢,看Event Queue是否有待執行的 task,這里是不斷地檢查,只要主線程的task queue沒有任務執行了,主線程就一直在這等着
  • ajax 執行完畢,將回調函數pushEvent Queue。(步驟 3、4 沒有先后順序而言)
  • 主線程“終於”等到了Event Queue里有 task可以執行了,執行對應的回調任務。
  • 如此往復。

宏任務(MacroTask)、微任務(MicroTask)

JavaScript 的任務不僅僅分為同步任務和異步任務,同時從另一個維度,也分為了宏任務(MacroTask)和微任務(MicroTask)。

先說說 MacroTask,所有的同步任務代碼都是MacroTask(這么說其實不是很嚴謹,下面解釋),setTimeoutsetIntervalI/OUI Rendering 等都是宏任務。

MicroTask,為什么說上述不嚴謹我卻還是強調所有的同步任務都是 MacroTask 呢,因為我們僅僅需要記住幾個 MicroTask 即可,排除法!別的都是 MacroTaskMicroTask 包括:Process.nextTickPromise.then catch finally(注意我不是說 Promise)、MutationObserver

瀏覽器環境下的 Event Loop

當我們梳理完哪些是 MicroTask ,除了那些別的都是 MacroTask 后,哪些是同步任務,哪些又是異步任務后,這里就應該徹底的梳理下JavaScript 的執行機制了。

如開篇說到的,執行和運行是不同的,執行要區分環境。所以這里我們將 Event Loop 的介紹分為瀏覽器和 Node 兩個環境下。

先放圖鎮樓!如果你已經理解了這張圖的意思,那么恭喜你,你完全可以直接閱讀 Node 環境下的 Event Loop 章節了!

setTimeout、setInterval

setTimeout

setTimeout 就是等多長時間來執行這個回調函數。setInterval 就是每隔多長時間來執行這個回調。

let startTime = new Date().getTime();

setTimeout(()=>{
  console.log(new Date().getTime()-startTime);
},1000);

如上代碼,顧名思義,就是等 1s 后再去執行 console。放到瀏覽器下去執行,OK,如你所願就是如此。

但是這次我們在探討 JavaScript 的執行機制,所以這里我們得探討下如下代碼:

let startTime = new Date().getTime();

console.log({startTime})

setTimeout(()=>{
  console.log(`開始執行回調的相隔時差:${new Date().getTime()-startTime}`);
},1000);

for(let i = 0;i<40000;i++){
  console.log(1)
}

如上運行,setTimeout 的回調函數等到 4.7s 以后才執行!而這時候,我們把 setTimeout 的 1s 延遲給刪了:

let startTime = new Date().getTime();

console.log({startTime})

setTimeout(()=>{
  console.log(`開始執行回調的相隔時差:${new Date().getTime()-startTime}`);
},0);

for(let i = 0;i<40000;i++){
  console.log(1)
}

結果依然是等到 4.7s 后才執行setTimeout 的回調。貌似 setTimeout 后面的延遲並沒有產生任何效果!

其實這么說,又應該回到上面的那張 JavaScript 執行的流程圖了。

setTimeout這里就是簡單的異步,我們通過上面的圖來分析上述代碼的一步一步執行情況

  • 首先 JavaScript 自上而下執行代碼
  • 遇到遇到賦值語句、以及第一個 console.log({startTime}) 分別作為一個 task,壓入到立即執行棧中被執行。
  • 遇到 setTImeout 是一個異步任務,則注冊相應回調函數。(異步函數告訴你,js 你先別急,等 1s 后我再將回調函數:console.log(xxx)放到 Task Queue 中)
  • OK,這時候 JavaScript 則接着往下走,遇到了 40000 個 for 循環的 task,沒辦法,1s 后都還沒執行完。其實這個時候上述的回調已經在Task Queue 中了。
  • 等所有的立即執行棧中的 task 都執行完了,在回頭看 Task Queue 中的任務,發現異步的回調 task 已經在里面了,所以接着執行。

打個比方

其實上述的不僅僅是 timeout,而是任何異步,比如網絡請求等。

就好比,我六點鍾下班了,可以安排下自己的活動了!

然后收拾電腦(同步任務)、收拾書包(同步任務)、給女朋友打電話說出來吃飯吧(必然是異步任務),然后女朋友說你等會,我先化個妝,等我畫好了call你。

那我不能干等着呀,就接着做別的事情,比如那我就在改個 bug 吧,你好了通知我。結果等她一個小時后說我化好妝了,我們出去吃飯吧。不行!我 bug 還沒有解決掉呢?你等會。。。。其實這個時候你的一小時化妝還是 5 分鍾化妝都已經毫無意義了。。。因為哥哥這會沒空~~

如果我 bug 在半個小時就解決完了,沒別的任務需要執行了,那么就在這等着呀!必須等着!隨時待命!。然后女朋友來電話了,我化完妝了,我們出去吃飯吧,那么剛好,我們在你的完成了請求或者 timeout 時間到了后我剛好閑着,那么我必須立即執行了。

setInterval

說完了 setTimeout,當然不能錯過他的孿生兄弟:setInterval。對於執行順序來說,setInterval會每隔指定的時間將注冊的函數置入 Task Queue,如果前面的任務耗時太久,那么同樣需要等待。

這里需要說的是,對於 setInterval(fn,ms) 來說,我們制定沒 xx ms執行一次 fn,其實是沒 xx ms,會有一個fn 進入到 Task Queue 中。一旦 setInterval 的回調函數fn執行時間超過了xx ms,那么就完全看不出來有時間間隔了。 仔細回味回味,是不是那么回事?

Promise

關於 Promise 的用法,這里就不過過多介紹了,后面會在寫《【THE LAST TIME】徹底吃透 JavaScript 異步》 一文的時候詳細介紹。這里我們只說 JavaScript 的執行機制。

如上所說,promise.thencatchfinally 是屬於 MicroTask。這里主要是異步的區分。展開說明之前,我們結合上述說的,再來“扭曲”梳理一下。

為了避免初學者這時候腦子有點混亂,我們暫時忘掉 JavaScript 異步任務! 我們暫且稱之為待會再執行的同步任務。

有了如上約束后,我們可以說,JavaScript 從一開始就自上而下的執行每一個語句(Task),這時候只能遇到立馬就要執行的任務和待會再執行的任務。對於那待會再執行的任務等到能執行了,也不會立即執行,你得等js 執行完這一趟才行

再打個比方

就像做公交車一樣,公交車不等人呀,公交車路線上有人就會停(農村公交!么得站牌),但是等公交車來,你跟司機說,我肚子疼要拉x~這時候公交不會等你。你只能拉完以后等公交下一趟再來(大山里!一個路線就一趟車)。

OK!你拉完了。。。等公交,公交也很快到了!但是,你不能立馬上車,因為這時候前面有個孕婦!有個老人!還有熊孩子,你必須得讓他們先上車,然后你才能上車!

而這些 孕婦、老人、熊孩子所組成的就是傳說中的 MicroTask Queue,而且,就在你和你的同事、朋友就必須在他們后面上車。

這里我們沒有異步的概念,只有同樣的一次循環回來,有了兩種隊伍,一種優先上車的隊伍叫做MicroTask Queue,而你和你的同事這幫壯漢組成的隊伍就是宏隊伍(MacroTask Queue)。

一句話理解:一次事件循環回來后,開始去執行 Task Queue 中的 task,但是這里的 task優先級。所以優先執行 MicroTask Queue 中的 task
,執行完后在執行MacroTask Queue 中的 task

小試牛刀

理論都扯完了,也不知道你懂沒懂。來,期中考試了!

console.log('script start');

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

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

沒必要搞個 setTimeout 有加個 Promise,Promise 里面再整個 setTimeout 的例子。因為只要上面代碼你懂了,無非就是公交再來一趟而已!

如果說了這么多,還是沒能理解上圖,那么公眾號內回復【1】,手摸手指導!

Node 環境下的 Event Loop

Node中的Event Loop是基於libuv實現的,而libuv是 Node 的新跨平台抽象層,libuv使用異步,事件驅動的編程方式,核心是提供i/o的事件循環和異步回調。libuvAPI包含有時間,非阻塞的網絡,異步文件操作,子進程等等。

Event Loop就是在libuv中實現的。所以關於 Node 的 Event Loop學習,有兩個官方途徑可以學習:

在學習 Node 環境下的 Event Loop 之前呢,我們首先要明確執行環境,Node 和瀏覽器的Event Loop是兩個有明確區分的事物,不能混為一談。nodejs的event是基於libuv,而瀏覽器的event loop則在html5的規范中明確定義。

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

Node 的 Event Loop 分為 6 個階段:

  • timers:執行setTimeout()setInterval()中到期的callback。
  • pending callback: 上一輪循環中有少數的I/O callback會被延遲到這一輪的這一階段執行
  • idle, prepare:僅內部使用
  • poll: 最為重要的階段,執行I/O callback,在適當的條件下會阻塞在這個階段
  • check: 執行setImmediate的callback
  • close callbacks: 執行close事件的callback,例如socket.on('close'[,fn])http.server.on('close, fn)

上面六個階段都不包括 process.nextTick()(下文會介紹)

整體的執行機制如上圖所示,下面我們具體展開每一個階段的說明

timers 階段

timers 階段會執行 setTimeoutsetInterval 回調,並且是由 poll 階段控制的。

在 timers 階段其實使用一個最小堆而不是隊列來保存所有的元素,其實也可以理解,因為timeout的callback是按照超時時間的順序來調用的,並不是先進先出的隊列邏輯)。而為什么 timer 階段在第一個執行階梯上其實也不難理解。在 Node 中定時器指定的時間也是不准確的,而這樣,就能盡可能的准確了,讓其回調函數盡快執行。

以下是官網給出的例子:

const fs = require('fs');

function someAsyncOperation(callback) {
  // Assume this takes 95ms to complete
  fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;

  console.log(`${delay}ms have passed since I was scheduled`);
}, 100);

// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
  const startCallback = Date.now();

  // do something that will take 10ms...
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});

當進入事件循環時,它有一個空隊列(fs.readFile()尚未完成),因此定時器將等待剩余毫秒數,當到達95ms時,fs.readFile()完成讀取文件並且其完成需要10毫秒的回調被添加到輪詢隊列並執行。

當回調結束時,隊列中不再有回調,因此事件循環將看到已達到最快定時器的閾值,然后回到timers階段以執行定時器的回調。
在此示例中,您將看到正在調度的計時器與正在執行的回調之間的總延遲將為105毫秒。

pending callbacks 階段

pending callbacks 階段其實是 I/O 的 callbacks 階段。比如一些 TCP 的 error 回調等。

舉個栗子:如果TCP socket ECONNREFUSED在嘗試connectreceives,則某些* nix系統希望等待報告錯誤。 這將在pending callbacks階段執行。

poll 階段

poll 階段主要有兩個功能:

  • 執行 I/O 回調
  • 處理 poll 隊列(poll queue)中的事件

當時Event Loop 進入到 poll 階段並且 timers 階段沒有任何可執行的 task 的時候(也就是沒有定時器回調),將會有以下兩種情況

  • 如果 poll queue 非空,則 Event Loop就會執行他們,知道為空或者達到system-dependent(系統相關限制)
  • 如果 poll queue 為空,則會發生以下一種情況
    • 如果setImmediate()有回調需要執行,則會立即進入到 check 階段
    • 相反,如果沒有setImmediate()需要執行,則 poll 階段將等待 callback 被添加到隊列中再立即執行,這也是為什么我們說 poll 階段可能會阻塞的原因。

一旦 poll queue 為空,Event Loop就回去檢查timer 階段的任務。如果有的話,則會回到 timer 階段執行回調。

check 階段

check 階段在 poll 階段之后,setImmediate()的回調會被加入check隊列中,他是一個使用libuv API 的特殊的計數器。

通常在代碼執行的時候,Event Loop 最終會到達 poll 階段,然后等待傳入的鏈接或者請求等,但是如果已經指定了setImmediate()並且這時候 poll 階段已經空閑的時候,則 poll 階段將會被中止然后開始 check 階段的執行。

close callbacks 階段

如果一個 socket 或者事件處理函數突然關閉/中斷(比如:socket.destroy()),則這個階段就會發生 close 的回調執行。否則他會通過 process.nextTick() 發出。

setImmediate() vs setTimeout()

setImmediate()setTimeout()非常的相似,區別取決於誰調用了它。

  • setImmediate在 poll 階段后執行,即check 階段
  • setTimeout 在 poll 空閑時且設定時間到達的時候執行,在 timer 階段

計時器的執行順序將根據調用它們的上下文而有所不同。 如果兩者都是從主模塊中調用的,則時序將受到進程性能的限制。

例如,如果我們運行以下不在I / O周期(即主模塊)內的腳本,則兩個計時器的執行順序是不確定的,因為它受進程性能的約束:

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

setImmediate(() => {
  console.log('immediate');
});
$ node timeout_vs_immediate.js
timeout
immediate

$ node timeout_vs_immediate.js
immediate
timeout

如果在一個I/O 周期內移動這兩個調用,則始終首先執行立即回調:

// timeout_vs_immediate.js
const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});
$ node timeout_vs_immediate.js
immediate
timeout

$ node timeout_vs_immediate.js
immediate
timeout

所以與setTimeout()相比,使用setImmediate()的主要優點是,如果在I / O周期內安排了任何計時器,則setImmediate()將始終在任何計時器之前執行,而與存在多少計時器無關。

nextTick queue

可能你已經注意到process.nextTick()並未顯示在圖中,即使它是異步API的一部分。 所以他擁有一個自己的隊列:nextTickQueue

這是因為process.nextTick()從技術上講不是Event Loop的一部分。 相反,無論當前事件循環的當前階段如何,都將在當前操作完成之后處理nextTickQueue

如果存在 nextTickQueue,就會清空隊列中的所有回調函數,並且優先於其他 microtask 執行。

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

process.nextTick() vs setImmediate()

從使用者角度而言,這兩個名稱非常的容易讓人感覺到困惑。

  • process.nextTick()在同一階段立即觸發
  • setImmediate()在事件循環的以下迭代或“tick”中觸發

貌似這兩個名稱應該呼喚下!的確~官方也這么認為。但是他們說這是歷史包袱,已經不會更改了。

這里還是建議大家盡可能使用setImmediate。因為更加的讓程序可控容易推理。

至於為什么還是需要 process.nextTick,存在即合理。這里建議大家閱讀官方文檔:why-use-process-nexttick

Node與瀏覽器的 Event Loop 差異

一句話總結其中:瀏覽器環境下,microtask的任務隊列是每個macrotask執行完之后執行。而在Node.js中,microtask會在事件循環的各個階段之間執行,也就是一個階段執行完畢,就會去執行microtask隊列的任務。

上圖來自浪里行舟

最后

來~期末考試了

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

setTimeout(function() {
    console.log('9');
    process.nextTick(function() {
        console.log('10');
    })
    new Promise(function(resolve) {
        console.log('11');
        resolve();
    }).then(function() {
        console.log('12')
    })
})

評論區留下你的答案吧~~老鐵!

參考文獻

學習交流

關注公眾號: 【全棧前端精選】 每日獲取好文推薦。

公眾號內回復 【1】,加入全棧前端學習群,一起交流。


免責聲明!

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



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