【譯】Node.js中的Event Loop

lanzhiheng發表於2018-12-13

原文連結: flaviocopes.com/node-event-…

指南

為了瞭解Node,Event Loop(後面我會翻譯成“事件迴圈”)是其中最重要的方面。

為什麼它如此重要?因為它表明了Node是怎樣做到非同步並且擁有非堵塞的I/O操作,當然也是使得Node的“殺手級”應用得以成功的重要原因。

Node.js的程式碼在單執行緒上執行。也就是每一個時刻只會發生一件事情。

這是一種限制,實際上卻非常有用,在很大程度上簡化了你的應用程式而不需要擔心併發的問題。

你只需要關心如何去編寫你的程式碼,規避任何會堵塞你執行緒的東西。比如說同步的網路呼叫以及無限迴圈

通常,在大多數的瀏覽器中,每一個瀏覽器的Tab都有一個事件迴圈,這使得每一個處理過程相互隔離,避免網頁陷入無限迴圈或者繁重的處理過程中的時候會堵塞住整個瀏覽器。

特殊的瀏覽器環境管理著多個同時執行的事件迴圈,比如處理Api的呼叫。Web Workers也是執行在它們自己的事件迴圈中。

你主要需要在意的是你的程式碼將會執行在單個事件迴圈上,編寫程式碼的時候把它放在心上,避免堵塞它。

堵塞事件迴圈

任何JavaScript程式碼如果花費太長的時間才能夠把控制權歸還給事件迴圈的話,那將會堵塞頁面中其他JavaScript程式碼的執行,甚至堵塞UI執行緒,使得使用者不能夠點選,滾動頁面等等。

在JavaScript裡面幾乎所有原始的I/O操作都是是非堵塞的。如網路請求,檔案系統操作等等。一般異常情況下才會被堵塞,這也是JavaScript裡面有這麼多的回撥函式的原因,也包括最近出現的promises以及async/await

呼叫棧

呼叫棧是一個LIFO的佇列(後進先出)。

事件迴圈會持續地檢查呼叫棧,看看是否有需要被執行的函式。

在這個過程中,它會把從呼叫棧找到的所有函式新增進來,並依次呼叫它們。

你可能已經熟悉在除錯工具或者瀏覽器console裡面出現的錯誤棧跟蹤資訊了吧?瀏覽器從呼叫棧中查詢函式名,然後告訴你哪個函式是當前呼叫的起源:

error stack

關於事件迴圈的簡單描述

讓我們找個例子:

const bar = () => console.log('bar')

const baz = () => console.log('baz')

const foo = () => {
  console.log('foo')
  bar()
  baz()
}

foo()
複製程式碼

程式碼的列印結果是

foo
bar
baz
複製程式碼

跟預期的一樣,程式碼執行的時候首先呼叫foo函式,接下來在foo內部bar函式將被呼叫,最後再呼叫函式baz

該過程中呼叫棧看起來像這樣。

call stack

事件迴圈在每次迭代中都會檢視呼叫棧中是否有東西,有的話就並執行它:

each interation

直到呼叫棧為空。

佇列中函式的執行

上面的例子看起來很正常,沒有任何特別的東西:JavaScript尋找一些需要執行的事物,並依次執行它們。

接下來讓我們看看如何推遲一個函式的執行,直到呼叫棧為空才執行該函式。

案例setTimeout(() => {}), 0)將會喚起一個函式,但是這個函式將會等到程式碼中的其他函式都被執行完之後才會執行。

舉個例子:

const bar = () => console.log('bar')

const baz = () => console.log('baz')

const foo = () => {
  console.log('foo')
  setTimeout(bar, 0)
  baz()
}

foo()
複製程式碼

這段程式碼的列印結果可能有點出乎意料:

foo
baz
bar
複製程式碼

當這段程式碼執行的時候,首先函式foo被呼叫,在foo內部首先會呼叫setTimeout,這裡傳入bar函式來作為它的第一個引數,另外為了讓它能夠儘快執行,傳入引數0作為計時器的過期時間。接下來再呼叫baz函式。

此時呼叫棧看起來像這樣:

call stack with setTimeout

下面是我們的程式中所有函式的執行順序:

execution order with setTimeout

為什麼會發生這種事情?

訊息佇列

setTimeout被呼叫時,瀏覽器或者Node.js會開啟一個計時器。一旦計時器過期,回撥函式就會被加入到訊息佇列中。而在這個例子中因為我們設定了0作為超時時間,所以函式將會馬上被加入到訊息佇列

訊息佇列是使用者發起的事件,如點選事件或者鍵盤事件存活的地方。fetch的響應在能夠被你程式碼使用之前也被放置於佇列中。又或者是像onLoad那樣的DOM事件。

事件迴圈給予呼叫棧較高的優先順序,首先它會處理所有能夠在呼叫棧中找到的函式,一旦呼叫棧為空,它就開始從訊息佇列中選取函式。

我們的程式不需要停下來等待像setTimeoutfetch或者其他一些類似的函式直到它們完成工作,因為它們是瀏覽器提供的功能,並且存活在他們自己的執行緒中。舉個例子,如果你設定了setTimeout的超時時間為2秒,你並不需要真的停下來等待兩秒後才執行後續的程式碼-這個等待將會在其他地方進行。

ES6工作佇列

ECMAScript 2015提出了一個叫做工作佇列的概念,Promises將會運用這個佇列。這是一個儘可能快速地執行非同步函式的方式,而不是把非同步函式放置在呼叫棧的最後面。

Promises將在當前函式結束之前被解析,且將在當前函式之後被執行。

我找到一個很好的類比,就是娛樂公園的過山車。訊息佇列就像是把你放在佇列之後,排在所有人的後面,你必須要等待你那個回合的到來。工作佇列就像是一個快速通行證,在你完成上一個專案之後你就可以馬上開始下一次的乘坐。

例子:

const bar = () => console.log('bar')

const baz = () => console.log('baz')

const foo = () => {
  console.log('foo')
  setTimeout(bar, 0)
  new Promise((resolve, reject) =>
    resolve('should be right after baz, before bar')
  ).then(resolve => console.log(resolve))
  baz()
}

foo()
複製程式碼

列印結果是

foo
baz
should be right after baz, before bar
bar
複製程式碼

這是Promises(以及構建於promises之上的async/await)與舊的通過setTimeout或者其他平臺API的非同步方式之間比較大的不同。

結論

這篇文章為你介紹了關於Node.js事件迴圈的基本組成部分。

它是任何通過Node.js編寫的程式的基本部分,我希望在這裡闡述的一些概念在將來會對你有所幫助。


閱讀我所有的Node.js教程

相關文章