理解 Node.js 事件循環
本文介紹了 Node.js 事件循環是如何工作,如何使用 Node.js 構建高速應用。文章還會涉及最常見的一些問題及其解決方案。
Node.js at Scale系列:我們正在編寫一系列文章,聚焦于那些大規模使用 Node.js 的公司、有一定 Node 基礎的開發者們的需求。
Node.js at Scale 系列章節:
-
使用 npm
-
npm 技巧與最佳實踐
-
語義化版本和模塊發布
-
理解 Module System、CommonJS 和 require
-
-
深入 Node.js 底層
-
Node.js 事件循環 ( 正是本文 )
-
垃圾回收
-
編寫元素模塊
-
-
Building
-
Node.js 應用結構
-
代碼整潔之道
-
異步處理
-
事件源(Event sourcing)
-
命令查詢與責任隔離
-
-
Testing
-
單元測試
-
E2E 測試
-
-
生產環境的 Node.js
-
應用監控
-
應用調試
-
應用分析
-
-
微服務
-
請求簽名(Request Signing)
-
分布式跟蹤(Distributed Tracing)
-
API 網關(API Gateways)
-
問題提出
多數網站后端是不需要進行復雜運算的。程序多數時間都在等待進行硬盤讀寫,等待網絡傳輸信息、返回答復。
IO 操作可能比數據處理要慢幾個數量級。舉個例子,SSD 可以達到 200-730 MB/s —— 至少高端 SSD 可以達到。讀取 1KB 數據僅需 1.4ms,但在這段時間中,主頻 2GHz 的 CPU 可以進行 28,000 次指令處理周期。
對網絡通信來說,情況還可能更糟糕,ping 下 google.com 試試看:
$ ping google.com
64 bytes from 172.217.16.174: icmp_seq=0 ttl=52 time=33.017 ms
64 bytes from 172.217.16.174: icmp_seq=1 ttl=52 time=83.376 ms
64 bytes from 172.217.16.174: icmp_seq=2 ttl=52 time=26.552 ms
64 bytes from 172.217.16.174: icmp_seq=3 ttl=52 time=40.153 ms
64 bytes from 172.217.16.174: icmp_seq=4 ttl=52 time=37.291 ms
64 bytes from 172.217.16.174: icmp_seq=5 ttl=52 time=58.692 ms
64 bytes from 172.217.16.174: icmp_seq=6 ttl=52 time=45.245 ms
64 bytes from 172.217.16.174: icmp_seq=7 ttl=52 time=27.846 ms
平均延時為 44ms。數據包在網絡上一個來回,前面提到的處理器可以執行 8800 萬次周期。
解決方案
多數操作系統都提供了某種類型的異步 IO 接口,在允許我們在處理那些不依賴于通信結果的數據之外,通信還能繼續...
如今,它主要是通過利用,在額外的軟件復雜度的成本。
數種方式可以達到此目的。如今的完成方式,主要是以額外的軟件復雜性為代碼,挖掘多線程潛力。比方說,在 Java 或 Python 中,文件讀取是阻塞操作。在等待網絡/硬盤通信(network/disk communication)完成時,程序無法做任何其他工作。我們能做的 —— 至少在 Java 中是如此 —— 只能是啟動新的線程,然后在操作完成后通知主線程。
既枯燥又復雜,但能完成任務。那 Node 是怎樣的呢?好吧,因為 Node.js(更準確的說是 V8) 是單線程的,我們肯定也會遇到同樣的問題。我們代碼只能在一個線程中運行。
編者按: 這里所說的并非完全正確。Java 和 Python 都有異步接口,但使用起來要比 Node.js 麻煩得多。
也許你知道,有時候,在瀏覽器中用 setTimeout(someFunction, 0) 能夠神奇地解決一些問題。可是為什么將超時時間設置為 0,將執行延遲 0ms 就能解決問題?難道和立即調用 someFunction 不是一回事嗎?并非如此。
首先,來看看調用棧(call stack),又簡稱作“棧”。我會盡量將問題簡化,因為我們只需要理解調用棧的最基本概念。如果你對此已經熟悉,請直接。
調用棧
調用一個函數時,返回地址(return address)、參數(arguments)、本地變量(local variables)等都會被推入棧中。如果在當前正在運行的函數中調用另一個函數,則該函數的相關內容也會以同樣的方式推到棧頂。
為行文簡便,接下來我將使用“函數被推入棧頂”這樣不太準確的表達。
來看看吧!( 譯者注:下面的示意圖中的一些地方將 square 誤作 sqrt ,請根據代碼甄別。 )
function main () {
const hypotenuse = getLengthOfHypotenuse(3, 4)
console.log(hypotenuse)
}
function getLengthOfHypotenuse(a, b) {
const squareA = square(a)
const squareB = square(b)
const sumOfSquares = squareA + squareB
return Math.sqrt(sumOfSquares)
}
function square(number) {
return number * number
}
main()
首先調用 main 函數:
緊接著以 3 和 4 為參數,調用 getLengthOfHypotenuse 函數:
然后是 square(a) :
square(a) 返回后,從棧中彈出,其返回值賦值給 squareA 。然后 squareA 被添加到 getLengthOfHypotenuse 的調用幀中:
下面計算 square(b) 也是一樣:
下一行是表達式 squareA + squareB 求值:
計算 Math.sqrt(sumOfSquares) :
現在 getLengthOfHypotenuse 剩下的工作就是將計算的最終結果返回:
getLengthOfHypotenuse 返回值被賦值給 main 中的 hypotenuse :
控制臺打印出 hypotenuse :
然后, main 返回,不帶任何值,并從棧中彈出,棧變為空。
注意: 上面提到函數執行完畢后,本地變量從棧中彈出。這僅對 Number、String、Boolean 等基本類型的值成立。對象、數組等值位于堆(heap)中,變量只是指向它們的指針。傳遞的變量其實只是指針,讓這些值在不同的棧幀中可變化。當函數從棧中彈出后,只有指針彈出,而實際值依然還在堆中。當對象失去作用后,由垃圾回收器釋放空間。
事件循環
不不不,不是這種循環。 :)
所以,當我們調用 setTimeout 、 http.get 、 process.nextTick 或 fs.readFile 這樣一些東西時,到底發生了什么?V8 代碼沒有這些,但 Chrome WebApi 和 Node.js 的 C++ API 中有。要了解它們,我們得更好地理解執行順序。
看看一個更一般的 Node.js 應用 —— 監聽 localhost:3000/ 的服務器。收到請求時,服務器會在控制臺上打印一些消息,請求 wttr.in/ ,然后將接收的響應轉發給請求者。
'use strict'
const express = require('express')
const superagent = require('superagent')
const app = express()
app.get('/', sendWeatherOfRandomCity)
function sendWeatherOfRandomCity (request, response) {
getWeatherOfRandomCity(request, response)
sayHi()
}
const CITIES = [
'london',
'newyork',
'paris',
'budapest',
'warsaw',
'rome',
'madrid',
'moscow',
'beijing',
'capetown',
]
function getWeatherOfRandomCity (request, response) {
const city = CITIES[Math.floor(Math.random() * CITIES.length)]
superagent.get(`wttr.in/${city}`)
.end((err, res) => {
if (err) {
console.log('O snap')
return response.status(500).send('There was an error getting the weather, try looking out the window')
}
const responseText = res.text
response.send(responseText)
console.log('Got the weather')
})
console.log('Fetching the weather, please be patient')
}
function sayHi () {
console.log('Hi')
}
app.listen(3000)
請求 localhost:3000 時,除了獲取天氣,還有哪些內容打印出來?
如果你在 Node 方面有些經驗,肯定不會驚訝:在代碼中,盡管調用 console.log('Fetching the weather, please be patient') 在 console.log('Got the weather') 之后,當前者會先打印出來:
Fetching the weather, please be patient
Hi
Got the weather
發生了什么?就算 V8 是單線程的,Node 底層的 C++ API 并不是啊。這意味著,無論何時調用非阻塞的操作,Node 會在底層調用一些和 JavaScript 代碼同時運行的代碼。一旦該隱藏線程接收到等待的值或者拋出錯誤,就會傳入必要參數,調用提供的回調。
注意: 上面所謂的“一些和 JavaScript 代碼同時運行的代碼”,實際上是 libuv 的一部分。libuv 是處理線程池的開源庫,用于處理信號,以及異步任務執行所必要的其他東西。一開始是為 Node.js 開發的,不過目前也有 很多其他項目 在使用。
為了深入底層,我們需要引入兩個新概念:事件循環(event loop)和任務隊列(task queue)。
任務隊列
Javascript 是單線程、事件驅動型語言。這意味著,我們可以為事件添加監聽器,當某一事件觸發時,監聽器執行提供的回調。
調用 setTimeout 、 http.get 或 fs.readFile 時,Node.js 將這些操作發送到另外一個線程,允許 V8 繼續執行代碼。計時完畢或 IO/http 操作完成后,Node 還會調用回調函數。
然后這些回調也可以將其他任務入列,其余亦可依此類推。這樣,在處理請求時還能讀取文件,并根據讀取的內容發送 http 請求,而不會阻塞正在處理的其他請求。
盡管如此,我們只有一個主線程加一個調用棧,所以為避免在讀取那個文件時又去處理另一個請求,回調函數需要等待調用棧變空。回調函數等待執行的中間狀態被稱為任務隊列(又稱作事件隊列、消息隊列)。一旦主線程結束此前工作,回調函數就會在一個無限循環當中被調用,因此叫作“事件循環”。(譯者注:附原文如下)
However, we only have one main thread and one call-stack, so in case there is another request being served when the said file is read, its callback will need to wait for the stack to become empty. The limbo where callbacks are waiting for their turn to be executed is called the task queue (or event queue, or message queue). Callbacks are being called in an infinite loop whenever the main thread has finished its previous task, hence the name 'event loop'.
在上一個例子中,事件循環大概如下所述:
-
express 為“request”事件注冊了一個處理程序,請求 “/” 時會被調用;
-
跳過函數,開始監聽 3000 端口;
-
調用棧為空,等待“request”事件觸發;
-
請求到來,等待已久的事件觸發,express 調用 sendWeatherOfRandomCity ;
-
sendWeatherOfRandomCity 入棧;
-
getWeatherOfRandomCity 被調用并入棧;
-
調用 Math.floor 和 Math.random ,入棧、出棧, cities 中的某一個被賦值給 city ;
-
傳入 'wttr.in/${city}' 調用 superagent.get ,為 end 事件設置處理回調;
-
發送 http://wttr.in/${city} http 請求到底層線程,繼續向下執行;
-
控制臺打印 'Fetching the weather, please be patient' , getWeatherOfRandomCity 函數返回;
-
調用 sayHi ,控制臺打印 'Hi' ;
-
sendWeatherOfRandomCity 函數返回、出棧,調用棧變空;
-
等待 http://wttr.in/${city} 發送響應;
-
一旦響應返回, end 事件觸發;
-
傳給 .end() 的匿名回調函數調用,帶著其閉包內所有變量一起入棧,也就是說,其內部能夠訪問、修改 express, superagent, app, CITIES, request, response, city 以及我們定義的函數;
-
調用 response.send() ,狀態碼為 200 或 500 ,再次發送到底層線程,response stream 不會阻塞代碼執行,匿名回調出棧。
這樣我們就能理解一開始提到的 setTimeout hack 是如何工作的。盡管將時間設置為 0,但是會延遲到當前棧和任務隊列為空后執行,以允許瀏覽器重新繪制 UI,或 Node 處理其他請求。
Microtask 與 Macrotask
實際上,不止一個任務隊列,microtask(小型任務) 與 macrotask(巨型任務)各有一個任務隊列。
Microtask 如:
-
process.nextTick
-
promises
-
Object.observe
Macrotask 如:
-
setTimeout
-
setInterval
-
setImmediate
-
I/O
看看下面的代碼:
console.log('script start')
const interval = setInterval(() => {
console.log('setInterval')
}, 0)
setTimeout(() => {
console.log('setTimeout 1')
Promise.resolve().then(() => {
console.log('promise 3')
}).then(() => {
console.log('promise 4')
}).then(() => {
setTimeout(() => {
console.log('setTimeout 2')
Promise.resolve().then(() => {
console.log('promise 5')
}).then(() => {
console.log('promise 6')
}).then(() => {
clearInterval(interval)
})
}, 0)
})
}, 0)
Promise.resolve().then(() => {
console.log('promise 1')
}).then(() => {
console.log('promise 2')
})
控制臺結果如下:
script start
promise1
promise2
setInterval
setTimeout1
promise3
promise4
setInterval
setTimeout2
setInterval
promise5
promise6
按照 WHATWG 規范,每一次事件循環(one cycle of the event loop),只處理一個 (macro)task。待該 macrotask 完成后,所有的 microtask 會在同一次循環中處理。處理這些 microtask 時,還可以將更多的 microtask 入隊,它們會一一執行,直到整個 microtask 隊列處理完。
下圖展示得更加清楚:
在上面的例子中:
Cycle 1:
-
setInterval 加入 macrotask 隊列;
-
setTimeout 1 加入 macrotask 隊列;
-
Promise.resolve 1 中,兩個 then 加入 microtask 隊列;
-
調用棧變空,microtask 執行。
Macrotask queue: setInterval , setTimeout 1
Cycle 2:
- microtask 隊列為空, setInteval 回調執行,又一個 setInterval 加入 macrotask 隊列,正好位于 setTimeout 1 之后;
Macrotask queue: setTimeout 1 , setInterval
Cycle 3:
-
microtask 隊列為空, setTimeout 1 回調執行, promise 3 和 promise 4 加入 microtask 隊列;
-
promise 3 和 promise 4 執行, setTimeout 2 加入 macrotask 隊列;
Macrotask queue: setInterval , setTimeout 2
Cycle 4:
- microtask 隊列為空, setInteval 回調執行,另一個 setInterval 加入 macrotask 隊列,正好位于 setTimeout 2 之后;
Macrotask queue: setTimeout 2 , setInteval
- setTimeout 2 回調執行, promise 5 和 promise 6 加入 microtask 隊列;
緊接著, promise 5 和 promise 6 的處理程序會清除 interval,但奇怪的是, setInterval 還是運行了一次。不過,如果在 Chrome 中運行代碼,結果和預期是一致的。
譯者注:筆者實際測試發現,情況可能和上面的敘述有所不同。Node v5.12 執行的結果是符合預期的。而 Chrome 53 上,反而出現一些狀況, promise 4 之后, setInterval 執行了兩次,原因未詳,有待進一步追蹤(disqus 評論被墻,我的 V*N 也沒戲)。
使用 process.nextTick 和一些嵌套回調,在也 Node 中也能修復問題:
console.log('script start')
const interval = setInterval(() => {
console.log('setInterval')
}, 0)
setTimeout(() => {
console.log('setTimeout 1')
process.nextTick(() => {
console.log('nextTick 3')
process.nextTick(() => {
console.log('nextTick 4')
setTimeout(() => {
console.log('setTimeout 2')
process.nextTick(() => {
console.log('nextTick 5')
process.nextTick(() => {
console.log('nextTick 6')
clearInterval(interval)
})
})
}, 0)
})
})
})
process.nextTick(() => {
console.log('nextTick 1')
process.nextTick(() => {
console.log('nextTick 2')
})
})
這和上面的邏輯基本一樣,只是看起來比較可怕。至少工作按照預期完成了。
馴服異步怪獸!
如前所見,在編寫 Node.js 應用時,需要管理、留心兩個任務隊列和事件循環 —— 如果想要發揮它們全部的理力量,如果需要避免耗時任務阻塞主線程。
事件循環的概念一開始可能不太好掌握,一旦掌握之后就再也離不開了。可能導致回調地獄的延續傳遞風格看起來很丑,不過我們有 Promise,很快還有 async-await 在手... 在等待 async-await 的時候,還可以使用 co 、 koa 這些工具。
最后一點建議:
了解了 Node.js 和 V8 如何處理長時間任務,可以開始嘗試使用。你之前可能聽說過,應當將耗時循環放入任務隊列。可以手動去做,或者借助 async.js 。
來自:http://www.zcfy.cc/article/node-js-at-scale-understanding-the-node-js-event-loop-risingstack-1652.html