Node.js是一個基於事件的平臺。這意味著Node中發生的任何事情都是對於事件的響應。傳入Node的資料處理要經歷一層層巢狀的回撥。這一流程相對於開發者被抽象出來,由一個叫做libuv的庫處理,就是libuv為我們提供了事件迴圈機制。

事件迴圈也許是Node中最容易被誤解的概念。

我為Dynatrace工作,這是一家效能監控服務商。在我們解決事件迴圈監控這一問題時,我們付出了很多努力去正確理解我們正在監測的部分。

這篇文章將包含我們所學到的,事件迴圈是如何工作,以及如何去正確的監控它。

常見錯誤觀念

libuv是為Node.js提供事件迴圈的庫。libuv背後的關鍵人物之一,Bert Belder,在他令人驚歎的 Node Interactive的主題演講的一開始,他以一個Google圖片搜尋的結果展示了人們用來描繪事件迴圈的不同方法,並且他說其中大部分是錯誤的。

我來概括一下(在我看來)最普遍的錯誤觀念。

錯誤觀念 1: 事件迴圈在使用者程式碼中運行於一個獨立的執行緒。

錯誤觀念

一個主執行緒來執行使用者的JavaScript程式碼(使用者程式碼 userland code),另一個執行緒來執行事件迴圈。每當有非同步操作發生,主執行緒將會把非同步操作移交給事件迴圈執行緒,當非同步操作完成,事件迴圈執行緒將會通知主執行緒去執行回撥。

事實

只有一個執行緒來執行JavaScript程式碼而且事件迴圈也執行在這個執行緒之中。回撥(一個執行中的Node.js應用中的任何使用者程式碼都是回撥)的執行通過事件迴圈來完成。後面我們將深入瞭解這些。

錯誤觀念 2: 所有非同步操作通過一個執行緒池來處

錯誤觀念

非同步操作,比如操作檔案系統,發起對外的HTTP請求或者資料庫互動總是需要載入一個由libuv提供的執行緒池。

事實

libuv預設建立一個由四個執行緒組成的執行緒池來載入非同步操作。如今的作業系統已經對很多I/O任務提供了非同步介面 (如Linux中的AIO)。只要有可能,libuv都會使用這些非同步介面而避免使用執行緒池。這同樣適用於第三方的子系統比如資料庫。這些驅動的作者將更傾向於使用非同步介面而不是使用執行緒池。簡而言之: 只有不存在其他方式的時候,非同步I/O才會使用執行緒池。

錯誤觀念 3: 事件迴圈類似於棧或佇列

錯誤觀念

事件迴圈輪詢一個由非同步任務組成的先進先出佇列,當任務完成時執行回撥。

事實

雖然需要類似於佇列的結構,但是事件迴圈並沒有使用棧。事件迴圈就像是一系列的階段以迴圈的方式處理各自具體任務的過程。

理解事件迴圈中的不同階段

為了真正瞭解事件迴圈我們必須去了解它在每個階段做了哪些工作。希望可以得到Bert Belder的認同,以我的方式來展示事件迴圈是如何工作的將會是下面這樣:

讓我們來聊一聊這些階段。全面的解釋可以在Node.js 網站上看到。

Timers

各種通過setTimeout()或者setInterval()設定的定時任務都將在這一階段被處理。

IO Callbacks

這一階段大多數回撥會被處理。這裡指的是使用者程式碼,因為所有在Node.js中的使用者程式碼本質上來說都在回撥中(例如一個剛收到的http請求會觸發一連串巢狀的回撥)。

IO Polling

輪詢將在下一輪事件迴圈中被處理的新事件。

Set Immediate

執行所有通過setImmediate()註冊的回撥。

Close

所有偵聽close事件的回撥將在這一階段被處理。

監控事件迴圈

我們可以看出事實上一個Node應用裡發生的任何事情都是通過事件迴圈來執行的。這意味著如果我們可以從事件迴圈中得到各種指標,這些指標可以在應用大體上的健康情況和效能方面,為我們提供有價值的資訊。由於沒有可以從事件迴圈中獲取到執行時指標的API,各種監控工具提供了各自的指標。來看一下我們所提供的指標。

Tick Frequency

每段時間內完成的週期數量。

Tick Duration

一個週期需要花費的時間。

由於我們的代理可以像原生模組那樣執行,通過新增探針來為我們提供這些資訊是相對容易的。

Tick frequency 和 tick duration 指標在實際中的應用

當我們第一次在不同的負載在進行測試的時候,結果是令人意想不到的----讓我展示一個示例:

在下面的場景中,我將呼叫一個express.js應用來向另外一臺http伺服器傳送請求。

這裡有四個場景:

  1. Idle 沒有收到任何請求。

  2. ab -c 5 利用apache bench一次建立5個併發請求

  3. ab -c 10 10個併發請求

  4. ab -c 10 (slow backend) http伺服器1s後再返回資料來模擬緩慢的後端。這會產生回撥的壓力因為請求在等待的後端返回在Node內部堆積。

如果我們觀察結果圖表,我們可以得出一個有趣的結論:

事件迴圈的持續時間和頻率是動態的,以適應負載的變化。

如果應用是空閒的,意味著沒有待處理的任務(計時器任務或是回撥等等),因為沒有理由去全速完成事件迴圈中的各個階段,因此事件迴圈會調整以適應這一情況,並且會在輪詢階段阻塞一會兒來等待新的外部事件進來。

這也意味著,沒有負載下的指標(低頻率高耗時)與在高負載下緩慢的後端的情況下的指標是相似的。

我們也看到這個示例應用在5個併發請求的場景下執行的狀態最好。

因此週期頻率和週期時間應以當前的每秒請求數為基準。

儘管這些資料已經為我們提供了一些有價值的資訊,但我們依舊不知道時間花在哪一個階段,因此我們做了更加深入的研究,又提出了兩個新指標。

Work processed latency

這個指標用了度量一個非同步任務被執行緒池處理所花費的時間。

高的工作處理時延表明了這是一個忙碌/被耗盡的執行緒池。

為了測試這個指標,我建立了一個express路由,利用一個名叫Sharp的圖片來處理圖片。因為圖片處理是昂貴的,Sharp利用執行緒池來完成對圖片的處理。

執行Apache bench以5個併發連線請求有圖片處理功能的路由的結果直接的反映在這個圖表上,並且能夠很明顯的與中等負載而無圖片處理的場景區分開。

Event Loop Latency

事件迴圈時延用來度量一個通過setTimeout(X)設定的定時任務被處理所花費的時間。

高的時間迴圈時延意味著時間迴圈忙於處理回撥。

為了這次這個指標,我建立了一個express路由,通過一個很低效的演算法來計算斐波那契數列。

執行Apache bench,以5個併發連線呼叫有斐波那契數列計算功能的路由,結果展示了當前回調佇列是忙碌的。

我們清楚地看到上面四個指標可以為我們提供有價值的資訊來幫助我們更好的理解Node.js的內部是如何工作。

所有這些指標都需要從一個更大的圖景來觀察以理解它。因此我們當前正在收集資訊並將這些資料作為參考因素。

調整事件迴圈

事實上,僅有指標而不知道如何採取行動去修正這些問題對我們幫助不大。這裡有一些關於事件迴圈看起來繁忙時應該如何去做的建議。

利用所有的CPU

一個Node.js應用執行在一個單一的執行緒中。這意味著在多核裝置中,負載並沒有被分發到所有的核心上。使用 cluster模組,它使得Node.js可以輕鬆的在每個CPU上建立子程序。每個子程序維護著一個獨立的事件迴圈,並且主程序將負載分發到所有的子程序中。

調整執行緒池

就像上面提到的,libuv將建立一個四個執行緒的執行緒池。這個執行緒池的預設大小可以通過設定環境變數UV_THREADPOOL_SIZE來重寫。雖然這樣可以解決I/O密集型應用的負載問題,但是過高的負載測試例如過大的執行緒池依舊會耗盡記憶體或CPU的資源。

移除服務中的計算密集型工作

如果Node.js花費太多時間在計算密集型操作上,為服務移除這些工作或是使用另一種更適合這個任務的語言將會是一個切實可行的選擇。

總結

讓我們總結一下在這篇文章中我們學到的:

  • 事件迴圈維持著一個Node.js應用的執行

  • 它的功能經常被錯誤的理解----它是需要經歷一系列的階段,每個階段處理不同的任務

  • 事件迴圈沒有提供開箱可用的指標,因此不同的APM服務商收集的指標是不同的。

  • 雖然這些指標提供了關於效能瓶頸有價值的資訊,但是深入理解事件迴圈機制和正在執行的程式碼才是關鍵。

  • 在未來,Dynatrace將增加一個事件迴圈遠端監控技術到根本原因檢測中以將事件迴圈的異常與問題相關聯。

對我來說,毫無疑問的我們剛剛建立了當今市場上最全面的事件迴圈監控解決方案,並且我很開心這些令人激動的新特性將在接下來的幾周內推向我們的使用者。

感謝

Dynatrace中傑出的Node.js代理團隊在事件迴圈監控上付出了很多努力。這篇部落格文章中呈現的大部分發現是基於他們在Node.js內部工作機制方面深入的知識。我想感謝Bernhard Liedl、Dominik Gruber、Gerhard Stöbich和Gernot Reisinger,感謝他們付出的努力以及對我的支援。

我希望這篇文章在這個主題上對讀者確實有所啟發。請關注我的twitter@dkhan,在很高興在那裡或是在下面的評論區裡解答你們的提問。

如果你想繼續瞭解更多事件迴圈的內部工作機制或是作為開發者如何使用事件迴圈,我推薦我朋友發表在RisingStack上的這篇文章 。

如果你想嘗試一下我們的Node.js監控,下載我們的免費試用版並在任何時間分享你的反饋給我——這是我們瞭解使用者的方式。