Javascript 單線程指的是在一個瀏覽器進程中只存在一個 Javascript 執行線程,所以任務需要順序排列等待執行,而不能像 Java 等多線程語言一樣並發執行。但是這種單線程模型在處理耗時的異步任務是會出現較長時間的線程阻塞,導致后續的任務不能被及時處理。所以在 Javascript 中存在異步的處理方式用於處理這種情況,不過嚴格來說所謂的異步,本質上還是借助於多線程的宿主實現的,並發 Javascript 語言本身特性。我想嘗試着總結一下在不同的宿主環境下,Javascript 的異步實現機制。
但凡“ 即是單線程又是異步 ”的語言都有一個共同的特點:它們是 event-driven 的,所以 Javascript 異步的實現也與其事件機制關系密切。
在瀏覽器端:
瀏覽器端的 Javascript 實現了兩個很重要的異步 API,它們分別是 定時器 和 AJAX請求,它們具體都是怎么工作的呢?
定時器
定時器比如 setTimeout 被執行時,由瀏覽器的定時器線程執行的定時計數,而並不是 Javascript 執行線程負責計數,可以想象如果是 Javascript 執行線程負責計數,那必定會造成執行線程的阻塞。定時器線程在定時時間觸發延時事件並將延時事件推入 Javascript 事件隊列。當 Javascript 主線程同步代碼執行完畢時,會去輪詢該事件隊列,取出最開始事件的處理函數推入主線程中被執行。
解釋至此我們可以知道,為什么會說 Javascript 的定時器是不完全准時觸發的呢?因為 Javascript 事件隊列中的任務是被順序取出執行的,如果在定時任務之前還存在其它的任務,或者主線程中的同步任務還沒有被執行完畢,則定時任務會等到這之前的任務全部執行完畢之后,即主線程空閑出來了,才會被取出執行。
一個關於定時器的例子
我們希望在 500 ms 之后觸發定時事件,然而上面這一段代碼在 chrome 中執行的結果定時事件卻是在 1000 ms 后才被觸發。因為我們在定時器的下面寫了一個空循環,在還不到 1000 ms 時 Javascript 主線程不會處於空閑狀態,主線程同步代碼在還沒有執行完畢時,Javascript 不會去取出事件隊列中的回調執行。
AJAX
AJAX 請求和定時器類似,同樣是委托瀏覽器線程代為執行耗時任務,這里是借由瀏覽器的HTTP請求線程發起對服務器的請求,在請求得到響應之后觸發請求完成事件,將回調函數推入事件隊列等待執行。
req.send()方法是 AJAX 向服務器發生數據,它是一個異步任務,而 req.onreadystatechange()屬於事件回調,只有在主線程同步代碼執行完畢之后才會被從事件隊列中取出執行,所以它是在 req.send()方法前面還是后面無關緊要,因為不管處於哪個位置,它都不會被立即執行。
這讓我們想起似乎我們在給 DOM 元素綁定交互事件的時候也是這樣,我們不需要去關心在文件的哪個區域聲明我們的事件監聽函數。其實原理是類似的,當用戶點擊一個綁定點擊處理函數的 DOM 元素時,會有一個點擊事件排入事件隊列,該點擊事件也需要等到當前所有正在運行的代碼結束之后(可能還要等待其它此前已排隊的事件也依次結束),才會執行。
NodeJS端:
NodeJS 的異步實現和瀏覽器端實現有所不同。在 NodeJS 中 Libuv 為 Node.js 提供了跨平台,線程池,事件池,異步 I/O 等能力,是 Node.js 如此強大的關鍵。Libuv 為上層的 Node.js 提供了統一的 API 調用,使其不用考慮平台差距,隱藏了底層實現。Libuv 本身就是異步和事件驅動的,所以,當我們將 I/O 操作的請求傳達給 Libuv 之后,Libuv 開啟線程來執行這次 I/O 調用,並在執行完成后,傳回給 Javascript 進行后續處理。
總結來說,一個異步 I/O 的大致實現流程如下:
發起 I/O 調用
1 用戶通過 Javascript 代碼調用 NodeJS 核心模塊,將參數和回調傳入核心模塊
2 NodeJS 核心模塊將傳入參數和回調封裝為一個請求對象
3 將這個請求對象推入到I/O線程池中等待執行
4 Javascript 發起的異步調用結束,Javascript 線程繼續執行后續操作
執行回調
1 異步任務完成之后,會將結果存放在請求對象的 result 屬性上,並發出操作完成通知
2 每次事件循環時會檢查 I/O 線程池中是否存在已經完成的 I/O 操作,如果有就將請求事件加入到I/O觀察者隊列當中(事件隊列),之后當作事件處理
3 處理I/O觀察者事件時,會將之前封裝在請求對象中的回調函數取出,並將 result 參數傳入執行,以完成 Javascript 回調的目的
我們知道 NodeJS 非常適合開發 IO 密集型應用,但並不適合開發 CPU(計算) 密集型應用。為什么會這樣呢?因為 NodeJS 異步的天性,在處理並發 IO 的時候不會阻塞主線程,實際上這種異步是借助於多線程實現的,IO 任務完成之后排隊等待主線程執行。因為 NodeJS 主線程執行同步代碼的速度非常之快,所以完全可以 hold 得住大規模的並發請求。但這也有存在例外,如果 NodeJS 主線程在執行同步任務的時候遇到一些計算量非常大,或者執行循環太久,等非常耗時的操作的時候,就會導致后續的代碼以及事件隊列里已完成的 IO 任務遲遲得不到執行,嚴重拖垮 NodeJS 的性能。
完結
參考:
JavaScript 運行機制詳解:再談Event Loop
深入淺出 NodeJS