咱們都知道,javascript從誕生之日起就是一門單線程的非阻塞的腳本語言。這是由其最初的用途來決定的:與瀏覽器交互。javascript
單線程意味着,javascript代碼在執行的任什麼時候候,都只有一個主線程來處理全部的任務。java
而非阻塞則是當代碼須要進行一項異步任務(沒法馬上返回結果,須要花必定時間才能返回的任務,如I/O事件)的時候,主線程會掛起(pending)這個任務,而後在異步任務返回結果的時候再根據必定規則去執行相應的回調。node
單線程是必要的,也是javascript這門語言的基石,緣由之一在其最初也是最主要的執行環境——瀏覽器中,咱們須要進行各類各樣的dom操做。試想一下 若是javascript是多線程的,那麼當兩個線程同時對dom進行一項操做,例如一個向其添加事件,而另外一個刪除了這個dom,此時該如何處理呢?所以,爲了保證不會 發生相似於這個例子中的情景,javascript選擇只用一個主線程來執行代碼,這樣就保證了程序執行的一致性。web
固然,現現在人們也意識到,單線程在保證了執行順序的同時也限制了javascript的效率,所以開發出了web worker技術。這項技術號稱讓javascript成爲一門多線程語言。ajax
然而,使用web worker技術開的多線程有着諸多限制,例如:全部新線程都受主線程的徹底控制,不能獨立執行。這意味着這些「線程」 實際上應屬於主線程的子線程。另外,這些子線程並無執行I/O操做的權限,只能爲主線程分擔一些諸如計算等任務。因此嚴格來說這些線程並無完整的功能,也所以這項技術並不是改變了javascript語言的單線程本質。chrome
能夠預見,將來的javascript也會一直是一門單線程的語言。api
話說回來,前面提到javascript的另外一個特色是「非阻塞」,那麼javascript引擎究竟是如何實現的這一點呢?答案就是今天這篇文章的主角——event loop(事件循環)。瀏覽器
注:雖然nodejs中的也存在與傳統瀏覽器環境下的類似的事件循環。然而二者間卻有着諸多不一樣,故把二者分開,單獨解釋。多線程
當javascript代碼執行的時候會將不一樣的變量存於內存中的不一樣位置:堆(heap)和棧(stack)中來加以區分。其中,堆裏存放着一些對象。而棧中則存放着一些基礎類型變量以及對象的指針。 可是咱們這裏說的執行棧和上面這個棧的意義卻有些不一樣。dom
咱們知道,當咱們調用一個方法的時候,js會生成一個與這個方法對應的執行環境(context),又叫執行上下文。這個執行環境中存在着這個方法的私有做用域,上層做用域的指向,方法的參數,這個做用域中定義的變量以及這個做用域的this對象。 而當一系列方法被依次調用的時候,由於js是單線程的,同一時間只能執行一個方法,因而這些方法被排隊在一個單獨的地方。這個地方被稱爲執行棧。
當一個腳本第一次執行的時候,js引擎會解析這段代碼,並將其中的同步代碼按照執行順序加入執行棧中,而後從頭開始執行。若是當前執行的是一個方法,那麼js會向執行棧中添加這個方法的執行環境,而後進入這個執行環境繼續執行其中的代碼。當這個執行環境中的代碼 執行完畢並返回結果後,js會退出這個執行環境並把這個執行環境銷燬,回到上一個方法的執行環境。。這個過程反覆進行,直到執行棧中的代碼所有執行完畢。
下面這個圖片很是直觀的展現了這個過程,其中的global就是初次運行腳本時向執行棧中加入的代碼:
從圖片可知,一個方法執行會向執行棧中加入這個方法的執行環境,在這個執行環境中還能夠調用其餘方法,甚至是本身,其結果不過是在執行棧中再添加一個執行環境。這個過程能夠是無限進行下去的,除非發生了棧溢出,即超過了所能使用內存的最大值。
以上的過程說的都是同步代碼的執行。那麼當一個異步代碼(如發送ajax請求數據)執行後會如何呢?前文提過,js的另外一大特色是非阻塞,實現這一點的關鍵在於下面要說的這項機制——事件隊列(Task Queue)。
js引擎遇到一個異步事件後並不會一直等待其返回結果,而是會將這個事件掛起,繼續執行執行棧中的其餘任務。當一個異步事件返回結果後,js會將這個事件加入與當前執行棧不一樣的另外一個隊列,咱們稱之爲事件隊列。被放入事件隊列不會馬上執行其回調,而是等待當前執行棧中的全部任務都執行完畢, 主線程處於閒置狀態時,主線程會去查找事件隊列是否有任務。若是有,那麼主線程會從中取出排在第一位的事件,並把這個事件對應的回調放入執行棧中,而後執行其中的同步代碼...,如此反覆,這樣就造成了一個無限的循環。這就是這個過程被稱爲「事件循環(Event Loop)」的緣由。
這裏還有一張圖來展現這個過程:
圖中的stack表示咱們所說的執行棧,web apis則是表明一些異步事件,而callback queue即事件隊列。
以上的事件循環過程是一個宏觀的表述,實際上由於異步任務之間並不相同,所以他們的執行優先級也有區別。不一樣的異步任務被分爲兩類:微任務(micro task)和宏任務(macro task)。
如下事件屬於宏任務:
setInterval()
setTimeout()
如下事件屬於微任務
new Promise()
new MutaionObserver()
前面咱們介紹過,在一個事件循環中,異步事件返回結果後會被放到一個任務隊列中。然而,根據這個異步事件的類型,這個事件實際上會被對應的宏任務隊列或者微任務隊列中去。而且在當前執行棧爲空的時候,主線程會 查看微任務隊列是否有事件存在。若是不存在,那麼再去宏任務隊列中取出一個事件並把對應的回到加入當前執行棧;若是存在,則會依次執行隊列中事件對應的回調,直到微任務隊列爲空,而後去宏任務隊列中取出最前面的一個事件,把對應的回調加入當前執行棧...如此反覆,進入循環。
咱們只需記住噹噹前執行棧執行完畢時會馬上先處理全部微任務隊列中的事件,而後再去宏任務隊列中取出一個事件。同一次事件循環中,微任務永遠在宏任務以前執行。
這樣就能解釋下面這段代碼的結果:
setTimeout(function () {
console.log(1);
});
new Promise(function(resolve,reject){
console.log(2)
resolve(3)
}).then(function(val){
console.log(val);
})
結果爲:
2
3
1
在node中,事件循環表現出的狀態與瀏覽器中大體相同。不一樣的是node中有一套本身的模型。node中事件循環的實現是依靠的libuv引擎。咱們知道node選擇chrome v8引擎做爲js解釋器,v8引擎將js代碼分析後去調用對應的node api,而這些api最後則由libuv引擎驅動,執行對應的任務,並把不一樣的事件放在不一樣的隊列中等待主線程執行。 所以實際上node中的事件循環存在於libuv引擎中。
下面是一個libuv引擎中的事件循環的模型:
┌───────────────────────┐
┌─>│ timers │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ I/O callbacks │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
│ │ idle, prepare │
│ └──────────┬────────────┘ ┌───────────────┐
│ ┌──────────┴────────────┐ │ incoming: │
│ │ poll │<──connections─── │
│ └──────────┬────────────┘ │ data, etc. │
│ ┌──────────┴────────────┐ └───────────────┘
│ │ check │
│ └──────────┬────────────┘
│ ┌──────────┴────────────┐
└──┤ close callbacks │
└───────────────────────┘
注:模型中的每個方塊表明事件循環的一個階段
這個模型是node官網上的一篇文章中給出的,我下面的解釋也都來源於這篇文章。我會在文末把文章地址貼出來,有興趣的朋友能夠親自與看看原文。
從上面這個模型中,咱們能夠大體分析出node中的事件循環的順序:
外部輸入數據-->輪詢階段(poll)-->檢查階段(check)-->關閉事件回調階段(close callback)-->定時器檢測階段(timer)-->I/O事件回調階段(I/O callbacks)-->閒置階段(idle, prepare)-->輪詢階段...
以上各階段的名稱是根據我我的理解的翻譯,爲了不錯誤和歧義,下面解釋的時候會用英文來表示這些階段。
這些階段大體的功能以下:
setTimeout()
和 setInterval()
。setImmediate()
的回調。setImmediate()
的回調會在這個階段執行。socket.on('close', ...)
這種close事件的回調。下面咱們來按照代碼第一次進入libuv引擎後的順序來詳細解說這些階段:
當個v8引擎將js代碼解析後傳入libuv引擎後,循環首先進入poll階段。poll階段的執行邏輯以下: 先查看poll queue中是否有事件,有任務就按先進先出的順序依次執行回調。 當queue爲空時,會檢查是否有setImmediate()的callback,若是有就進入check階段執行這些callback。但同時也會檢查是否有到期的timer,若是有,就把這些到期的timer的callback按照調用順序放到timer queue中,以後循環會進入timer階段執行queue中的 callback。 這二者的順序是不固定的,收到代碼運行的環境的影響。若是二者的queue都是空的,那麼loop會在poll階段停留,直到有一個i/o事件返回,循環會進入i/o callback階段並當即執行這個事件的callback。
值得注意的是,poll階段在執行poll queue中的回調時實際上不會無限的執行下去。有兩種狀況poll階段會終止執行poll queue中的下一個回調:1.全部回調執行完畢。2.執行數超過了node的限制。
check階段專門用來執行setImmediate()
方法的回調,當poll階段進入空閒狀態,而且setImmediate queue中有callback時,事件循環進入這個階段。
當一個socket鏈接或者一個handle被忽然關閉時(例如調用了socket.destroy()
方法),close事件會被髮送到這個階段執行回調。不然事件會用process.nextTick()
方法發送出去。
這個階段以先進先出的方式執行全部到期的timer加入timer隊列裏的callback,一個timer callback指得是一個經過setTimeout或者setInterval函數設置的回調函數。
如上文所言,這個階段主要執行大部分I/O事件的回調,包括一些爲操做系統執行的回調。例如一個TCP鏈接生錯誤時,系統須要執行回調來得到這個錯誤的報告。
在node中有三個經常使用的用來推遲任務執行的方法:process.nextTick,setTimeout(setInterval與之相同)與setImmediate
這三者間存在着一些很是不一樣的區別:
儘管沒有說起,可是實際上node中存在着一個特殊的隊列,即nextTick queue。這個隊列中的回調執行雖然沒有被表示爲一個階段,當時這些事件卻會在每個階段執行完畢準備進入下一個階段時優先執行。當事件循環準備進入下一個階段以前,會先檢查nextTick queue中是否有任務,若是有,那麼會先清空這個隊列。與執行poll queue中的任務不一樣的是,這個操做在隊列清空前是不會中止的。這也就意味着,錯誤的使用process.nextTick()
方法會致使node進入一個死循環。。直到內存泄漏。
那麼合適使用這個方法比較合適呢?下面有一個例子:
const server = net.createServer(() => {}).listen(8080);
server.on('listening', () => {});
這個例子中當,當listen方法被調用時,除非端口被佔用,不然會馬上綁定在對應的端口上。這意味着此時這個端口能夠馬上觸發listening事件並執行其回調。然而,這時候on('listening)
尚未將callback設置好,天然沒有callback能夠執行。爲了不出現這種狀況,node會在listen事件中使用process.nextTick()
方法,確保事件在回調函數綁定後被觸發。
在三個方法中,這兩個方法最容易被弄混。實際上,某些狀況下這兩個方法的表現也很是類似。然而實際上,這兩個方法的意義卻大爲不一樣。
setTimeout()
方法是定義一個回調,而且但願這個回調在咱們所指定的時間間隔後第一時間去執行。注意這個「第一時間執行」,這意味着,受到操做系統和當前執行任務的諸多影響,該回調並不會在咱們預期的時間間隔後精準的執行。執行的時間存在必定的延遲和偏差,這是不可避免的。node會在能夠執行timer回調的第一時間去執行你所設定的任務。
setImmediate()
方法從意義上將是馬上執行的意思,可是實際上它倒是在一個固定的階段纔會執行回調,即poll階段以後。有趣的是,這個名字的意義和以前提到過的process.nextTick()
方法纔是最匹配的。node的開發者們也清楚這兩個方法的命名上存在必定的混淆,他們表示不會把這兩個方法的名字調換過來---由於有大量的node程序使用着這兩個方法,調換命名所帶來的好處與它的影響相比不值一提。
setTimeout()
和不設置時間間隔的setImmediate()
表現上及其類似。猜猜下面這段代碼的結果是什麼?
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
實際上,答案是不必定。沒錯,就連node的開發者都沒法準確的判斷這二者的順序誰前誰後。這取決於這段代碼的運行環境。運行環境中的各類複雜的狀況會致使在同步隊列裏兩個方法的順序隨機決定。可是,在一種狀況下能夠準確判斷兩個方法回調的執行順序,那就是在一個I/O事件的回調中。下面這段代碼的順序永遠是固定的:
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
答案永遠是:
immediate
timeout
由於在I/O事件的回調中,setImmediate方法的回調永遠在timer的回調前執行。
javascrit的事件循環是這門語言中很是重要且基礎的概念。清楚的瞭解了事件循環的執行順序和每個階段的特色,可使咱們對一段異步代碼的執行順序有一個清晰的認識,從而減小代碼運行的不肯定性。合理的使用各類延遲事件的方法,有助於代碼更好的按照其優先級去執行。這篇文章指望用最易理解的方式和語言準確描述事件循環這個複雜過程,但因爲做者本身水平有限,文章中不免出現疏漏。若是您發現了文章中的一些問題,歡迎在留言中提出,我會盡可能回覆這些評論,把錯誤更正。