JavaScript 運行機制詳解:再談Event Loop
一年前,我寫了一篇 《什么是 Event Loop?》,談了我對 Event Loop 的理解。
上個月,我偶然看到了 Philip Roberts 的演講《Help, I'm stuck in an event-loop》。這才尷尬地發現,自己的理解是錯的。我決定重寫這個題目,詳細、完整、正確地描述 JavaScript 引擎的內部運行機制。下面就是我的重寫。
進入正文之前,插播一條消息。我的新書《ECMAScript 6 入門》出版了(版權頁,內頁1,內頁2),銅版紙全彩印刷,非常精美,還附有索引(當然價格也比同類書籍略貴一點點)。預覽和購買點擊這里。
一、為什么 JavaScript 是單線程?
JavaScript 語言的一大特點就是單線程,也就是說,同一個時間只能做一件事。那么,為什么 JavaScript 不能有多個線程呢?這樣能提高效率啊。
JavaScript 的單線程,與它的用途有關。作為瀏覽器腳本語言,JavaScript 的主要用途是與用戶互動,以及操作 DOM。這決定了它只能是單線程,否則會帶來很復雜的同步問題。比如,假定 JavaScript 同時有兩個線程,一個線程在某個 DOM 節點上添加內容,另一個線程刪除了這個節點,這時瀏覽器應該以哪個線程為準?
所以,為了避免復雜性,從一誕生,JavaScript 就是單線程,這已經成了這門語言的核心特征,將來也不會改變。
為了利用多核 CPU 的計算能力,HTML5 提出 Web Worker 標準,允許 JavaScript 腳本創建多個線程,但是子線程完全受主線程控制,且不得操作 DOM。所以,這個新標準并沒有改變 JavaScript 單線程的本質。
二、任務隊列
單線程就意味著,所有任務需要排隊,前一個任務結束,才會執行后一個任務。如果前一個任務耗時很長,后一個任務就不得不一直等著。
如果排隊是因為計算量大,CPU 忙不過來,倒也算了,但是很多時候 CPU 是閑著的,因為 IO 設備(輸入輸出設備)很慢(比如 Ajax 操作從網絡讀取數據),不得不等著結果出來,再往下執行。
JavaScript 語言的設計者意識到,這時 CPU 完全可以不管 IO 設備,掛起處于等待中的任務,先運行排在后面的任務。等到 IO 設備返回了結果,再回過頭,把掛起的任務繼續執行下去。
于是,JavaScript 就有了兩種執行方式:一種是 CPU 按順序執行,前一個任務結束,再執行下一個任務,這叫做同步執行;另一種是 CPU 跳過等待時間長的任務,先處理后面的任務,這叫做異步執行。程序員自主選擇,采用哪種執行方式。
具體來說,異步執行的運行機制如下。(同步執行也是如此,因為它可以被視為沒有異步任務的異步執行。)
(1)所有任務都在主線程上執行,形成一個執行棧(execution context stack)。
(2)主線程之外,還存在一個"任務隊列"(task queue)。系統把異步任務放到"任務隊列"之中,然后繼續執行后續的任務。
(3)一旦"執行棧"中的所有任務執行完畢,系統就會檢查"任務隊列"。如果這個時候,異步任務已經結束了等待狀態,就會從"任務隊列"進入執行棧,恢復執行。
(4)主線程不斷重復上面的第三步。
下圖就是主線程和任務隊列的示意圖。
只要主線程空了,就會去檢查"任務隊列",這就是 JavaScript 的運行機制。這個過程會不斷重復。
三、事件和回調函數
"任務隊列"實質上是一個事件的隊列(也可以理解成消息的隊列),IO 設備完成一項任務,就在"任務隊列"中添加一個事件,表示相關的異步任務可以進入"執行棧"了。主線程檢查"任務隊列",就是檢查里面有哪些事件。
"任務隊列"中的事件,除了 IO 設備的事件以外,還包括一些用戶產生的事件(比如鼠標點擊、頁面滾動等等)。只要指定過回調函數,這些事件發生時就會進入"任務隊列",等待主線程讀取。
所謂"回調函數"(callback),就是那些會被主線程掛起來的代碼。異步任務必須指定回調函數,當異步任務從"任務隊列"回到執行棧,回調函數就會執行。
四、Event Loop
主線程從"任務隊列"中讀取事件,這個過程是循環不斷的,所以整個的這種運行機制又稱為 Event Loop(事件循環)。
為了更好地理解 Event Loop,請看下圖(轉引自 Philip Roberts 的演講《Help, I'm stuck in an event-loop》)。
上圖中,主線程運行的時候,產生堆(heap)和棧(stack),棧中的代碼調用各種外部 API,它們在"任務隊列"中加入各種事件(click,load,done)。只要棧中的代碼執行完畢,主線程就會去檢查"任務隊列",看看哪些事件已 經完成了,并執行對應的回調函數。
執行棧中的代碼,總是在異步任務之前執行。請看下面這個例子。
var req = new XMLHttpRequest (); req.open ('GET', url); req.onload = function (){}; req.onerror = function (){}; req.send ();
上面代碼中的 req.send 方法是 Ajax 操作向服務器發送數據,它是一個異步任務,意味著只有當前腳本的所有代碼執行完,系統才會去"任務隊列"檢查是否有返回結果。所以,它與下面的寫法等價。
var req = new XMLHttpRequest (); req.open ('GET', url); req.send (); req.onload = function (){}; req.onerror = function (){};
也就是說,指定回調函數的部分(onload 和 onerror),在 send ()方法的前面或后面無關緊要,因為它們屬于執行棧的一部分,系統總是執行完它們,才會去檢查"任務隊列"。
五、定時器
除了插入異步任務,"任務隊列"還有一個作用,就是可以插入定時事件,即指定某些代碼在多少時間之后執行。這叫做"定時器"(timer)功能,即定時執行代碼。
定時器功能主要由 setTimeout ()和 setInterval ()這兩個函數來完成,它們的內部運行機制完全一樣,區別在于前者指定的代碼是一次性執行,后者則為反復執行。以下主要討論 setTimeout ()。
setTimeout ()接受兩個參數,第一個是回調函數,第二個是推遲執行的毫秒數。
console.log (1);
setTimeout (function(){
console.log (2);
},1000);
console.log (3);
上面代碼的執行結果是1,3,2,因為 setTimeout ()將第二行推遲到 1000 毫秒之后執行。
如果將 setTimeout ()的第二個參數設為0,就表示當前代碼執行完,立即執行(0 毫秒間隔)指定的回調函數。
setTimeout (function(){
console.log (1);
}, 0);
console.log (2);
上面代碼的執行結果總是2,1,因為只有在執行完第二行以后,系統才會去執行"任務隊列"中的回調函數。
有些瀏覽器規定了 setTimeout ()的第二個參數的最小值,比如 Firefox 規定不得低于 4 毫秒,如果低于這個值,就會自動調整。另外,對于那些 DOM 的變動(尤其是涉及頁面重新渲染的部分),通常不會立即執行,而是每 16 毫秒執行一次。這時使用 requestAnimFrame ()的效果要好于 setTimeout ()。
需要注意的是,setTimeout ()只是將事件插入了"任務隊列",必須等到當前代碼(執行棧)執行完,主線程才會去執行它指定的回調函數。要是當前代碼耗時很長,有可能要等很久,所以 并沒有辦法保證,回調函數一定會在 setTimeout ()指定的時間執行。
六、Node.js 的 Event Loop
Node.js 也是單線程的 Event Loop,但是它的運行機制不同于瀏覽器環境。
請看下面的示意圖(作者@BusyRich)。
根據上圖,Node.js 的運行機制如下。
(1)V8 引擎解析 JavaScript 腳本。
(2)解析后的代碼,調用 Node API。
(3)libuv 庫負責 Node API 的執行。它將不同的任務分配給不同的線程,形成一個 Event Loop(事件循環),以異步的方式將任務的執行結果返回給 V8 引擎。
(4)V8 引擎再將結果返回給用戶。
Node.js 有一個 process.nextTick ()方法,可以將指定事件推遲到 Event Loop 的下一次執行,也就是當前的執行棧清空之后立即執行。
function foo () { console.error (1); }process.nextTick (foo); console.log (2);// 2 // 1
process.nextTick (foo)的作用,與 setTimeout (foo, 0) 很相似,但是執行效率高得多。
<span id="shareA4" class="fl">
</span>