關于真正理解Node.js事件循環你需要了解的一切
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服務器發送請求。
這里有四個場景:
-
Idle 沒有收到任何請求。
-
ab -c 5 利用
apache bench
一次創建5個并發請求 -
ab -c 10 10個并發請求
-
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,感謝他們付出的努力以及對我的支持。
我希望這篇文章在這個主題上對讀者確實有所啟發。請關注我的推ter@dkhan,在很高興在那里或是在下面的評論區里解答你們的提問。
如果你想繼續了解更多事件循環的內部工作機制或是作為開發者如何使用事件循環,我推薦我朋友發表在RisingStack上的這篇文章 。
如果你想嘗試一下我們的Node.js監控,下載我們的免費試用版并在任何時間分享你的反饋給我——這是我們了解用戶的方式。
來自: