前言
最近在跟團隊內的小伙伴們一起學習和研究Vue.js的源碼,其中有一塊是nextTick函數(shù)的實現(xiàn),這個函數(shù)的主要作用就是使用宏任務或微任務來異步執(zhí)行界面的渲染更新操作等等,所以我本來是打算深入研究一下JavaScript的宏任務和微任務的,但是后來我發(fā)現(xiàn)我連JavaScript基本的運行機制都沒太搞懂。
- 什么是調用棧(Call Stack)?
- 什么是執(zhí)行上下文(Execution Context)?
- JavaScript的異步任務(setTimeout?Promise?)又是如何運作的?
- JavaScript的Event Loop又是個神馬東東?
- ……
帶著這些疑問,我開始了長達一個多月的探索之旅。幸運的是,我找到了一些非常棒的學習資料,它們讓我受益匪淺,也解答了我的很大一部分問題,這就是我今天要分享給大家的我的學習成果,我會在這一篇博客里把我看過的這些學習資料中所講到的重點知識全部都包含進來,以幫助大家更快更全面地理解JavaScript基本的運行機制。
話不多說,我們進入正文。
內存的劃分
在一個經典的計算機系統(tǒng)架構中,程序在運行時會把分配到的內存劃分成四個區(qū)塊,分別是:Code區(qū)塊、Static/Global區(qū)塊、Stack區(qū)塊以及Heap區(qū)塊。
Code區(qū)塊:用于裝載程序運行的指令,其實就是你編寫的代碼最終編譯成的機器指令;
Static/Global區(qū)塊(以下簡稱:Static區(qū)塊):用于存放全局變量。定義在函數(shù)內的變量只能在該函數(shù)內可見,在函數(shù)外是無法直接訪問到的,但是定義在這里的變量可以在任何函數(shù)中都能夠訪問得到;
Stack區(qū)塊:即Call Stack,用于存放函數(shù)運行時的數(shù)據(jù)信息。包括:函數(shù)調用時的參數(shù)、函數(shù)內定義的變量、函數(shù)運行結束后返回的地址等等;
Heap區(qū)塊:函數(shù)運行時的基本數(shù)據(jù)類型的數(shù)據(jù)會直接保存在Stack中,而對象類型的數(shù)據(jù)則會在Heap區(qū)塊中分配內存進行存儲,然后返回分配內存的起始地址以保存在Stack中聲明的變量中以便后續(xù)訪問。
Stack(調用棧)
我們目前只需要關注Stack區(qū)塊即可。Stack是一個典型的棧類型數(shù)據(jù)結構(FILO:First In Last Out)。當JavaScript中的函數(shù)運行時,會往Stack棧中Push一段數(shù)據(jù),這段數(shù)據(jù)我們稱之為Stack Frame,當函數(shù)運行結束后,會將該函數(shù)對應的Stack Frame數(shù)據(jù)段Pop出棧。所以,函數(shù)間的嵌套調用就會在Stack棧中堆疊一摞的Stack Frame數(shù)據(jù)段。為了讓你有一個更清晰直觀的認識,接下來我們來看一段代碼(示例一):
function foo() {
console.log('foo');
}
function bar() {
foo();
console.log('bar');
}
function baz() {
bar();
console.log('baz');
}
baz();
這段代碼很簡單,它的運行結果就是依次打印出:foo、bar和baz。我們來看一下這段代碼在運行過程中Stack區(qū)塊的變化情況。
第0步:程序準備執(zhí)行,分配并劃分內存空間,將代碼指令裝載進Code區(qū)塊并開始執(zhí)行。假設此時代碼塊的執(zhí)行函數(shù)名為main,那么JavaScript Runtime會先將Stack Frame(main)壓入Stack棧中,然后開始調用baz函數(shù)。
第1步:調用baz函數(shù),將Stack Frame(baz)壓入Stack棧中。
第2步:baz調用bar函數(shù)。將Stack Frame(bar)壓入Stack棧中。
第3步:bar調用foo函數(shù)。將Stack Frame(foo)壓入Stack棧中。
第4步:foo調用console.log函數(shù)。將Stack Frame(log)壓入Stack棧中。
第5步:console.log函數(shù)在控制臺打印出‘foo’,執(zhí)行完畢后將Stack Frame(log)推出Stack棧。
第6步:foo函數(shù)執(zhí)行完畢,將Stack Frame(foo)推出Stack棧。
第7步:bar調用console.log函數(shù)。將Stack Frame(log)壓入Stack棧中。
第8步:console.log函數(shù)在控制臺打印出‘bar’,執(zhí)行完畢后將Stack Frame(log)推出Stack棧。
第9步:bar函數(shù)執(zhí)行完畢,將Stack Frame(bar)推出Stack棧。
第10步:baz調用console.log函數(shù)。將Stack Frame(log)壓入Stack棧中。
第11步:console.log函數(shù)在控制臺打印出‘baz’,執(zhí)行完畢后將Stack Frame(log)推出Stack棧。
第12步:baz函數(shù)執(zhí)行完畢,將Stack Frame(baz)推出Stack棧。
第13步:程序運行結束,將Stack Frame(main)推出Stack棧,Code區(qū)塊和Stack區(qū)塊均使用完畢等待被GC回收。
看到這里,你應該已經對JavaScript的Call Stack有了一個更清晰直觀的認識了。
接下來,我們來聊一聊JavaScript中的“報錯”。相信大家在瀏覽器中開發(fā)時都碰到過報錯的情況,這時候瀏覽器終端會輸出一段報錯信息,里面包含了錯誤發(fā)生時的Stack棧中的函數(shù)調用鏈路情況。例如,我把上面的代碼改成這樣(示例二):
function foo() {
throw new Error('error from foo');
}
function bar() {
foo();
}
function baz() {
bar();
}
baz();
代碼執(zhí)行后,會在瀏覽器終端打印出下面這樣的報錯信息。
基于Stack這樣的設計,編譯器就能夠很輕松地定位發(fā)生錯誤時的函數(shù)調用鏈路情況,我們也就能夠很方便地排查發(fā)生錯誤的原因了。
Stack Overflow(棧溢出)
很多人也碰到過棧溢出(Stack Overflow)的問題。那么為什么會有棧溢出的情況發(fā)生呢?因為Stack棧的大小是在程序運行開始前就已經確定下來不可變更的,所以當你往棧中存放的數(shù)據(jù)超出棧的最大容量時,就會發(fā)生棧溢出的情況。通常的原因都是因為代碼的Bug導致函數(shù)無限循環(huán)嵌套調用,如同下面這個示例(示例三)所示:
單線程的JavaScript
我們都知道,JavaScript是一門單線程(single-threaded)的語言,單線程就意味著“JavaScript Runtime只有一個Call Stack”,也意味著“JavaScript Runtime同一時間只能做一件事情”,來看看下面這段代碼(示例四):
let arr = [0, 1, 2, 3, 4, 5];
/* 平方值 */
function square(arr) {
return arr.map((item) => item * item);
}
let res1 = square(arr);
console.log(res1); // [0, 1, 4, 9, 16, 25]
/* 立方值 */
function cube(arr) {
return arr.map((item) => item * item * item);
}
let res2 = cube(arr);
console.log(res2); // [0, 1, 8, 27, 64, 125]
這段代碼很簡單,給定一個arr數(shù)組,分別計算輸出數(shù)組中每一個數(shù)值求平方和求立方之后的結果數(shù)組。這段代碼在JavaScript中必然是順序執(zhí)行的,先求平方再求立方,但是我們不妨設想一下,因為square和cube函數(shù)做的事情互不相干,那么我們能不能讓它們并行執(zhí)行以提高運行效率呢?在這里因為arr數(shù)組很短,兩個函數(shù)的計算邏輯也很簡單,所以這段代碼運行起來非常地快,但是如果arr數(shù)組非常地大,square和cube方法又進行了一些非常耗時的復雜計算的話,那么我們的設想就變得非常地有意義了。但是,可行嗎?答案是:No。之前我說過,JavaScript Runtime是單線程的,它同一時間只能做一件事情。所以我們寫的JavaScript代碼只能單向串行執(zhí)行,無法并行執(zhí)行(這里暫不考慮Web Workers等技術)。
但是,如果是這樣的話,那么我們在代碼中使用setTimeout函數(shù)時,就必須等待setTimeout指定的延遲時長過后執(zhí)行回調函數(shù),然后才能繼續(xù)執(zhí)行后面的代碼,使用ajax發(fā)送請求也是同樣的情況,我們必須等到請求結果返回后執(zhí)行回調函數(shù),代碼才能繼續(xù)往后走。但是我們知道這些都不是真實的情況,那么為什么會存在這樣的矛盾點呢?
先不著急揭曉答案,我們先來研究一下setTimeout函數(shù)。
setTimeout
setTimeout函數(shù)基本的功能,就是接收一個回調函數(shù)和一個delay延遲時長(默認為0),然后在delay時長過后執(zhí)行回調函數(shù)。來看一下下面的這段代碼和它的運行結果(示例五):
function foo () {
console.log('one');
setTimeout(function inner() {
console.log('two')
}, 0);
console.log('three');
}
foo();
也許有些同學會對運行結果感到很意外,'three'竟然在'two'之前被打印出來,我們都知道setTimeout可以延遲執(zhí)行一段函數(shù),但是為什么延遲時長設置為0都不能讓inner函數(shù)立即被執(zhí)行呢?為了探究這個問題,我們來看一下這段代碼在運行過程中Stack區(qū)塊的變化情況:
我們重點關注上面的第4步、第5步和第9步。可以看到,當?shù)? ~ 5步調用setTimeout函數(shù)后,Stack Frame(setTimeout)莫名消失了,它接收的回調函數(shù)inner在此時并沒有被執(zhí)行,程序繼續(xù)往后走從而打印出'three'。當?shù)?步foo函數(shù)執(zhí)行完畢,也就是看似整段代碼執(zhí)行結束后,第9步inner函數(shù)又莫名出現(xiàn)在了Stack棧中并開始執(zhí)行,inner函數(shù)運行完畢后整段代碼才真正地運行結束。
我們再來看看另外一個例子(示例六):
function foo() {
let start = Date.now();
console.log('start');
setTimeout(function inner() {
console.log('inner: ' + (Date.now() - start));
}, 2000);
while((Date.now() - start) < 1500);
console.log('end: ' + (Date.now() - start));
}
foo();
這段代碼的運行結果有兩點值得我們關注。第一點,foo函數(shù)因為包含了一行空while語句而執(zhí)行了1500ms,但是setTimeout中的inner函數(shù)似乎并沒有受到任何影響,仍然在2秒鐘之后開始執(zhí)行,說明foo函數(shù)的執(zhí)行和setTimeout的計時操作是在并行執(zhí)行的。第二,inner函數(shù)打印的時間差并不是剛剛好等于2000ms,而是2002ms,而且如果你運行這段代碼的話你就會發(fā)現(xiàn),你打印的結果很可能跟我不一樣,但是一定是大于等于2000ms的一個值。
廬山真面目
著名的v8引擎是Chrome和NodeJS背后使用的JavaScript Runtime引擎,而你在它的源碼里是搜不到setTimeout、DOM、Ajax等字樣的,因為它本身只包含了heap和stack,其他的setTimeout、DOM、Ajax等相關的功能都是由瀏覽器基于v8引擎之上所構建和提供的WebAPIs功能。這些WebAPIs和v8引擎一樣都是用C++編寫的,它們會以獨立的線程的方式提供服務,所以我們的JavaScript Runtime是單線程的沒錯,但是當我們調用這些WebAPIs時,它們就會另起一個獨立的線程來完成各自的工作,這樣我們的JavaScript代碼才有了并發(fā)的效果。
v8引擎的結構圖如下所示:
而瀏覽器的全貌圖是這樣子的:
Event Table(事件映射表)
首先介紹一下WebAPIs部分,瀏覽器會維護一個事件映射表(Event Table),它記錄著事件與回調函數(shù)之間的映射關系。如果你想監(jiān)聽某個DOM的click事件的話,那你就必須先在該DOM上注冊click事件,然后當該DOM接收到click事件時才會有回調函數(shù)被執(zhí)行,如果某個事件沒有被綁定回調函數(shù)的話,那么該事件發(fā)生時就如同石沉大海一樣什么也不會發(fā)生。Ajax也是一樣,如果不添加返回響應時的回調函數(shù)的話,那么就會變成單純的發(fā)送一個HTTP請求,也不會有后續(xù)的回調函數(shù)處理響應內容了。setTimeout自不必說,它必須要設置一個回調函數(shù)才有意義。總而言之,這些事件與回調函數(shù)之間的映射關系都會被瀏覽器記錄在Event Table表里,以便當對應事件發(fā)生時能執(zhí)行對應的回調函數(shù)。
Message Queue(消息隊列)
接下來是消息隊列Message Queue(簡稱MQ),有些文章稱之為Event Queue或者Callback Queue,說的都是同一個東西。MQ是一個典型的FIFO(First In First Out)的消息隊列,新消息會被加入到隊列的尾部,消息的執(zhí)行順序與加入隊列的順序相同。每一條消息都有與之綁定的一個函數(shù),當隊首的消息被處理時,消息對應的函數(shù)就會把消息當做輸入?yún)?shù)并開始執(zhí)行。剛剛Event Table中記錄的事件發(fā)生時,就會往MQ隊列中加入一條消息,然后等待被執(zhí)行。
Event Loop
接下來我們就要觸及到整篇文章的重點和核心了,那就是Event Loop。剛剛我們說到,消息已經被加入到MQ隊列中,那么消息什么時候會被處理呢?這時候就該Event Loop登場了。
Event Loop實際做的事情非常地簡單:它會持續(xù)不斷地檢查Call Stack是否為空,如果為空的話就檢查MQ隊列是否有待處理的消息,如果有的話就從MQ隊列的隊首取出一條消息并執(zhí)行消息綁定的函數(shù),如果沒有的話就同步監(jiān)控MQ隊列是否有新的消息加入,一旦發(fā)現(xiàn)就立即取出并執(zhí)行消息綁定的函數(shù)。整個過程不斷重復。
知道了Event Loop的運行機制之后,之前的幾個疑問就迎刃而解了。
首先看下示例五的setTimeout神秘消失和離奇閃現(xiàn)事件。我現(xiàn)在把第4步、第5步、第8步和第9步的完整截圖發(fā)出來給大家看看:
第5步中,我們調用了瀏覽器提供的setTimeout方法,隨即啟動一個單獨的線程做計時操作,然后往Event Table中加入一條記錄。這里由于delay參數(shù)設置為0,所以事件會被立即觸發(fā),然后往MQ隊列中加入一條消息,由于此時Call Stack還不為空,所以消息會在MQ隊列中等待。第8步中,foo函數(shù)執(zhí)行完畢,Call Stack被清空,Event Loop發(fā)現(xiàn)Call Stack為空之后立即檢查MQ隊列,發(fā)現(xiàn)有一條待處理的消息,于是從隊列中取出消息并開始執(zhí)行消息綁定的函數(shù),也就是inner函數(shù),最后inner函數(shù)執(zhí)行完畢,至此整個程序運行結束。大家可以在這兒看到完整的過程。
再來看下示例六的兩個問題點。第一點答案已經揭曉了,我們的JavaScript Runtime和setTimeout是在兩個獨立的線程上并行執(zhí)行的。關于第二點,我相信有些同學已經知道答案了,因為添加在setTimeout中的回調函數(shù)在倒計時結束之后并不會被立即執(zhí)行(即便delay參數(shù)被設置為0),而是需要先將消息添加到MQ隊列的隊尾,然后等待排在前面的消息全部被處理完畢后才能開始執(zhí)行,這個過程總歸要花點時間,所以通常setTimeout回調函數(shù)執(zhí)行時的實際delay時長都要大于指定的delay時長。同樣給出示例六的完整運行過程。
順便提一下,瀏覽器的每一個tab(iframe標簽和Web Workers同樣如此)都擁有自己獨立的Event Loop以及一整套的Runtime運行環(huán)境,包括Call Stack、Heap、Message Queue、Render Queue(后面會提到)等等,這樣就保證了即便某一個tab因為執(zhí)行了某種耗時的操作被阻塞,其他的tab也能夠正常運作,而不會說直接導致整個瀏覽器被卡死。不同Runtime之間的通訊方式可以看這里。
Blocking(阻塞)
JavaScript號稱是一門“single-threaded(單線程)、non-blocking(非阻塞)、asynchronous(異步的)、concurrent(并發(fā)的)”編程語言。這確實是事實但也不盡然。說它是事實是因為瀏覽器將網(wǎng)絡請求、文件操作(NodeJS)等幾乎所有耗時的操作都以獨立線程(concurrent)和異步回調(asynchronous)的形式提供給我們使用,所以我們的JavaScript Runtime主線程可以持續(xù)高效不間斷地執(zhí)行我們的JS代碼,這就是非阻塞(non-blocking)的含義。單線程(single-threaded)的JavaScript Runtime是優(yōu)勢也是劣勢。優(yōu)勢在于它簡化了我們編寫代碼的方式,使得我們可以不用考慮復雜的并發(fā)問題。劣勢在于一旦有耗時的操作占據(jù)了JavaScript Runtime主線程的話,就會導致MQ隊列中的消息無法得到及時的處理,還會阻塞UI渲染線程的執(zhí)行,進而影響到頁面的流暢性。
我們將上面的示例六稍作改動,這次我們把setTimeout的delay參數(shù)設置為500ms,來看看會發(fā)生些什么(示例七):
function foo() {
let start = Date.now();
console.log('start');
setTimeout(function inner() {
console.log('inner: ' + (Date.now() - start));
}, 500);
while((Date.now() - start) < 1500);
console.log('end: ' + (Date.now() - start));
}
foo();
可以看到,整體代碼的運行耗時依然是1500ms不變,但是我們發(fā)現(xiàn)inner函數(shù)執(zhí)行時時間也過去了1500ms,而并沒有像我們期望的那樣在500ms后就執(zhí)行,原因就是因為while((Date.now() - start) < 1500);
是一句同步的操作,它的執(zhí)行會占據(jù)JavaScript Runtime主線程和Call Stack調用棧,進而導致即便inner函數(shù)對應的消息在500ms之后就已經在MQ隊列中等待,但是由于此時Call Stack并不為空,所以inner函數(shù)就無法被Event Loop及時Pick進入Call Stack執(zhí)行,它不得不等到1500ms過后Call Stack被清空,然后才能被執(zhí)行。實際的運行效果請大家自行查看。
Rendering(渲染)
剛剛我們有提到,如果JavaScript Runtime主線程被阻塞的話,同樣會影響到UI渲染線程的執(zhí)行,而一旦UI渲染線程被阻塞,用戶就無法在頁面上執(zhí)行點擊、滑動等操作了。這究竟是為什么呢?
原來,在瀏覽器的實現(xiàn)中,UI渲染操作(或者說是DOM更新操作)同樣是以隊列的形式處理的。類似于Message Queue,瀏覽器會維護一個Render Queue(簡稱RQ)來專門存放UI渲染消息,而且它跟MQ一樣,必須等到Call Stack為空時才能被處理,不同的是,它的處理優(yōu)先級是要高于MQ的。界面刷新的頻次一般是每秒鐘60次,也就是每16.67ms會執(zhí)行一次,所以Event Loop每隔16.67ms就查看一下RQ隊列是否有待處理的消息,如果有的話就檢查Call Stack是否為空,為空就從RQ隊列取出消息并處理,否則就繼續(xù)等待直至Call Stack被清空,然后再處理RQ隊列中的UI渲染消息。
我相信大家都碰到過頁面卡頓的情況,原因就在這里了。我之前發(fā)的鏈接工具叫做loupe,是一個專門用來觀察JavaScript Runtime的工具網(wǎng)站,打開它并點擊左上角的圖標就可以展開設置面板,里面可以設置代碼運行時停頓的時長,還可以模擬UI渲染操作,勾中之后就可以查看當主線程代碼運行時,UI渲染消息被阻塞的過程了。我們還是以示例六為例,來看看實際的運行效果:
再談Blocking
我們已經知道,JavaScript Runtime主線程的阻塞會導致RQ隊列和MQ隊列中的消息無法被及時處理,所以我們要盡量避免執(zhí)行一些同步耗時的操作,要給到這些隊列中的消息被處理的機會。
同樣,會阻礙隊列消息被及時處理的還有隊列本身被阻塞的情況。比較典型的場景是在document的onscroll事件上綁定了回調函數(shù),由于onscroll事件觸發(fā)的頻次同樣是每秒60次,所以當用戶滾動頁面時,很容易就會把MQ隊列塞滿,如果回調函數(shù)里還執(zhí)行了一些UI渲染等耗時的操作的話,那簡直就是災難性的,畢竟UI渲染線程和JavaScript Runtime主線程是無法并行執(zhí)行的(運行效果傳送門)。
尾聲
至此我的分享就結束了,感謝Philip Roberts在2014年歐洲JSConf上精彩的演講,是他讓我真正搞明白了JavaScript的Event Loop究竟是如何工作的,之前提到的loupe也是他的杰作,附上油管鏈接和優(yōu)酷鏈接以供各位看官享用:)
參考資料
- What the heck is the event loop anyway? | Philip Roberts | JSConf EU
- Concurrency model and Event Loop
- The JavaScript Event Loop
- Understanding JS: The Event Loop
- JavaScript Event Loop Explained
***更新@2019年07月07日***
閉包
最近又看了一篇博客(The JavaScript Event Loop: Explained),引發(fā)了我對于閉包的思考,考慮如下代碼:
function foo() {
let a = {
name: 'Chris, Z',
gender: 'Man',
};
let b = 'Baby';
let c = 1024;
let d = true;
setTimeout(function inner() {
console.log(a);
console.log(b);
console.log(c);
console.log(d);
});
}
foo();
按照我們之前所說的,當foo執(zhí)行完畢后它對應的stack frame(foo)就被移出Call Stack棧而不復存在了,但是我們也知道inner函數(shù)執(zhí)行時是能夠訪問到foo函數(shù)內定義的abcd變量的,這不是矛盾了嗎?
我其實也沒找到具體的資料解釋這一塊Runtime引擎是怎么處理的,所以我大膽地設想了幾種可能的做法:
做法一
stack frame(foo)出棧時確實被內存回收了,但是Runtime引擎在這里做了優(yōu)化,inner函數(shù)會將abcd變量的值拷貝下來保存到某個地方,由于a變量指向了堆中的一個對象,b變量指向了堆中的一個字符串常量,它們都是引用值,所以當inner函數(shù)將ab變量的引用地址值保存下來時,stack frame(foo)中聲明的ab變量本身就可以被放心地回收了,ab變量所指向的堆地址由于仍然被inner函數(shù)所引用而不會被GC回收,進而可以在inner函數(shù)執(zhí)行時被引用到。而cd變量就更簡單了,它們只是原始類型而已,直接被inner函數(shù)拷貝保存下來就可以了,既不會影響stack frame(foo)的內存回收,也不會影響inner函數(shù)執(zhí)行時引用到cd變量的值。
做法二
stack frame(foo)并不會真正出棧(邏輯上已經出棧,但物理上仍然占據(jù)棧內存),inner函數(shù)也無需在執(zhí)行前保存它引用的變量值。那么此時Call Stack在內存空間上就會形成“空洞”,只不過Runtime引擎會很好地處理這種情況,不會讓后續(xù)的stack frame入棧和出棧感受到“空洞”的存在而已。
做法三
前面的跟做法二一樣,只不過Call Stack會直接用跳過stack frame(foo)的一個新地址作為起始地址開始構建,這樣就不會形成“空洞”了。
當然,上面的這些都只是我個人的猜想而已,如果誰有確切的答案還望不吝賜教。