目前,咱們常見的JavaScript運行時(runtime)有兩個,一個是瀏覽器環境,一個是Node.js環境,既然是兩個不一樣的運行時,那麼JavaScript在他們中的執行機制天然不同。本文先講述JavaScript在Node.js環境中的執行機制,在下一篇博文中我會詳細給你們介紹JavaScript在瀏覽器環境中的執行機制。html
首先,咱們着重強調一下Node自身的執行模型——事件循環(event loop),他也是咱們今天的主角。正是它使得Node中的回調函數十分廣泛。在進程啓動時,Node便會建立一個相似於while(true){...}的循環,每執行一次循環體的過程咱們稱爲Tick。每一個Tick的過程就是查看是否有事件待處理,若是有,就取事件及其相關的回調函數。若是存在關聯的回調函數,就執行他們。而後進入下一個循環,若是再也不有事件要處理,就退出進程。node
先來看一張圖,以下:
瀏覽器
圖1.1 The Node.js System
bash
根據上圖,總結Node.js的運行機制以下:架構
(1)V8引擎解析JavaScript腳本。異步
(2)解析後的代碼,調用Node API。socket
(3)libuv庫負責Node API的執行。它將不一樣的任務分配給不一樣的線程,造成一個Event Loop(事件循環),以異步的方式將任務的執行結果返回給V8引擎。ide
(4)V8引擎再將結果返回給用戶。函數
由此,咱們能夠知道,Node中的事件循環是由底層的libuv庫負責執行的,關於libuv庫,簡單介紹一下。起初,Node只能夠在Linux平臺上運行,若是想在Windows平臺上學習和使用Node,則必須經過Cygwin或者MinGW。隨着Node的發展,微軟注意到了它的存在,並投入了一個團隊實現Windows平臺的兼容。兼容Windows和*nix平臺主要得益於Node在架構層面的改動,它在操做系統與Node上層模塊系統之間構建了一層平臺架構,即libuv。oop
圖1.2 Node基於libuv實現跨平臺的架構示意圖
首先要明確的是,Node中的事件循環是運行在單線程的環境下(JavaScript在Node環境中的主線程是單線程的,事件循環的線程也是單線程的,這兩個不是一個線程)。Node做爲一種運行時,它的事件循環是由底層的libuv庫實現的。下面如圖2.1所示,描述了Node中事件循環的具體流程:
圖2.1 Node中事件循環流程
上面的圖例中,將Node事件循環分紅了6個不一樣的階段,其中每一個階段都維護着一個回調函數的隊列,在不一樣的「階段」(咱們使用階段來描述事件循環,它並無任何特別之處,本質上就是不一樣方法的順序調用),事件循環會處理不一樣類型的事件,其表明的含義分別爲:
假設事件循環如今進入了某個階段,即便在這期間有其它隊列中的事件就緒,也會先將當前階段隊列裏的所有回調方法執行完畢後,再進入到下個階段。
下面,咱們來針對Node事件循環的每一個階段進行詳細說明。
timers 階段
這個階段主要用來處理定時器相關的回調,當一個定時器超時後,一個事件就會加入到隊列中,事件循環跳轉至這個階段執行對應的回調函數。定時器的回調會在觸發後儘量早地被調用,這表示實際的延時可能會比定時器規定的時間要長。若是事件循環,此時正在執行一個比較耗時的callback,例如處理一個比較耗時的循環,那麼定時器的回調只能等到當前回調執行結束了才能被執行,即被阻塞。事實上,timers階段的執行受到poll階段的控制,後面會講到。
IO callbacks 階段
Nodejs官網文檔對這個階段的解釋爲:除了timers、setImmediate,以及close操做以外的大多數的回調方法都位於這個階段執行。可是,一些常見的回調,例如fs.readFile的回調是放在poll階段來執行的。根據libuv的文檔,一些應該在上輪事件循環poll階段執行的callback,由於某些緣由不能執行,就會被延遲到這一輪的事件循環的I/O callbacks階段執行。換句話說這個階段執行的callbacks是上輪殘留的。
poll 階段
poll階段的主要任務是等待新的事件的出現(該階段使用epoll來獲取新的事件),若是沒有,事件循環可能會在此阻塞。這些事件對應的回調方法可能位於timers階段(若是定義了定時器),也多是check階段(若是設置了setImmediate方法)。
poll階段主要有兩個步驟以下:
(1)若是有到期的定時器,那麼就執行定時器的回調方法。
(2)處理poll階段對應的事件隊列(如下簡稱poll隊列)裏的事件。
當事件循環到達poll階段時,若是這時沒有要處理的定時器的回調方法,則會進行下面的判斷:
(1)若是poll隊列不爲空,則事件循環會按照順序遍歷執行隊列中的回調函數,這個過程是同步的。
(2)若是poll隊列爲空,會接着進行以下的判斷:①若是當前代碼定義了setImmediate方法,事件循環會離開poll階段,而後進入check階段去執行setImmediate方法定義的回調方法。②若是當前代碼並無定義setImmediate方法,那麼事件循環可能會進入等待狀態,並等待新的事件出現,這也是該階段爲何會被命名爲poll(輪詢)的緣由。此外,還會不斷檢查是否有相關的定時器超時,若是有,就會跳轉到timers階段,而後執行對應的回調。check 階段
setImmediate是一個特殊的定時器方法,它佔據了事件循環的一個階段,整個check階段就是爲setImmediate方法而設置的。通常狀況下,當事件循環到達poll階段後,就會檢查當前代碼是否調用了setImmediate,但若是一個回調函數是被setImmediate方法調用的,事件循環就會跳出poll階段而進入check階段。
close 階段
若是一個socket或者一個句柄被關閉,那麼就會產生一個close事件,該事件會被加入到對應的隊列中。close階段執行完畢後,本輪事件循環結束,循環進入到下一輪。
看完了上面的描述,咱們明白了Node中的事件循環是分階段處理的,對於每個階段來講,處理事件隊列中的事件就是執行對應的回調方法,每個階段的事件循環都對應着不一樣的隊列。
在Node中,事件隊列不止一個,定時器相關的事件和磁盤IO產生的事件須要不一樣的處理方式,若是把全部的事件都放到一個隊列裏,勢必要增長許多相似switch/case的代碼。那樣的話,倒不如將不一樣類型的事件歸類到不一樣的事件隊列裏,而後一個個的遍歷下來,若是當中出現了新的事件,就進行相應的處理。
event loop的每一次循環都須要依次通過上述的階段。 每一個階段都有本身的callback隊列,每當進入某個階段,都會從所屬的隊列中取出callback來執行,當隊列爲空或者被執行callback的數量達到系統的最大數量時,進入下一階段。這六個階段都執行完畢稱爲一輪循環。
process.nextTick的意思就是定義出一個異步動做,而且讓這個動做在事件循環當前階段結束後執行。
process.nextTick其實並非事件循環的一部分,但它的回調方法也是由事件循環調用的,該方法定義的回調方法會被加入到一個名爲nextTickQueue的隊列中。在事件循環的任何階段,若是nextTickQueue不爲空,都會在當前階段操做結束後優先執行nextTickQueue中的回調函數,當nextTickQueue中的回調方法被執行完畢後,事件循環纔會繼續向下執行。
Node限制了nextTickQueue的大小,若是遞歸調用了process.nextTick,那麼當nextTickQueue達到最大限制後會拋出一個錯誤,咱們驗證一下:
function recurse(i){
while(i<9999){
process.nextTick(recurse(i++))
}
}
recurse(0);
//運行結果以下:
//RangeError:Maximum call stack size exceeded複製代碼
既然nextTickQueue也是一個隊列,那麼先被加入隊列的回調會優先執行,咱們驗證一下:
process.nextTick(function(){
console.log('one')
})
process.nextTick(function(){
console.log('tow')
})
console.log('three');
//運行結果以下:
//three
//one
//two複製代碼
和其它回調函數同樣,nextTick定義的回調也是由事件循環執行的,若是nextTick的回調方法中出現了阻塞操做,後面的要執行的回調函數一樣會被阻塞,咱們驗證一下:
process.nextTick(function(){
console.log('one');
//因爲死循環的存在,以後的事件被阻塞
while(true){}
});
process.nextTick(function(){
console.log('tow') //不會被打印
});
console.log('three');
//運行結果以下:
//three
//one
複製代碼
seImmediate方法不屬於ECMAScript標準,而是Node提出的新方法,它一樣將一個回調函數加入到事件隊列中,不一樣於setTimeout和setInterval,setImmediate並不接受一個時間做爲參數,setImmediate的事件會在當前事件循環的結尾觸發,對應的回調方法會在當前事件循環的末尾(check)執行。
setImmediate方法和process.nextTick方法很類似,兩者常常被拿來放在一塊兒比較,因爲process.nextTick會在當前操做完成後馬上執行,所以總會在setImmediate以前執行,咱們驗證一下:
setImmediate(function(param){
console.log("執行"+param);
},"setImmediate");
process.nextTick(function(){
console.log("執行next Tick");
});
//運行結果以下:
//執行next Tick
//執行setImmediate
複製代碼
此外,當有遞歸的異步操做時只能使用setImmediate,不能使用process.nextTick,前面講process.nextTick時已經驗證過這個問題了,就是遞歸調用process.nextTck會出現call stack溢出的狀況。關於遞歸調用setImmediate,咱們驗證一下:
function recurse(i,end){
if(i>end){
console.log('done!')
} else {
console.log(i);
setImmediate(recurse,i+1,end)
}
}
recurse(0,999999999999);複製代碼
運行上面的代碼徹底沒有問題,由於setImmediate不會生成call stack。
經過上面的內容,咱們已經知道了setImmediate方法會在poll階段結束後執行,而setTimeout會在規定的時間到期後執行,因爲沒法預測執行代碼時事件循環當前處於哪一個階段,所以當代碼中同時存在這兩個方法時,回調函數的執行順序是不固定的,咱們驗證一下:
setTimeout(function(){
console.log('timeout');
},0)
setImmediate(function(){
console.log('immediate');
})
//在node環境中,屢次執行上面的代碼,就會發現以下兩種結果:
(1)先輸出timeout,後輸出immediate
(2)先輸出immediate,後輸出timeout
經過上面的分析,咱們知道這種狀況是正常的。複製代碼
可是若是將兩者放在一個IO操做的callback中,則永遠是setImmediate先執行,咱們驗證一下:
require('fs').readFile('foo.txt',function(){
setTimeout(function(){
console.log('timeout');
},0)
setImmediate(function(){
console.log('immediate');
})
})
//屢次執行上面的代碼,始終是先輸出immediate,後輸出timeout複製代碼
這是由於readFile的回調函數執行時,事件循環位於poll階段,所以事件循環會先進入check階段執行setImmediate的回調,而後再進入timers階段執行setTimeout的回調。
文章有些內容直接參考node.js官網文檔來寫的,想了解原汁原味的Node.js event loop,請點擊查看。在libuv官方文檔中也有對event loop的介紹,這裏介紹的更細膩一些,請點擊查看。