JavaScript深刻之事件循環機制(event loop)

單線程模型

衆所周知,JavaScript 是單線程的,所謂單線程,就是指一次只能完成一個任務,若是有多個任務就必需要排隊,前面的一個任務完成了,再執行後面的任務,以此類推。html

須要注意的是 JavaScript 只在一個線程上運行,不表明瀏覽器內核只有一個線程,事實上瀏覽器內部有多個線程,主線程用於 JavaScript 代碼的編譯和執行,其它線程都是在後臺配合主線程。web

JavaScript 之因此選擇單線程,跟歷史有關係。JavaScript 從誕生起就是單線程,緣由是不想讓瀏覽器變得太複雜,多線程須要面臨鎖、狀態同步等問題,這對於一種網頁腳本語言來講開銷太大。若是 JavaScript 同時有兩個線程,一個線程在網頁 DOM 節點上添加內容,另外一個線程刪除了這個節點,這時瀏覽器應該以哪一個線程爲準?因此,爲了不復雜性,JavaScript 一開始就是單線程,這已經成了這門語言的核心特徵。編程

同步和異步

上面說了 JavaScript 是單線程的,這種模式下,若是有一個很是耗時的任務進行的話,後面的任務都得排隊等着,這時候應用程序就沒法去作其餘的事情,爲此 JavaScript 語言的任務執行模式分爲兩個部分:同步(Synchronous)和異步(Asynchronous)api

  • 同步:就是上面說的排隊等待的形式。
  • 異步:異步操做發生在未知或不可預測的時間,是指在執行一個任務的時候不能當即返回結果,而是在未來經過必定手段獲得,後一個任務不用等前一個任務結束就執行。

那麼 JavaScript 是如何來執行異步任務的呢,就是後面要講的事件循環機制。promise

調用棧(call stack)

講事件循環以前,咱們先來看一下 JavaScript 中的 call stack。下圖是 JavaScript 引擎的一個簡化圖:瀏覽器

上圖中看出 JavaScript 引擎主要包含兩個部分:服務器

  1. Memory Heap (內存堆):這是內存分配發生的地方。
  2. Call Stack(調用棧):這是代碼執行時存儲函數調用的結構。

前面說了,JavaScript 是一種單線程編程語言,這意味着它只有一個 Call Stack 。所以,它一次僅能作一件事。Call Stack 是一個數據結構,它基本記錄了咱們在程序執行中的所處的位置,若是咱們進入一個函數,咱們把它放在堆棧的頂部。若是咱們從一個函數中返回,咱們彈出堆棧的頂部。網絡

上面圖中能夠看出,當開始執行 JS 代碼時,首先向調用棧中壓入一個 main()函數(表明了全局上下文),而後執行咱們的代碼,根據先進後出的原則,後執行的代碼會先彈出棧。數據結構

若是在調用堆棧中執行的函數調用須要花費大量時間才能進行處理,會發生什麼? 例如,假設你想在瀏覽器中使用 JavaScript 進行一些複雜的圖像轉換。這時候瀏覽器就被阻塞了,這意味着瀏覽器沒法渲染,它不能運行任何其餘代碼,它就是被卡住了。這時候就想到了咱們前面講過的異步任務的處理方式,那麼如何執行異步任務呢,就是下面要講的事件循環(event loop)機制多線程

事件循環(event loop)

儘管容許執行異步 JavaScript 代碼(如 setTimeout 函數),但直到 ES6 出現,實際上 JavaScript 自己歷來沒有任何明確的異步概念。 JavaScript 引擎歷來都只是執行單個程序模塊而不作更多別的事情。 那麼,誰來告訴 JS 引擎去執行你編寫的一大段程序?實際上,JS 引擎並非孤立運行,它運行在一個宿主環境中,對於大多數開發人員來講,宿主環境就是一個典型的 Web 瀏覽器或 Node.js。全部環境中的共同點是一個稱爲事件循環的內置機制,它隨着時間的推移處理程序中多個模塊的執行順序,並每次調用 JS 引擎。

因此,例如,當你的 JavaScript 程序發出一個 Ajax 請求來從服務器獲取一些數據時,你在一個回調函數中寫好了 「響應」 代碼,JS 引擎將會告訴宿主環境:

「嘿,我如今暫停執行,可是每當你完成這個網絡請求,而且你有一些數據,請調用這個函數並返回給我。

而後瀏覽器開始監聽來自網絡的響應,當響應返回給你的時候,宿主環境會將回調函數插入到事件循環中來安排回調函數的執行順序。

咱們來看下面的圖表:

咱們都使用過 setTimeout、AJAX 這些 API, 可是,這些 API 不是由 JS 引擎提供的。那這些 Web APIs 究竟是什麼? 從本質上講,它們是瀏覽器並行啓動的一部分,是你沒法訪問的線程,你僅僅只能夠調用它們。

前面說了瀏覽器內核是多線程,在內核控制下各線程相互配合以保持同步,一個瀏覽器一般由如下常駐線程組成:

  • GUI 渲染引擎線程:顧名思義,該線程負責頁面的渲染
  • JavaScript 引擎線程:負責 JS 的解析和執行
  • 定時觸發器線程:處理定時事件,好比setTimeout, setInterval
  • 事件觸發線程:處理DOM事件
  • 異步 http 請求線程:處理http請求

上圖中看出,JavaScript 運行時,除了正在運行的主線程,還存在一個 callback queue(也叫task queue),即任務隊列,裏面是各類須要當前程序處理的異步任務(實際上,根據異步任務的類型,存在多個任務隊列)。

異步執行的運行機制以下:

  1. 首先主線程(即 JavaScript 引擎)會在 call stack 中執行全部的同步任務。
  2. 當遇到異步任務(如好比setTimeout、Ajax)時,則交由 Web APIs 相應的線程來處理,Web APIs這邊處理完畢後,會將相應的 callback 函數放入到任務隊列中。
  3. event loop 會不斷的監測 調用棧 和 任務隊列,當調用棧爲空的時候,event loop 就會把任務隊列中的第一個事件取出推入到調用棧中。
  4. 執行渲染操做,更新界面
  5. 如此循環往復。

下面咱們經過一個例子來看一下具體的執行過程。

console.log('Hi');
setTimeout(function cb1() { 
    console.log('cb1');
}, 5000);
console.log('Bye');

複製代碼

setTimeout 有個要注意的地方,如上述例子延遲 5s 執行,不是嚴格意義上的 5s,正確來講是至少 5s 之後會執行。由於 Web API 會設定一個 5s 的定時器,時間到期後將回調函數加到隊列中,此時該回調函數還不必定會立刻運行,由於隊列中可能還有以前加入的其餘回調函數,並且還必須等到 Call Stack 空了以後纔會從隊列中取一個回調執行。這也是不少人說 JavaScript 中的定時器其實不是徹底精確的緣由。

關於事件循環的詳細講解,推薦一個視頻《what the hack is event loop》

任務隊列

每一個線程都有本身的事件循環,因此每一個 web worker 有本身的事件循環(event loop),因此它能獨立地運行。一個事件循環有多個 task 來源,而且保證在 task 來源內的執行順序,在每次循環中瀏覽器要選擇從哪一個來源中選取 task,任務源能夠分爲 微任務(microtask)宏任務(macrotask),在ES6規範中,microtask 稱爲 jobs, macrotask 稱爲 task。

macrotask 主要包括下面幾個:

  • script 主程序
  • setTimeout
  • setInterval
  • setImmediate(Node)
  • I/O
  • UI交互事件

microtask 主要包含:

  • Promise
  • MutationObserver
  • process.nextTick (Node)

參考 whatwg規範中關於任務隊列的定義咱們能夠了解到:

  1. 每一個事件循環都有一個微任務隊列(microtask queue)。
  2. 瀏覽器每次都是先執行最舊的 macrotask,也就是先加進宏任務隊裏的那個 macrotask。
  3. 每次執行完一個 macrotask,就會檢查 microtask queue 裏面是否存在 microtask,若是有則不斷執⾏ microtask,在 microtasks 執行時還能夠加入更多的 microtask,而後一個一個的執行,直到 microtask 隊列清空。
  4. 下一個循環,執行下一個 macrotask 中的任務,如此循環往復。

有點繞,咱們下面先看一個例子來解釋一下:

console.log('script start');

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

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

console.log('script end');
// script start
// script end
// promise1
// promise2
// setTimeout
複製代碼

咱們來分析一下上面代碼的具體執行步驟。表格中紅色的表示當前正在執行的任務。

  1. 首先主線程執行同步代碼,script 代碼進入 call stack,當前正在執行的 macrotask 爲 主script。
macrotasks microtasks call stack Log
script script script start
  1. 遇到 setTimeout 函數,將其回調函數加入到 macrotasks 中
macrotasks microtasks call stack Log
script script script start
setTimeout callback
  1. 繼續往下執行,遇到 Promise,將已經resolved 的 Promise 回調加入到 microtasks。
macrotasks microtasks call stack Log
script Promise then 1 script script start
setTimeout callback
  1. 繼續往下執行,輸出 log ‘script end’,此時當前的 macrotask 執行完畢,前面說到當每一次 macrotask 執行完畢後都會去檢查 microtask queue,此時開始處理 microtasks 中的任務。
macrotasks microtasks call stack Log
script Promise then 1 Promise callback 1 script start
setTimeout callback script end
  1. 檢查 microtasks 發現有一個 microtask,開始執行 Promise then 1 的回調,輸出log。
macrotasks microtasks call stack Log
script Promise then 1 Promise callback 1 script start
setTimeout callback script end
promise1
  1. 發現該回調還有一個 then 函數的回調,再把它(暫且稱之爲 Promise then 2)也放入到 microtasks 中,此時 Promise then 1 這個 microtask 執行完畢,被移除。此時 macrotasks 還未清空,所以要繼續執行 microtasks, 輸出log。
macrotasks microtasks call stack Log
script Promise then 2 Promise callback 2 script start
setTimeout callback script end
promise1
promise2
  1. 此時,主 script 這個 macrotask 執行完畢,開始執行下一個 macrotask,也就是 setTimeout callback,輸出log,而 microtask queue 被清空。
macrotasks microtasks call stack Log
setTimeout callback setTimeout callback script start
script end
promise1
promise2
setTimeout
  1. setTimeout callback 這個 macrotask 執行完畢,此時檢查 microtask queue 中沒有任務,而且 macrotask queue 中也沒有任務了,本次事件循環結束,call stack 清空。
相關文章
相關標籤/搜索