一篇文章教會你Event loop——瀏覽器和Node

發表於2018-04-03

最近對Event loop比較感興趣,所以瞭解了一下。但是發現整個Event loop儘管有很多篇文章,但是沒有一篇可以看完就對它所有內容都瞭解的文章。大部分的文章都只闡述了瀏覽器或者Node二者之一,沒有對比的去看的話,認識總是淺一點。所以才有了這篇整理了百家之長的文章。

1. 定義

Event loop:為了協調事件(event),使用者互動(user interaction),指令碼(script),渲染(rendering),網路(networking)等,使用者代理(user agent)必須使用事件迴圈(event loops)。(3月29修訂)

那什麼是事件?

事件:事件就是由於某種外在或內在的資訊狀態發生的變化,從而導致出現了對應的反應。比如說使用者點選了一個按鈕,就是一個事件;HTML頁面完成載入,也是一個事件。一個事件中會包含多個任務。

我們在之前的文章中提到過,JavaScript引擎又稱為JavaScript直譯器,是JavaScript解釋為機器碼的工具,分別執行在瀏覽器和Node中。而根據上下文的不同,Event loop也有不同的實現:其中Node使用了libuv庫來實現Event loop; 而在瀏覽器中,html規範定義了Event loop,具體的實現則交給不同的廠商去完成。

所以,瀏覽器的Event loop和Node的Event loop是兩個概念,下面分別來看一下。

2. 意義

在實際工作中,瞭解Event loop的意義能幫助你分析一些非同步次序的問題(當然,隨著es7 async和await的流行,這樣的機會越來越少了)。除此以外,它還對你瞭解瀏覽器和Node的內部機制有積極的作用;對於參加面試,被問到一堆非同步操作的執行順序時,也不至於兩眼抓瞎。

3. 瀏覽器上的實現

在JavaScript中,任務被分為Task(又稱為MacroTask,巨集任務)和MicroTask(微任務)兩種。它們分別包含以下內容:

MacroTask: script(整體程式碼), setTimeout, setInterval, setImmediate(node獨有), I/O, UI rendering
MicroTask: process.nextTick(node獨有), Promises, Object.observe(廢棄), MutationObserver

需要注意的一點是:在同一個上下文中,總的執行順序為同步程式碼—>microTask—>macroTask[6]。這一塊我們在下文中會講。

瀏覽器中,一個事件迴圈裡有很多個來自不同任務源的任務佇列(task queues),每一個任務佇列裡的任務是嚴格按照先進先出的順序執行的。但是,因為瀏覽器自己排程的關係,不同任務佇列的任務的執行順序是不確定的。

具體來說,瀏覽器會不斷從task佇列中按順序取task執行,每執行完一個task都會檢查microtask佇列是否為空(執行完一個task的具體標誌是函式執行棧為空),如果不為空則會一次性執行完所有microtask。然後再進入下一個迴圈去task佇列中取下一個task執行,以此類推。

2655194155-5ab0a0c60c00b_articlex

注意:圖中橙色的MacroTask任務佇列也應該是在不斷被切換著的。

本段大批量引用了《什麼是瀏覽器的事件迴圈(Event Loop)》的相關內容,想看更加詳細的描述可以自行取用。

4. Node上的實現

nodejs的event loop分為6個階段,它們會按照順序反覆執行,分別如下:

  1. timers:執行setTimeout() 和 setInterval()中到期的callback。
  2. I/O callbacks:上一輪迴圈中有少數的I/Ocallback會被延遲到這一輪的這一階段執行
  3. idle, prepare:佇列的移動,僅內部使用
  4. poll:最為重要的階段,執行I/O callback,在適當的條件下會阻塞在這個階段
  5. check:執行setImmediate的callback
  6. close callbacks:執行close事件的callback,例如socket.on(“close”,func)

不同於瀏覽器的是,在每個階段完成後,而不是MacroTask任務完成後,microTask佇列就會被執行。這就導致了同樣的程式碼在不同的上下文環境下會出現不同的結果。我們在下文中會探討。

另外需要注意的是,如果在timers階段執行時建立了setImmediate則會在此輪迴圈的check階段執行,如果在timers階段建立了setTimeout,由於timers已取出完畢,則會進入下輪迴圈,check階段建立timers任務同理。

690666428-5ab0a22b5cbca_articlex

5. 示例

5.1 瀏覽器與Node執行順序的區別

在這個例子中,Node的邏輯如下:

最初timer1和timer2就在timers階段中。開始時首先進入timers階段,執行timer1的回撥函式,列印timer1,並將promise1.then回撥放入microtask佇列,同樣的步驟執行timer2,列印timer2;
至此,timer階段執行結束,event loop進入下一個階段之前,執行microtask佇列的所有任務,依次列印promise1、promise2。

而瀏覽器則因為兩個setTimeout作為兩個MacroTask, 所以先輸出timer1, promise1,再輸出timer2,promise2。

更加詳細的資訊可以查閱《深入理解js事件迴圈機制(Node.js篇)

為了證明我們的理論,把程式碼改成下面的樣子:

按理說setTimeout(fn,0)應該比setImmediate(fn)快,應該只有第二種結果,為什麼會出現兩種結果呢?
這是因為Node 做不到0毫秒,最少也需要1毫秒。實際執行的時候,進入事件迴圈以後,有可能到了1毫秒,也可能還沒到1毫秒,取決於系統當時的狀況。如果沒到1毫秒,那麼 timers 階段就會跳過,進入 check 階段,先執行setImmediate的回撥函式。

另外,如果已經過了Timer階段,那麼setImmediate會比setTimeout更快,例如:

上面程式碼會先進入 I/O callbacks 階段,然後是 check 階段,最後才是 timers 階段。因此,setImmediate才會早於setTimeout執行。

具體可以看《Node 定時器詳解》。

5.2 不同非同步任務執行的快慢

因為我們上文說過microTask會優於macroTask執行,所以先輸出下面兩個,而在Node中process.nextTick比Promise更加優先[3],所以4在3前。而根據我們之前所說的Node沒有絕對意義上的0ms,所以1,2的順序不固定。

5.3 MicroTask佇列與MacroTask佇列

這個例子來源於《JavaScript中的執行機制》。Promise的程式碼是同步程式碼,then和catch才是非同步的,所以4要同步輸出,然後Promise的then位於microTask中,優於其他位於macroTask佇列中的任務,所以5會優於1,6輸出,而Timer優於Check階段,所以1,6。

6. 總結

綜上,關於最關鍵的順序,我們要依據以下幾條規則:

  1. 同一個上下文下,MicroTask會比MacroTask先執行
  2. 然後瀏覽器按照一個MacroTask任務,所有MicroTask的順序執行,Node按照六個階段的順序執行,並在每個階段後面都會執行MicroTask佇列
  3. 同個MicroTask佇列下process.tick()會優於Promise

Event loop還是比較深奧的,深入進去會有很多有意思的東西,有任何問題還望不吝指出。

參考文件:

  1. 什麼是瀏覽器的事件迴圈(Event Loop)
  2. 不要混淆nodejs和瀏覽器中的event loop
  3. Node 定時器詳解
  4. 瀏覽器和Node不同的事件迴圈(Event Loop)
  5. 深入理解js事件迴圈機制(Node.js篇)
  6. JavaScript中的執行機制

相關文章