這一次,Event Loop 一波帶走

做者:Horace
博客:最近簡單的搭了一個博客~
ps:新人小白一枚,若有錯誤,歡迎指出~前端

筆者最近忙着作項目之類的,文章輸出遺落下了一段時間,此次咱們就來聊一個面試中一個比較重要的知識點 —— Event Loopnode

可能有人會奇怪一個 EventLoop 還能寫出什麼,且聽我慢慢來逼叨,看完這篇文章帶你搞定 Event Loop 以及它相關的一些知識點。git

1、Event Loop 是什麼

在開始說 Event Loop 以前,咱們先來認識一下它究竟是個什麼東西。程序員

In computer science, the event loop is a programming construct or design pattern that waits for and dispatches events or messages in a program. The event loop works by making a request to some internal or external "event provider" (that generally blocks the request until an event has arrived), then calls the relevant event handler ("dispatches the event"). The event loop is also sometimes referred to as the message dispatcher, message loop, message pump, or run loop.github

上面這段是Wikipedia對 Event Loop 的解釋,簡單的來講就是Event Loop是一個程序結構,用於等待和分派消息和事件我我的的理解是 JS 中的 Event Loop 是瀏覽器或 Node 的一種協調 JavaScript 單線程運行時不會阻塞的一種機制。面試

爲何要學 Event Loop?

可能有人會比較疑惑前端爲何要學看起來比較底層的 Event Loop,不只僅是由於這是一道面試的常考題。chrome

  1. 做爲一個程序員,瞭解程序的運行機制是很重要的,這樣能夠幫助你去輸出更優質的代碼。
  2. 前端是一個範圍很廣的領域,技術一直在更新迭代,掌握了底層的原理能夠應對新的技術。
  3. 一個優秀的程序員要能讓寫的代碼按照本身想的去運行,若是連代碼自己的運行機制都沒法掌握的話,就不用談什麼掌控本身的代碼了。

2、進程和線程

上文我說了 Event Loop 是單線程阻塞問題的一種解決機制,因此在正式開始前仍是要先從進程和線程的角度來聊一聊。衆所周知的一件事是,JavaScript 是一個單線程機制的語言,那咱們先來看看進程和線程的定義:promise

定義

  1. 進程:進程是 CPU 資源分配的最小單位
  2. 線程:線程是 CPU 調度的最小單位

說實話,光從定義來看你根本感覺不到進程和線程究竟是什麼樣的一個東西。簡單來講,進程簡單理解就是咱們日常使用的程序,如 QQ,瀏覽器,網盤等。進程擁有本身獨立的內存空間地址,擁有一個或多個線程,而線程就是對進程粒度的進一步劃分。瀏覽器

更通俗的來講,進程就像是一家工廠,多個工廠之間是獨立存在的。而線程就像是工廠中的那些工人,共享資源,完成同一個大目標。網絡

JS 的單線程

不少人都知道的是,JavaScript 是一門動態的解釋型的語言,具備跨平臺性。在被問到 JavaScript 爲何是一門單線程的語言,有的人可能會這麼回答:「語言特性決定了 JavaScript 是一個單線程語言,JavaScript 天生是一個單線程語言」,這只不過是一層糖衣罷了。

JavaScript 從誕生起就是單線程,緣由大概是不想讓瀏覽器變得太複雜,由於多線程須要共享資源、且有可能修改彼此的運行結果,對於一種網頁腳本語言來講,這就太複雜了。

準確的來講,我認爲 JavaScript 的單線程是指 JavaScript 引擎是單線程的,JavaScript 的引擎並非獨立運行的,跨平臺意味着 JavaScript 依賴其運行的宿主環境 --- 瀏覽器(大部分狀況下是瀏覽器)。

瀏覽器須要渲染 DOM,JavaScript 能夠修改 DOM 結構,JavaScript 執行時,瀏覽器 DOM 渲染中止。若是 JavaScript 引擎線程不是單線程的,那麼能夠同時執行多段 JavaScript,若是這多段 JavaScript 都操做 DOM,那麼就會出現 DOM 衝突。

舉個例子來講,在同一時刻執行兩個 script 對同一個 DOM 元素進行操做,一個修改 DOM,一個刪除 DOM,那這樣話瀏覽器就會懵逼了,它就不知道到底該聽誰的,會有資源競爭,這也是 JavaScript 單線程的緣由之一。

3、瀏覽器

瀏覽器的多線程

以前說過,JavaScript 運行的宿主環境瀏覽器是多線程的。

以 Chrome 來講,咱們能夠經過 Chrome 的任務管理器來看看。

Chrome任務管理器

當你打開一個 Tab 頁面的時候,就建立了一個進程。若是從一個頁面打開了另外一個頁面,打開的頁面和當前的頁面屬於同一站點的話,那麼這個頁面會複用父頁面的渲染進程。

瀏覽器主線程常駐線程

  1. GUI 渲染線程
    • 繪製頁面,解析 HTML、CSS,構建 DOM 樹,佈局和繪製等
    • 頁面重繪和迴流
    • 與 JS 引擎線程互斥,也就是所謂的 JS 執行阻塞頁面更新
  2. JS 引擎線程
    • 負責 JS 腳本代碼的執行
    • 負責準執行準備好待執行的事件,即定時器計數結束,或異步請求成功並正確返回的事件
    • 與 GUI 渲染線程互斥,執行時間過長將阻塞頁面的渲染
  3. 事件觸發線程
    • 負責將準備好的事件交給 JS 引擎線程執行
    • 多個事件加入任務隊列的時候須要排隊等待(JS 的單線程)
  4. 定時器觸發線程
    • 負責執行異步的定時器類的事件,如 setTimeout、setInterval
    • 定時器到時間以後把註冊的回調加到任務隊列的隊尾
  5. HTTP 請求線程
    • 負責執行異步請求
    • 主線程執行代碼遇到異步請求的時候會把函數交給該線程處理,當監聽到狀態變動事件,若是有回調函數,該線程會把回調函數加入到任務隊列的隊尾等待執行

這裏沒看懂不要緊,後面我會再說。

4、瀏覽器端的 Event Loop

看到這裏,總算是進入正題了,先講講瀏覽器端的 Event Loop 是什麼樣的。

JS運行機制圖

上圖是一張 JS 的運行機制圖,Js 運行時大體會分爲幾個部分:

  1. Call Stack:調用棧(執行棧),全部同步任務在主線程上執行,造成一個執行棧,由於 JS 單線程的緣由,因此調用棧中每次只能執行一個任務,當遇到的同步任務執行完以後,由任務隊列提供任務給調用棧執行。
  2. Task Queue:任務隊列,存放着異步任務,當異步任務能夠執行的時候,任務隊列會通知主線程,而後該任務會進入主線程執行。任務隊列中的都是已經完成的異步操做,而不是說註冊一個異步任務就會被放在這個任務隊列中。

說到這裏,Event Loop 也能夠理解爲:不斷地從任務隊列中取出任務執行的一個過程。

同步任務和異步任務

上文已經說過了 JavaScript 是一門單線程的語言,一次只能執行一個任務,若是全部的任務都是同步任務,那麼程序可能由於等待會出現假死狀態,這對於一個用戶體驗很強的語言來講是很是不友好的。

好比說向服務端請求資源,你不可能一直不停的循環判斷有沒有拿到數據,就好像你點了個外賣,點完以後就開始一直打電話問外賣有沒有送到,外賣小哥都會抄着鍋鏟來打你(狗頭)。所以,在 JavaScript 中任務有了同步任務和異步任務,異步任務經過註冊回調函數,等到數據來了就通知主程序。

概念

簡單的介紹一下同步任務和異步任務的概念。

  1. 同步任務:必須等到結果來了以後才能作其餘的事情,舉例來講就是你燒水的時候一直等在水壺旁邊等水燒開,期間不作其餘的任何事情。
  2. 異步任務:不須要等到結果來了才能繼續往下走,等結果期間能夠作其餘的事情,結果來了會收到通知。舉例來講就是你燒水的時候能夠去作本身想作的事情,聽到水燒開的聲音以後再去處理。

從概念就能夠看出來,異步任務從必定程度上來看比同步任務更高效一些,核心是提升了用戶體驗。

Event Loop

Event Loop 很好的調度了任務的運行,宏任務和微任務也知道了,如今咱們就來看看它的調度運行機制。

JavaScript 的代碼執行時,主線程會從上到下一步步的執行代碼,同步任務會被依次加入執行棧中先執行,異步任務會在拿到結果的時候將註冊的回調函數放入任務隊列,當執行棧中的沒有任務在執行的時候,引擎會從任務隊列中讀取任務壓入執行棧(Call Stack)中處理執行。

宏任務和微任務

如今就有一個問題了,任務隊列是一個消息隊列,先進先出,那就是說,後來的事件都是被加在隊尾等到前面的事件執行完了纔會被執行。若是在執行的過程當中忽然有重要的數據須要獲取,或是說有事件忽然須要處理一下,按照隊列的先進先出順序這些是沒法獲得及時處理的。這個時候就催生了宏任務和微任務,微任務使得一些異步任務獲得及時的處理。

曾經看到的一個例子很好,宏任務和微任務形象的來講就是:你去營業廳辦一個業務會有一個排隊號碼,當叫到你的號碼的時候你去窗口辦充值業務(宏任務執行),在你辦理充值的時候你又想改個套餐(微任務),這個時候工做人員會直接幫你辦,不可能讓你從新排隊。

因此上文說過的異步任務又分爲宏任務和微任務,JS 運行時任務隊列會分爲宏任務隊列和微任務隊列,分別對應宏任務和微任務。

先介紹一下(瀏覽器環境的)宏任務和微任務大體有哪些:

  • 宏任務:
    1. script(總體的代碼)
    2. setTimeout
    3. setInterval
    4. I/O 操做
    5. UI 渲染 (對這個筆者持保留意見)
  • 微任務:
    1. Promise.then
    2. MutationObserver

事件運行順序

  1. 執行同步任務,同步任務不須要作特殊處理,直接執行(下面的步驟中遇到同步任務都是同樣處理) --- 第一輪從 script開始
  2. 從宏任務隊列中取出隊頭任務執行
  3. 若是產生了宏任務,將宏任務放入宏任務隊列,下次輪循的時候執行
  4. 若是產生了微任務,將微任務放入微任務隊列
  5. 執行完當前宏任務以後,取出微任務隊列中的全部任務依次執行
  6. 若是微任務執行過程當中產生了新的微任務,則繼續執行微任務,直到微任務的隊列爲空
  7. 輪循,循環以上 2 - 6

總的來講就是:同步任務/宏任務 -> 執行產生的全部微任務(包括微任務產生的微任務) -> 同步任務/宏任務 -> 執行產生的全部微任務(包括微任務產生的微任務) -> 循環......

注意:微任務隊列

舉個栗子

光說不練假把式,如今就來看一個例子:

舉個栗子

放圖的緣由是爲了讓你們在看解析以前能夠先本身按照運行順序走一遍,寫好答案以後再來看解析。
解析:
(用綠色的表示同步任務和宏任務,紅色表示微任務)

+ console.log('script start')
+ setTimeout(function() {
+ console.log('setTimeout')
+ }, 0)
+ new Promise((resolve, reject)=>{
+ console.log("promise1") 
+ resolve()
+ })
- .then(()=>{
- console.log("then11")
+ new Promise((resolve, reject)=>{
+ console.log("promise2")
+ resolve();
+ })
- .then(() => {
- console.log("then2-1")
- })
- .then(() => {
- console.log("then2-2")
- })
- })
- .then(()=>{
- console.log("then12")
- })
+ console.log('script end')
複製代碼
  1. 首先遇到 console.log(),輸出 script start
  2. 遇到 setTimeout 產生宏任務,註冊到宏任務隊列[setTimeout],下一輪 Event Loop 的時候在執行
  3. 而後遇到 new Promise 構造聲明(同步),log 輸出 promise1,而後 resolve
  4. resolve 匹配到 promise1 的第一個 then,把這個 then 註冊到微任務隊列[then11]中,繼續當前總體腳本的執行
  5. 遇到最後的一個 log,輸出 script end當前執行棧清空
  6. 從微任務隊列中取出隊頭任務'then11' 進行執行,其中有一個 log,輸出 then11
  7. 往下遇到 new Promise 構造聲明(同步),log 輸出 promise2,而後 resolve
  8. resolve 匹配到 promise2 的第一個 then,把這個 then 註冊到微任務隊列[then2-1],當前 then11 可執行部分結束,而後產生了 promise1 的第二個 then,把這個 then 註冊到微任務隊列[then2-1, then12]
  9. 拿出微任務隊頭任務'then2-1' 執行,log 輸出 then2-1,觸發 promise2 的第二個 then,註冊到微任務隊列[then12, then2-2]
  10. 拿出微任務隊頭任務'then12',log 輸出 then12
  11. 拿出微任務隊頭任務'then2-2',log 輸出 then2-2
  12. 微任務隊列執行完畢,別忘了宏任務隊列中的 setTimeout,log 輸出 setTimeout

通過以上一番縝(xia)密(gao)分析,但願沒有繞暈你,最後的輸出結果就是:
script start -> promise1 -> script end -> then11 -> promise2 -> then2-1 -> then12 -> then2-2 -> setTimeout

宏任務?微任務?

不知道你們看了宏任務和微任務以後會不會有一個疑惑,宏任務和微任務都是異步任務,微任務以前說過了是爲了及時解決一些必要事件而產生的。

  • 爲何要有微任務?
    爲何要有微任務的緣由前面已經說了,這裏就再也不贅述,簡單說一下就是爲了及時處理一些任務,否則等到最後再執行的時候拿到的數據可能已是被污染的數據達不到預期目標了。

  • 什麼是宏任務?什麼是微任務?
    相信你們在學習 Event Loop 查找資料的時候,確定各類資料裏面都會講到宏任務和微任務,可是不知道你有沒有靈魂拷問過你本身:什麼是宏任務?什麼是微任務?怎麼區分宏任務和微任務?不能只是默許接受這個概念,在這裏,我根據個人我的理解進行一番說(hu)明(che)

  • 宏任務和微任務的真面目
    其實在 Chrome 的源碼中並無什麼宏任務和微任務的代碼或是說明,在 JS 大會上提到過微任務這個名詞,可是也沒有說到底什麼是微任務。

    宏任務
    文章最開始的時候說過,在 chrome 裏,每一個頁面都對應一個進程。而該進程又有多個線程,好比 JS 線程、渲染線程、IO 線程、網絡線程、定時器線程等等,這些線程之間的通訊是經過向對象的任務隊列中添加一個任務(postTask)來實現的。宏任務的本質能夠認爲是多線程事件循環或消息循環,也就是線程間通訊的一個消息隊列。

    就拿 setTimeout 舉例來講,當遇到它的時候,瀏覽器就會對 Event Loop 說:嘿,我有一個任務交給你,Event Loop 就會說:好的,我會把它加到個人 todoList 中,以後我會執行它,它是須要調用 API 的。

    宏任務的真面目是瀏覽器派發,與 JS 引擎無關的,參與了 Event Loop 調度的任務

    微任務
    微任務是在運行宏任務/同步任務的時候產生的,是屬於當前任務的,因此它不須要瀏覽器的支持,內置在 JS 當中,不須要 API 支持,直接在 JS 的引擎中就被執行掉了。

特殊的點

  1. async 隱式返回 Promise 做爲結果

  2. 執行完 await 以後直接跳出 async 函數,讓出執行的全部權

  3. 當前任務的其餘代碼執行完以後再次得到執行權進行執行

  4. 當即 resolve 的 Promise 對象,是在本輪"事件循環"的結束時執行,而不是在下一輪"事件循環"的開始時

再舉個栗子

console.log('script start')

  async function async1() {
      await async2()
      console.log('async1 end')
  }
  async function async2() {
      console.log('async2 end')
  }
  async1()

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

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

  console.log('script end')
複製代碼

按照以前的分析方法去分析以後就會得出一個結果:
script start => async2 end => Promise => script end => promise1 => promise2 => async1 end => setTimeout

能夠看出 async1 函數獲取執行權是做爲微任務的隊尾,可是,在 Chrome73(金絲雀) 版本以後,async 的執行優化了,它會在 promise1 和 promise2 的輸出以前執行。筆者大概瞭解了一下應該是用 PromiseResolve 對 await 進行了優化,減小了 Promise 的再次建立,有興趣的小夥伴能夠看看 Chrome 的源碼。

5、Node 中的 Event Loop

Node 中也有宏任務和微任務,與瀏覽器中的事件循環相似。Node 與瀏覽器事件循環不一樣,其中有多個宏任務隊列,而瀏覽器是隻有一個宏任務隊列。

Node 的架構底層是有 libuv,它是 Node 自身的動力來源之一,經過它能夠去調用一些底層操做,Node 中的 Event Loop 功能就是在 libuv 中封裝實現的。

宏任務和微任務

Node 中的宏任務和微任務在瀏覽器端的 JS 相比增長了一些,這裏只列出瀏覽器端沒有的:

  • 宏任務
    1. setImmediate
  • 微任務
    1. process.nextTick

事件循環機制的六個階段

六個階段

Node 的事件循環分紅了六個階段,每一個階段對應一個宏任務隊列,至關因而宏任務進行了一個分類。

  1. timers(計時器)
    執行 setTimeout 以及 setInterval 的回調
  2. I/O callbacks
    處理網絡、流、TCP 的錯誤回調
  3. idel, prepare --- 閒置階段
    node 內部使用
  4. poll(輪循)
    執行 poll 中的 I/O 隊列,檢查定時器是否到時間
  5. check(檢查)
    存放 setImmediate 回調
  6. close callbacks
    關閉回調,例如 sockect.on('close')

輪循順序

執行的輪循順序 --- 每一個階段都要等對應的宏任務隊列執行完畢纔會進入到下一個階段的宏任務隊列

  1. timers
  2. I/O callbacks
  3. poll
  4. setImmediate
  5. close events

每兩個階段之間執行微任務隊列

Event Loop 過程

  1. 執行全局的 script 同步代碼
  2. 執行微任務隊列,先執行全部 Next Tick 隊列中的全部任務,再執行其餘的微任務隊列中的全部任務
  3. 開始執行宏任務,共六個階段,從第一個階段開始執行本身宏任務隊列中的全部任務(瀏覽器是從宏任務隊列中取第一個執行!!)
  4. 每一個階段的宏任務執行完畢以後,開始執行微任務
  5. TimersQueue -> 步驟2 -> I/O Queue -> 步驟2 -> Check Queue -> 步驟2 -> Close Callback Queue -> 步驟2 -> TimersQueue ...

這裏要注意的是,nextTick 事件是一個單獨的隊列,它的優先級會高於微任務,因此在當前宏任務/同步任務執行完成以後,會先執行 nextTick 隊列中的全部任務,再去執行微任務隊列中的全部任務。

setTimeout 和 setImmediate

在這裏要單獨說一下 setTimeout 和 setImmediate,setTimeout 定時器很熟悉,那就說說 setImmediate

setImmediate() 方法用於把一些須要長時間運行的操做放在一個回調函數裏,並在瀏覽器完成其餘操做(如事件和顯示更新)後當即運行回調函數。從定義來看就是爲了防止一些耗時長的操做阻塞後面的操做,這也是爲何 check 階段運行順序排的比較後。

舉個栗子

咱們來看這樣的一個例子:

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

setImmediate(() => {
  console.log('setImmediate')
})
複製代碼

這裏涉及 timers 階段和 check 階段,按照上面的運行順序來講,timers 階段是在第一個執行的,會早於 check 階段。運行這段程序能夠看到以下的結果:

但是再多運行幾回,你就會看到以下的結果:

setImmediate 的輸出跑到 setTimeout 前面去了,這時候就是:小朋友你是否有不少的問號❓

分析

咱們來分析一下緣由,timers 階段確實是在 check 階段以前,可是在 timers 階段時候,這裏的 setTimeout 真的到了執行的時間嗎?

這裏就要先看看 setTiemout(fn, 0),這個語句的意思不是指不延遲的執行,而是指在能夠執行 setTimeout 的時候就當即執行它的回調,也就是處理完當前事件的時候當即執行回調。

在 Node 中 setTimeout 第二個時間參數的最小值是 1ms,小於 1ms 會被初始化爲 1(瀏覽器中最小值是 4ms),因此在這裏 setTimeout(fn, 0) === setTimeout(fn, 1)

setTimeout 的回調函數在 timers 階段執行,setImmediate 的回調函數在 check 階段執行,Event Loop 的開始會先檢查 timers 階段,可是在代碼開始運行以前到 timers 階段(代碼的啓動、運行)會消耗必定的時間,因此會出現兩種狀況:

  1. timers 前的準備時間超過 1ms,知足 loop -> timers >= 1,setTimeout 的時鐘週期到了,則執行 timers 階段(setTimeout)的回調函數

  2. timers 前的準備時間小於 1ms,還沒到 setTimeout 預設的時間,則先執行 check 階段(setImmediate)的回調函數,下一次 Event Loop 再進入 timers 階段執行 timer 階段(setTimeout)的回調函數

最開始就說了,一個優秀的程序員要讓本身的代碼按照本身想要的順序運行,下面咱們就來控制一下 setTimeout 和 setImediate 的運行。

  • 讓 setTimeout 先執行
    上面代碼運行順序不一樣無非就是由於 Node 準備時間的不肯定性,咱們能夠直接手動延長準備時間👇
const start = Date.now()
  while (Date.now() - start < 10)
  setTimeout(() => {
  console.log('setTimeout')
  }, 0)

  setImmediate(() => {
    console.log('setImmediate')
  })
複製代碼
  • 讓 setImmediate 先執行
    setImmediate 是在 check 階段執行,相對於 setTimeout 來講是在 timers 階段以後,只須要想辦法把程序的運行環境控制在 timers 階段以後就能夠了。

    讓程序至少從 I/O callbacks 階段開始 --- 能夠套一層文件讀寫把把程序控制在 I/O callbacks 階段的運行環境中👇

const fs = require('fs')

fs.readFile(__dirname, () => {
  setTimeout(() => {
    console.log('setTimeout')
  }, 0)
  
  setImmediate(() => {
    console.log('setImmediate')
  })
})
複製代碼

Node 11.x 的變化

timers 階段的執行有所變化

setTimeout(() => console.log('timeout1'))
setTimeout(() => {
 console.log('timeout2')
 Promise.resolve().then(() => console.log('promise resolve'))
})
複製代碼
  1. node 10 及以前的版本:
    要考慮上一個定時器執行完成時,下一個定時器是否到時間加入了任務隊列中,若是未到時間,先執行其餘的代碼。
    好比:
    timer1 執行完以後 timer2 到了任務隊列中,順序爲 timer1 -> timer2 -> promise resolve
    timer2 執行完以後 timer2 還沒到任務隊列中,順序爲 timer1 -> promise resolve -> timer2

  2. node 11 及其以後的版本:
    timeout1 -> timeout2 -> promise resolve
    一旦執行某個階段裏的一個宏任務以後就馬上執行微任務隊列,這和瀏覽器端運行是一致的。

小結

Node 和瀏覽器端有什麼不一樣

  1. 瀏覽器端的 Event Loop 和 Node.js 中的 Event Loop 是不一樣的,實現機制也不同
  2. Node.js 能夠理解成有4個宏任務隊列和2個微任務隊列,可是執行宏任務時有6個階段
  3. Node.js 中限制性全局 script 代碼,執行完同步代碼後,先從微任務隊列 Next Tick Queue 中取出全部任務放入調用棧執行,再從其餘微任務隊列中取出全部任務放入調用棧中執行,而後開始宏任務的6個階段,每一個階段都將其宏任務隊列中的全部任務都取出來執行(瀏覽器是隻取第一個執行),每一個宏任務階段執行完畢以後開始執行微任務,再開始執行下一階段宏任務,以此構成事件循環
  4. 宏任務包括 ....
  5. 微任務包括 ....

看到這裏,你應該對瀏覽器端和 Node 端的 Event Loop 有了必定的瞭解,那就留一個題目。

不直接放代碼是想讓你們先本身思考而後在敲代碼運行一遍~

最後多一嘴

本文到這裏算是結束了,仍是那句話,作一個程序員要知其然更要知其因此然。我寫些文章也是想把知識輸出,檢驗本身是否是真的學懂了。文章中可能還存在一些沒有說清楚的地方或者是有錯的地方,歡迎直接指出~

參考資料

Tasks, microtasks, queues and schedules
JS大會1
JS大會2
《深刻淺出nodejs》

我把個人學習記錄都記錄在了個人 github 而且會持續的更新下去,有興趣的小夥伴能夠看看~
github

相關文章
相關標籤/搜索