Event Loop到底是什麼?

2020-04-08   sandag

javascript程式設計師喜歡說各種高逼格的術語:「event-loop」,"non-blocking","callback","asynchronous","single-threaded"和「concurrency」等(譯者註:確實如此,一些所謂的面試官在自己對這些概念一知半解的情況下也喜歡問這些概念)。

我們總是裝出一胸有成竹的樣子說著這樣的話:「不要阻塞event loop」,"你要確保你的代碼以60FPS(frames-per-second)的速度去運行",「你這麼搞法肯定是不行啦,那個函數是一個異步執行的callback啊」。

如果你是像我一樣的慫貨,你肯定會點點頭,表示十分認同的樣子。儘管你也不知道他說得對不對,因為你不知道這些術語到底是啥意思。與此同時的事實是,找到一些講述javascript如何被解釋執行的好資料也是相對艱難的。(如今我花了18個月去做了這方面的研究),所以,我們一起學習一下吧。

在一些便利的可視化工具的幫助下,我們可以很直觀地理解到javascript的運行時到底發生了什麼。

演講正文

大家好,感謝大家蒞臨side track(譯者註:side track是啥?)。it is awesomen to see it packed out in here。大家請容許我伸伸懶腰,這樣一來,我整個人看起來不至於那麼僵硬。我想跟大家談談event loop-event loop到底是什麼鬼, 尤其是javascript的event loop到底是什麼鬼?

首先,請容許我自我介紹一下。就像他(指主持人)說的那樣,我在AdnYet工作。AdnYet是美國的一個很不錯的軟體小廠。如果大家在實時軟體方面有需求的話,可以聯繫我們。我們最擅長這方面的研發了。

言歸正傳。18個月前,我是一個專業的在職javascript開發者。我常常自問:「javascript是如何運行的呢?」。每到這個時候,我的內心都沒有一個很確定的答案。我聽過v8團隊,也聽過v8作為javascript在chrome的運行時而存在。除此之外,我就一無所知了。我完全不知道v8具體意味著什麼(應該是指v8在瀏覽器內核的中分工角色是什麼),具體又是做什麼的。我聽過比如「single threaded」這些術語,我也知道我自己目前在用的就是叫「callback」。但是callback的運行原理是怎樣的呢?為了對這些習以為常的概念進行深究,我開始了我的探索旅程。探索過程中,我一邊確定探索主題,一邊查閱資料和在瀏覽器上做試驗。其過程用擬人手法可以簡單描述為如下的對話形式:

  • 我:「javascript,你是誰?」。
  • javascript:「我?我是一門單線程,可並發的程式語言啊」。
  • 我:「哦,算你狠(作無語狀)」。
  • javascript:「好吧,我說得更具體點吧。我有一個call stack,一個event loop,一個callback queue和其它的一些API之類的東西」。
  • 我喃喃自語:「叼,我又不是學CS(computer sciense)的,鬼知道你說的這些術語是什麼。」

後來,我聽聞了v8,並且也知道,除了v8和chrome,外界還有其它各種javascript的運行時和瀏覽器。我就跑去問v8了。

  • 我:「v8,v8,你是不是有一個call stack,一個event loop,一個callback queue和其它的一些API之類的東西」。
  • v8:"我有一個call stack和一個heap,你說的其它那些東西我就不知道了。"
  • 我:「乜你甘得意噶」。

基本上就是這樣,18個月過去了。經過這18個月的上下求索,我現在終於搞清楚了。而這18個月研究所得出的乾貨,就是我今天要分享給你們的東西了。我希望這些乾貨能夠幫助到那些javascript新手去理解「相比於其它程式語言,為什麼javascript顯得那麼怪異呢?」,「為什麼callback讓我們又愛又恨,但是又不可或缺呢?」。如果你是經驗豐富的javascript開發者,希望這次分享能為你提供一個視角去理解你當前所用的運行時是如何工作的。而後,你能夠更好地編排你的代碼。

當我們把Chrome中的javascript運行時平台v8單獨拎出來看的話,我們發現它是很好的javascript運行時學習對象。v8主要包含兩部分:heap和callstack。heap,是內存分配所發生的地方。call stack就是你的stack frame所在的地方。但是,如果你把v8的源碼下載下來,然後在裡面全局搜索一下「setTimeout」,「DOM」或者「HTTP request」等字眼,你會發現,它們根本就不在v8的源碼裡面。這是一路研究下來最讓我意外的發現。當我們提到異步機制時第一反應所想到的東西竟然不在那個你自以為是的v8裡面。知道真相的你,不知道眼淚有沒有掉下來呢?

18個月的探索下來,我發現我所要研究的主題所涉及到的真的真的是一張很大的知識網。正因為知識網之巨大,之有價值,我才特別地希望你跟我一道學習學習。這樣一來,你就會明白各種圍繞v8零散的知識點是什麼?我們已經有了一個v8。現在,給你介紹一個由瀏覽器提供實現的,叫「web API」的東西,比如DOM,AJAX和 time out(setTimeout和setInterval)之類的東西就是歸屬於「web API」這裡面的。同時,我們還有神神秘秘的event loop和callback queue。我相信你之前也肯定聽說過這些術語,但也許你不太懂得這些術語是如何關聯起來,有機組成一個知識網的。我打算從最基礎,最常見的一些術語開始講。你們的一部分人可能已經了理解透,另外一部分可能並沒有。我覺得大部分人應該是屬於後者的。如果你是前者,那麼忍受一下我的嘮叨吧。

again,javascript是一門single threaded的程式語言。這個single threaded的程式語言的runtime有一個single call stack。一個時間段中,call stack只能做一件事。一個時間段中,程序只能執行一片代碼片段,這就是所謂的「single threaded」的意思。「single threaded」,「single call stack」和「do one thing at a time」這三者的關係如下:

single threaded === single call stack === do one thing at a time

現在,讓我們開始嘗試通過可視化將我們腦海裡面零散理解組織起來。如果我們有你們左邊的代碼(演講者指向螢幕的ppt):

function multiply(a,b){
return a * b;
}

function square(n) {
return multiply(n,n);
}

function printSquare(n) {
var squared = square(n);
console.log(squared);
}

printSquare(4);

這裡面一個叫multiply的函數,負責將兩個數字相乘。一個叫square的函數,負責調用multiply來實現一個數的平方。然後有一個叫printSquare的函數負責將調用square的結果用console.log方法列印出來。最後,我們在文件的底部開始調用print函數。

這些代碼應該沒寫錯吧?理解起來沒問題吧?嗯,那我們開始把它跑起來。我們上一個ppt。可以這麼說,call stack基本上是一種用來告訴我們當前是執行到程序的哪裡的數據結構。如果我們執行到一個函數調用,那麼我們就把一些東西放到棧頂,如果我們從一個函數裡面return出去,那麼我們就把棧頂的那些東西pop出來。這就是call stack能做的事情。當你運行這個文件的時候,我可以把這個文件本身當作是整個程序的main函數。所以,一開始,我們把main函數放進去。從文件的頂部開始看,接下來是一些函數聲明,they are just like defining the state of the world。最後,我們來到了printSquare函數的調用。一提到函數調用,我們得馬上把它push 到call stack的頂部。而在執行printSquare函數的過程中,首先遇到了square函數調用,所以,我們馬上又把square函數push到call stack的頂部。而在執行square過程中,它又調用multiply函數。與此類推,我們又把multiply函數push進去。現在call stack頂部的是multiply函數,那麼我們就先執行它。它對A和B執行乘法操作後,就把結果return出去。一旦我們遇到return語句,我們就把call stack頂部的函數pop出來。所以,我們把multiply函數pop走,現在程序返回到square函數,因為遇到了return語句,與此類推,我們把square從call stack頂部pop走,回到了printSquare函數。最後,我們調用console.log把結果列印出來。很明顯,這裡已經沒有return了,我們到了函數的底部了。通過這種可視化的方式來講解,不知道你們明白了沒?(yes,Phil(譯者註:演講者自己用女生的聲明模仿觀眾說了這話))。即使你之前腦海裡面沒有call stack這個清晰的概念,但是如果你做過瀏覽器端的開發的話,那麼你已經遇到過它了。在舉個例子,如果我們有一下代碼:

function bar(){
throw new Error('Oops');
}
function bar(){
Foo()
}
function baz(){
bar()
}

baz()

如果我們把以上代碼在Chrome瀏覽上運行的話,我們在console就會看到列印出來的stack trace。是的,stack trace就是執行出錯的時候call stack的當前狀態。

從上往下看,我們依此看到:

unaugth error: Oops
Foo
bar
baz
anonymous

最後的那個anonymous function就是我們的main函數。

同樣的,你也許聽過這樣的術語「blowing the stack」,那下面就是一個例子:

function Foo(){
Foo()
}

在這個例子當中,main函數調用了Foo函數,然後在Foo函數又調用了Foo函數,與此類推。當我們執行這段代碼的時候,chrome會說,遞歸調用Foo函數16000次可能不是你的本意,我現在報個錯,殺死掉進程,好讓你定位到bug的所在。

所以,雖然我可能會讓你看到了call stack的新的一面,但是其實在實際的開發中你多少對它已經有點印象了。

我們講完了call stack。接下來的大主題就是blocking(阻塞)。我們將會討論什麼是blocking和blocking的行為表現是如何的。

實際上,對於blocking和非blocking這兩個概念並沒有十分嚴格的定義。 在我看來,blocking就是指執行得十分緩慢的代碼而已 。舉個例子,console.log不慢,但是如果執行一個1到100億的循環後去做console.log,那麼我們可以說這個console.log是很慢的。網絡請求是慢的,圖片請求也是慢的。當一個被push進call stack的函數執行起來很慢的話,我們就可以說這個函數是blocking的。 這就是我們平時提到的blocking的實際含義。在這裡,我們有一個用偽代碼寫成的小例子。

var foo = $.getSync('//foo.com');
var bar = $.getSync('//bar.com');
var qux = $.getSync('//qux.com');

console.log(foo);
console.log(bar);
console.log(qux);

裡面有個jQuery AJAX請求風格的getSync方法。如果這些getSync方法執行起來都是同步的,那麼會發生什麼呢?我們暫時先忘記異步callback其實本質也是同步的這個事實。按照我們的寫法,執行起來的結果將會是這樣的:我們調用了getSync方法,然後接下里就是等待。因為我們正在發起了網絡請求,而網絡請求是於計算機硬體相關聯的,它們往往是很慢的。好在,我們的第一個網絡請求終於完成了。我們接著發起第二個網絡請求,然後我們接下來還是等待。第二個網絡請求完了,我們馬上發起第三個......與此類推。只要其中的一個網絡請求永遠都不會完成的話,那麼我覺得我現在可以回家洗洗睡了。最終,這三個網絡請求的blocking行為完成了,call stack得以清空掉,是吧?所以,如果在一個single threaded的程式語言裡面,你是不能像ruby那樣使用線程的話,那麼,就會出現像這個例子所演示的情形那樣-我們發起了一個網絡請求,在它完成之前,我們只有乾等待。除此之外,我們無計可施啊。為什麼我們將這種結果當作一個問題而存在呢?一切的一切都是因為我們的代碼跑在瀏覽器端。

下面我再舉一個例子進行說明。

這是chrome瀏覽器,而這是我們上一個例子用過的代碼。我們假設瀏覽器沒有為我們提供同步的AJAX請求(好吧,事實上是有提供的),那麼我們就使用一個耗時5秒的大循環來模擬一下這種同步請求。接下里,我們打開console,我們將會看到發生了什麼。我們看到,當我們在請求foo.com的時候,我們對介面做的什麼操作都沒有得到響應。即使是我剛才點擊過的按鈕還是處於凹陷的狀態,還沒完成它的重新渲染。是的,瀏覽器被阻塞住了,它被卡得像便秘的屎一樣,一動不動。在這些網絡請求完成之前,瀏覽器做不了任何事情。瀏覽器雖然知道了我在介面進行了一些操作,但是它自己對於我的操作無法作出響應,也即是說它無法進行有效的介面渲染。這是因為,我們的call stack到目前為止還是沒有處於清空狀態,所以瀏覽器無法對介面進行重渲染,也執行不了其它代碼。介面被卡住了,我們十分不開心,是吧?

作為開發者,如果我們想用戶看到一個好看的,流暢的介面的話,那麼我們就不能阻塞call stack。我們應該怎麼去解決這個問題呢?好吧,最簡單的解決方案就是異步callback方案。在瀏覽器中,幾乎沒有哪個函數是blocking的,在nodejs裡面也是一樣的。所有會阻塞call stack的函數都會被改成異步的。「將函數改成異步」基本是是這樣的模式:我們需要執行某些代碼,與此同時,我們會提供一個callback給當前的運行時。當需要執行的代碼完成了,運行時就會執行我們提供的callback。如果你見過javascript,那麼你也就見過了異步callback的樣子了。

下面是一個簡單的例子:

console.log('hi');

setTimeout(function(){
console.log('there');
},5000);

console.log('JSConfEU');

我們用這個簡單的例子提醒一下大家我們講到哪裡。我們console.log "hi",然後執行setTimeout。但是這個setTimeout的執行會把console.log入隊到未來某個時刻去執行。我們跳過這個log,轉而去log「JSConfEU」。5秒鐘之後,我們會看到一個「there」的log,是吧?這個過程沒問題吧?不錯。基本上,我們都知道這就是setTimeout要做的事情。顯然,異步callback是跟我們之前見到的call stack有關的。那麼是如何相關法呢?我們先把代碼跑起來。首先console.log('hi')入棧,然後是setTimeout。我們知道這個setTimeout是不會馬上執行的,而是會在5秒鐘之後才執行。我們不能把它推入到棧中。因為我們目前還沒有找到恰當的方式去描述它,所以就暫時假設它無緣無故消失了。當然,我們在後面會回來講它的。setTimeout無緣無故消失後,我們就將console.log('JSConfEU')入棧,然後log「JSConfEU」。最後,call stack被清空了。5秒鐘之後,console.log('there')神奇地出現在call stack上面了。這是怎麼回事呢?這時候該是event loop和concurrency出場的時候了。是的,我已經反反覆復跟你說,javascript在一個時間段裡面只能做一件事件。打個比方,你不能在執行其它代碼的時候讓它(譯者註:它指的是javascript運行時)發起一個AJAX請求。你不能在執行其它代碼的時候執行setTimeout。而我們之所以能以並行的方式去做事情,那是因為瀏覽器的能力遠在javascript運行時之上(譯者註:瀏覽器是javascript運行時的超集,javascript運行時之外還有別的東西)。記住下面這種圖:

確實,javascript運行時在一個時間段裡面只能做一件事,但是瀏覽器給了我們其它的東西。它給了我們一個叫webAPI的東西,這些都是一些有效的線程。你可以調用它們,它們能夠知道並發的發生,並承接住這些並發。如果你是後端開發人員。這張圖所闡述的機制跟nodejs差不多。在nodejs裡面,webAPI得換成了c++ API了。當然,還有一些被c++隱藏起來的線程。既然,我們已經有了這張圖,我們不妨以「瀏覽器」這個全局的視角來看看這段代碼是如何被執行的。

跟之前演示的那樣,執行console.log「hi」,把「hi」列印到控制台,接下來,來看看當我們調用setTimeout的時候到底發生了什麼。我們把一個callback函數和需要延遲的毫秒數傳入到setTimeout調用中。現在,對於我們來說,setTimeoout就是一個由瀏覽器提供給我們的API。它的實現源碼並不在v8的源碼裡面。這是正在運行中的javascript運行時之外的東西。瀏覽器會另外給你設定一個倒計時。瀏覽器中的這部分代碼負責為你倒數時間。這也就是說,setTimeout調用已經完成了,我們二話不說就把它從call stack中pop出去。接著是log「JSConfEU」。我們已經在webAPI中啟動了一個5秒中倒計時。

現在,webAPI不能直接修改你的代碼。它不能在自己準備好的時候直接將一些代碼塊推入到call stack中。如果它真的這麼乾的話,這些代碼塊就會隨機地插入到你當前代碼的中間,這豈不是亂套了。到了這裡,是時候讓task queue或者說callback queue(譯者註:之後統一採用「task queue」的叫法)參與進來了。任何一個web API一旦它完成了自己的任務後,就會把相關聯的callback推入到task queue中。最終的最終,我們終於湊齊了講解了event loop,本次演講的題目:「what the heck is the event loop?」的所有要素了。

那到底什麼是event loop呢?event loop就好像是whole equation(整個方程?)中的一個最簡單的小片段。它有一個簡單的任務。那就是同時監視call stack和task queue。如果call stack當前處於清空狀態的話,那麼event loop就會把task queue的排在第一個東西(callback)pop出來,推入到call stack中去運行。 回歸到這個演示中來。我們看到當前call stack是為空的,而且有個一callback在task queue上。看到自己來活了,event loop馬上就運作起來的。它把這個callback推入到call stack中。時刻記住,call stack是javascript的地盤,它背靠v8這顆大樹。它的地盤它做主。於是乎,這個剛出現的callback馬上就被執行了-console.log('there')被執行了,在控制台,「there」被列印出來。到這,call stack被清空了,我們的代碼徹徹底底被執行完了。大家理解了沒呢?everyone,where me?(譯者註:意思是大家都沒睡著吧,看到我嗎?)完美!

好的,我們已經看到了event loop的基本工作流程了。在你跟異步編程打交道的過程中,你遇到的眾多情景中的其中一個是這樣的:人家不跟你說明到底啥原因,就是讓你用0毫秒去調用setTimeout。這時候你可能心裡在想:「稍等,你讓我在0毫秒後執行這個函數。我為什麼要把我的代碼包裹在一個0毫米的setTimeout裡面呢?這樣做意義何在呢?」。當你第一次遇到這種情況的時候,應該跟我一樣,肯定很困惑。我們都知道這種寫法肯定是做了什麼,但是不知道其中的具體原因。這裡也不賣關子了,具體原因就是人們想要把代碼延遲到call stack清空後才執行。那麼下面我們來演示一下用0毫秒去調用setTimeout的情況。如果你寫過javascript代碼,你就會知道跟上面的例子(非0毫秒去調用setTimeout)的結果是一樣的。 我們將會看到依次列印「hi」和「JSConfEU」,「there」將會在最後列印。下面我們看看具體的演示。在call stack以0毫秒去調用setTimeout,setTimeout馬上就會從call stack中pop走。接著它的callback會馬上被webAPIpush到task queue中。請記住我說過關於event loop的話。event loop必須等到當前call stack清空之後才能把task queue中的callback推入到call stack去的。所以,當前沒有清空的call stack會繼續執行。於是乎,我們先看到列印"hi",然後看到列印「JSConfEU」,此時call stack已經清空了,是時候event loop參與進來的,最後調用了你的callback。不管基於何總原因,我們都可以把代碼的執行延遲到call stack最後一幀或者等到call stack清空後再執行。上面提到的以0毫秒去調用setTimeout只是這種做法的一個示例而已。所有的webAPI都是以相同的原理在工作。現在假設我們要準備著callback向一個URL發起一個AJAX請求。這代碼的執行原理跟上面提到的setTimeout都是一樣的。oops,對不起。console列印「hi」,然後瀏覽器使用webAPI發起了AJAX請求。記住,真正實現發起AJAX請求的原始碼不在javascript的運行時中,而是在瀏覽器對webAPI的實現源碼裡面。所以,我們保存著allback,把小菊花轉起來,默默等待。然後,繼續執行我們在call stack中代碼。直到AJAX請求完成或者永遠都完成不了。現在咱們假設這個AJAX請求完成了,那麼它對應的callback會馬上被推入到task queue中,因為此時call stack清空了,所以,它被event loop選中了,推入到call stack中跑起來了。javascript的異步調用背後所發生的事情大概就是這麼多了。

下面,讓我們來跑一個瘋狂的,複雜的示例吧。我希望這段代碼能夠跑起來。可能你們不知道吧,這次演講的所有PPT我都是在keynote上完成了。單單是當前這張幻燈片就有大概500個動畫步驟(代碼爆炸,化為灰燼,觀眾大笑)。

譯者註:外國友人很少會把PPT稱為「PPT」,正統的叫法應該「Slides(片子)」或是「Deck」,前者意指單張或幾張PPT頁,後者意指一整套PPT報告(就像a deck of cards)。

哇,真糸得意。我在這裡給出個連結。嗯....大夥們,我放得夠大嗎?大家能看見嗎?好的。基本這次演講思路和PPT都是沿用了今年初Scotlan JS上的東西。在那次演講之後,我損壞了大半部分的PPT。然後,自己又沒有耐心去重做那些PPT。因為用keynote做PPT真的是一件(坐到)屁股疼的事情。所以,我選擇了一條捷徑。我自己寫了一個工具用來可視化javascript的運行時機制。這個工具叫做loop。下面,我們用這個新工具把這個例子跑起來吧。

console.log('Started');

$.on('button', 'click', function onClick(){
console.log('Clicked');
});

setTimeout(function onTimeout(){
console.log('Timeout finished');
}, 5000);

console.log('Done');

這個例子基本上跟前面的例子差不多,只不過我沒有使用shim過的XHR。使用shim過的XHR來演示完全可行,只不過我這裡沒有這麼干而已。正如你所看到的那樣,我們的代碼是就是為了log點東西出來。首先,$.on()是對addEventListener的一個封裝,然後是以5000毫秒調用setTimeout。最後,log一個「Done」。下面我們真正把代碼跑起來,看看到底發生了什麼。

代碼開始後,列印個「Started」後,剛入棧的$.on()和setTimeout都會馬上被pop出去,然後加入到webAPI的地盤中去。call stack中的代碼繼續執行。5000毫秒到了,我們就把callback推入到task queue中去。此時call stack處於清空狀態,所以callback會被推入到call stack去執行。最後列印出「Timeout finished」,代碼執行完畢。如果我點擊一下頁面的按鈕,那麼瀏覽器就會觸發webAPI的執行,把點擊事件的callback推入到task queue中去。call stack為空,然後把callback推入到call stack中執行。如果我們連續點擊100多次,我們能看到會發生什麼。在我點擊按鈕之後,click的callback不會被馬上執行,而是推入到task queue。當event loop開始安排task queue的時候,click的callback才能被處理到。是吧?我還有好幾個例子。我們將通過這些例子來談談你跟異步API打交道過程可能遇到的但是沒有去深究的東西。我將會使用這個loop的工具來進行演示。

首先第一個例子是以1秒的延遲調用setTimeout四次,每次都是在1秒後列印「hi」:

setTimeout(function onTimeout(){
console.log('hi');
}, 1000);

setTimeout(function onTimeout(){
console.log('hi');
}, 1000);

setTimeout(function onTimeout(){
console.log('hi');
}, 1000);

setTimeout(function onTimeout(){
console.log('hi');
}, 1000);

等到所有的callback被入隊到task queue之時,沒有任何一個callback被執行。此時,第四個callback還沒有執行,時間已經超出了它所要求延遲的1秒了。是吧。這個例子說明的是javascript中的time out的實質含義。我們傳入延遲時間(以毫秒為單位)代表的正是可執行的最小時間,而不是確切的時間。瀏覽器並不能保證在你傳入的延遲時間內執行你的callback。以0毫秒調用setTimeout也是一樣的。你的callback不會馬上,立即被執行,而是被承諾為儘快地執行。是吧。

下面這個例子,我想來談論一下callback。callback這個概念的定義取決於跟誰說和它們如何被解析的。 callback這個概念可以有兩種定義。第一種是,凡是被別的函數所調用的函數都可以稱之為callback;第二種是,更加明確地指那種提供給異步操作的,最終會被推入到task queue的函數 。下面這一點代碼會演示一下著兩者的不同:

// 同步版本
[1,2,3,4].forEach(function(i){
console.log(i);
});

// 異步版本
function asyncForEach(array, cb){
array.forEach(function(i){
setTimeout(cb.bind(null,i), 0);
})
};

asyncForEach([1,2,3,4],function(i){
console.log(i);
});

對於同步版本的代碼,我們在數組上調用了forEach方法,forEach接受一個函數作為參數,儘管這個函數不是被異步執行的,但是它是跑在當前的call stack中,你也可以稱這個函數為callback。我將會運行這段示例代碼,讓我們看看這兩者之間的差異到底是什麼。首先,同步版本的代碼先運行了。整個代碼塊將會推入到call stack中,它就在這裡占用並阻塞著call stack,是吧?直到當前call stack清空,才輪到異步版本的代碼執行。是的,執行速度降下來了。實際上,我們將好幾個callback推入到了task queue中了。等到當前call stack清空了,我們才能依次地從task queue中把callback彈出來,推入到call stack 中運行,最終列印出所遍歷的元素。在這個例子中,console.log()的執行足夠快,所以使用異步方式來遍曆數組的好處並沒有體現出來。假設,你現在在遍曆數組的過程中對數組的元素做一些比較耗時的操作的時候,那麼,異步方式的寫法的好處就會體現出來。我好像有在哪裡寫過這種例子,噢,不,我應該記錯了,我沒有這樣的例子。好吧,我們改造一下上面這個例子來進行說明:

function delay() {
// just do the slow thing
}

// 同步版本
[1,2,3,4].forEach(function(i){
console.log("Processing sync");
delay();
});

// 異步版本
function asyncForEach(array, cb){
array.forEach(function(i){
setTimeout(cb.bind(null,i), 0);
})
};

asyncForEach([1,2,3,4],function(i){
console.log("Processing async");
delay();
});

好的,現在我打算打開一個模擬瀏覽器repaint或者說render的開關。這個開關是我這個早上比較匆忙整合都這個loop工具的。目前,我沒有提到的一點是所有的這一切(call stack, webAPI,task queue和event loop)是如何介面渲染打交道的。好吧,我貌似提到過,但是沒有去深入解釋它。可以這麼說,瀏覽器的運行是受你當前正在執行的javascript代碼所約束的。理想狀態下,瀏覽器想要以1秒60幀的速度(也就是花16毫秒去完成一幀的渲染)去渲染螢幕的。60FPS是瀏覽器所能達到的刷新介面的最快的速度了。這裡再次強調,60FPS這種理想狀態是受正在執行的javascript代碼所約束的。如果當前call stack不為空的話,實際上瀏覽器是無法進行介面重繪的。 你可以把介面渲染理解為一個有callback與之對應的異步操作。這種render callback也是需要等待call stack清空之後才能執行的。render callback與普通的入隊到task queue的callback相比,不同之處在於,render callback比普通的callback的優先級要高。每隔16毫秒,瀏覽器就會往render queue上入隊一個render callback。這些render callback必須等到當前的call stack清空才能被執行。這裡所提到的「render queue」是因為要解釋介面渲染機制而模擬出來的。這就好像,每隔1秒,render queue就會問瀏覽器:「我可以做介面渲染了嗎?」。瀏覽器回答:「是的,你可以。」,然後又再下一秒進行如此類推的對話。

是的,因為當前我們的代碼並沒有做一些阻塞call stack的事情。如果我把上面的這個同步版本的代碼跑起來的話,你可以看到,當我們通過同步執行的loop對數組進行很耗時的遍歷的時候,我們的介面渲染工作是被阻塞掉的。一旦介面渲染工作被阻塞掉的話,那麼你就不能在介面上對文本進行選中,你也不能看到任何點擊之後的介面反應。是吧,這種情況我們在早先的那個例子又看到過。雖然,在同步版本中也會阻塞當前call stack。但是因為time out的時間為0,所以阻塞的時間是相當的短的。而在此之後,在執行兩個個數組元素的操作的縫隙,我們給了render queue一個時機去執行render callback。這是因為故意把對數組元素進行操作的callback編排為異步callback。它們最終被入隊到task queue中去。鑒於render queue的優先級比task queue高,所有render callback能跟操作數組元素的callback交替進行,從而避免了介面較長時間裡面無法得到刷新。不知道大家理解不?雖然,上面引入render queue是為了可視化地解釋介面渲染的工作原理。但是基本上它能正確地指出一個事實。那就是,當人們說「不要阻塞event loop」的時候,他們實際在說「不要一些耗時的代碼放在call stack上,以免它們長期占用call stack」。因為,一旦你這麼做了,call stack長時間內得不到清空,那麼瀏覽器也就沒法完成一些它們需要急著做的事情了-比如說,創建一個流暢的介面瀏覽體驗。這也是為什麼當你用javascript在做一些圖片處理工作或者動畫而又沒有好好地編排你的代碼的時候,介面上的很多東西都會變得很慢的原因。

下面就是一個因為javascript代碼沒有得到很好編排而導致介面變慢的例子。這是一個處理scroll事件的例子。

DOM中的scroll事件的觸發是很頻繁的。怎麼個頻繁法呢?我相信它們的觸發速度跟最佳介面刷新率60FPS是一樣的,也是16毫秒觸發一次。假如,我有以下代碼。我在document對象上對scroll事件做了監聽,同時監聽的callback主要負責做一些動畫或者其它一些耗時的活。當我們滾動頁面的時候,那麼就會有大量的callback湧入到task queue中,是吧 ?

最後瀏覽器都必須處理一個個地處理這些callback。每個callback執行起來都是挺慢的。這個時候,你不是阻塞call stack,你是直接用callback「淹沒」了task queue。這種情況跟將觸發大量callback背後的情況進行可視化很像。當然,通過debounce來減少callback的執行從而增加call stack的空閒時間,這種方式也是可行的。但是,我採用的另外一種方式。我將這些event callback全數推入task queue,但是我們每隔幾秒鐘或者等用戶停止滾動後的一小段時間後就做一些耗時的操作,這種方式也是可行的。我會有另外一個專門來講述這個可視化loop工具的實現原理的演講。這實現原理大體是這樣的:當代碼運行在運行時的時候,我通過使用一個叫Esprima的javascript parser來執行這段代碼來降低代碼的執行速度。具體通過往裡面插入一段耗時半秒中的大while循環來達到的。把Esprima放在了web worker上,同時在上面完成了可視化這段代碼執行流程所需要做的所有事情。我的那個完整的演講將會深入探究其中細節。對於這次演講的到來,我感到十分的興奮。到那個時候,我將會跟大家好好嘮嘮這事,屆時就功德圓滿了。好吧,就這樣。謝謝大家。