學(xué)習(xí)JavaScript的時(shí)候了解到JavaScript是單線程的,剛開始很疑惑籍铁,單線程怎么處理網(wǎng)絡(luò)請(qǐng)求涡上、文件讀寫等耗時(shí)操作呢?效率豈不是會(huì)很低拒名?隨著對(duì)這方面內(nèi)容的了解和深入吩愧,知道了其中的奧秘。本篇文章就主要講解一下JavaScript怎么處理異步問題增显。
一雁佳、同步與異步
在介紹JavaScript的異步機(jī)制之前,首先介紹一下:什么是同步同云?什么是異步糖权?
同步
如果在函數(shù)返回的時(shí)候,調(diào)用者就能夠得到預(yù)期結(jié)果(即拿到了預(yù)期的返回值或者看到了預(yù)期的效果)炸站,那么這個(gè)函數(shù)就是同步的星澳。
如下所示:
//在函數(shù)返回時(shí),獲得了預(yù)期值旱易,即2的平方根
Math.sqrt(2);
//在函數(shù)返回時(shí)禁偎,獲得了預(yù)期的效果腿堤,即在控制臺(tái)上打印了'hello'
console.log('hello');
上面兩個(gè)函數(shù)就是同步的。
如果函數(shù)是同步的如暖,即使調(diào)用函數(shù)執(zhí)行的任務(wù)比較耗時(shí)笆檀,也會(huì)一直等待直到得到預(yù)期結(jié)果。
異步
如果在函數(shù)返回的時(shí)候盒至,調(diào)用者還不能夠得到預(yù)期結(jié)果酗洒,而是需要在將來(lái)通過(guò)一定的手段得到,那么這個(gè)函數(shù)就是異步的枷遂。
如下所示:
//讀取文件
fs.readFile('hello.txt', 'utf8', function(err, data) {
console.log(data);
});
//網(wǎng)絡(luò)請(qǐng)求
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = xxx; // 添加回調(diào)函數(shù)
xhr.open('GET', url);
xhr.send(); // 發(fā)起函數(shù)
上述示例中讀取文件函數(shù) readFile
和網(wǎng)絡(luò)請(qǐng)求的發(fā)起函數(shù) send
都將執(zhí)行耗時(shí)操作樱衷,雖然函數(shù)會(huì)立即返回,但是不能立刻獲取預(yù)期的結(jié)果登淘,因?yàn)楹臅r(shí)操作交給其他線程執(zhí)行箫老,暫時(shí)獲取不到預(yù)期結(jié)果(后面介紹)封字。而在JavaScript中通過(guò)回調(diào)函數(shù) function(err, data) { console.log(data); }
和 onreadystatechange
黔州,在耗時(shí)操作執(zhí)行完成后把相應(yīng)的結(jié)果信息傳遞給回調(diào)函數(shù),通知執(zhí)行JavaScript代碼的線程執(zhí)行回調(diào)阔籽。
如果函數(shù)是異步的流妻,發(fā)出調(diào)用之后,馬上返回笆制,但是不會(huì)馬上返回預(yù)期結(jié)果绅这。調(diào)用者不必主動(dòng)等待,當(dāng)被調(diào)用者得到結(jié)果之后會(huì)通過(guò)回調(diào)函數(shù)主動(dòng)通知調(diào)用者在辆。
二证薇、單線程與多線程
在上面介紹異步的過(guò)程中就可能會(huì)納悶:既然JavaScript是單線程,怎么還存在異步匆篓,那些耗時(shí)操作到底交給誰(shuí)去執(zhí)行了浑度?
JavaScript其實(shí)就是一門語(yǔ)言,說(shuō)是單線程還是多線程得結(jié)合具體運(yùn)行環(huán)境鸦概。JS的運(yùn)行通常是在瀏覽器中進(jìn)行的箩张,具體由JS引擎去解析和運(yùn)行。下面我們來(lái)具體了解一下瀏覽器窗市。
瀏覽器
目前最為流行的瀏覽器為:Chrome先慷,IE,Safari咨察,F(xiàn)ireFox论熙,Opera。瀏覽器的內(nèi)核是多線程的摄狱。
一個(gè)瀏覽器通常由以下幾個(gè)常駐的線程:
- 渲染引擎線程:顧名思義脓诡,該線程負(fù)責(zé)頁(yè)面的渲染
- JS引擎線程:負(fù)責(zé)JS的解析和執(zhí)行
- 定時(shí)觸發(fā)器線程:處理定時(shí)事件素跺,比如setTimeout, setInterval
- 事件觸發(fā)線程:處理DOM事件
- 異步http請(qǐng)求線程:處理http請(qǐng)求
需要注意的是,渲染線程和JS引擎線程是不能同時(shí)進(jìn)行的誉券。渲染線程在執(zhí)行任務(wù)的時(shí)候指厌,JS引擎線程會(huì)被掛起。因?yàn)镴S可以操作DOM踊跟,若在渲染中JS處理了DOM踩验,瀏覽器可能就不知所措了。
JS引擎
通常講到瀏覽器的時(shí)候商玫,我們會(huì)說(shuō)到兩個(gè)引擎:渲染引擎和JS引擎箕憾。渲染引擎就是如何渲染頁(yè)面,Chrome/Safari/Opera用的是Webkit引擎拳昌,IE用的是Trident引擎袭异,F(xiàn)ireFox用的是Gecko引擎。不同的引擎對(duì)同一個(gè)樣式的實(shí)現(xiàn)不一致炬藤,就導(dǎo)致了經(jīng)常被人詬病的瀏覽器樣式兼容性問題御铃。這里我們不做具體討論。
JS引擎可以說(shuō)是JS虛擬機(jī)沈矿,負(fù)責(zé)JS代碼的解析和執(zhí)行上真。通常包括以下幾個(gè)步驟:
- 詞法分析:將源代碼分解為有意義的分詞
- 語(yǔ)法分析:用語(yǔ)法分析器將分詞解析成語(yǔ)法樹
- 代碼生成:生成機(jī)器能運(yùn)行的代碼
- 代碼執(zhí)行
不同瀏覽器的JS引擎也各不相同,Chrome用的是V8羹膳,F(xiàn)ireFox用的是SpiderMonkey睡互,Safari用的是JavaScriptCore,IE用的是Chakra陵像。
之所以說(shuō)JavaScript是單線程就珠,就是因?yàn)闉g覽器在運(yùn)行時(shí)只開啟了一個(gè)JS引擎線程來(lái)解析和執(zhí)行JS。那為什么只有一個(gè)引擎呢醒颖?如果同時(shí)有兩個(gè)線程去操作DOM妻怎,瀏覽器是不是又要不知所措了。
所以图贸,雖然JavaScript是單線程的蹂季,可是瀏覽器內(nèi)部不是單線程的。一些I/O操作疏日、定時(shí)器的計(jì)時(shí)和事件監(jiān)聽(click, keydown...)等都是由瀏覽器提供的其他線程來(lái)完成的偿洁。
三、消息隊(duì)列與事件循環(huán)
通過(guò)以上了解沟优,可以知道其實(shí)JavaScript也是通過(guò)JS引擎線程與瀏覽器中其他線程交互協(xié)作實(shí)現(xiàn)異步涕滋。但是回調(diào)函數(shù)具體何時(shí)加入到JS引擎線程中執(zhí)行?執(zhí)行順序是怎么樣的挠阁?
這一切的解釋就需要繼續(xù)了解消息隊(duì)列和事件循環(huán)宾肺。
如上圖所示溯饵,左邊的棧存儲(chǔ)的是同步任務(wù),就是那些能立即執(zhí)行锨用、不耗時(shí)的任務(wù)丰刊,如變量和函數(shù)的初始化、事件的綁定等等那些不需要回調(diào)函數(shù)的操作都可歸為這一類增拥。
右邊的堆用來(lái)存儲(chǔ)聲明的變量啄巧、對(duì)象。下面的隊(duì)列就是消息隊(duì)列掌栅,一旦某個(gè)異步任務(wù)有了響應(yīng)就會(huì)被推入隊(duì)列中秩仆。如用戶的點(diǎn)擊事件、瀏覽器收到服務(wù)的響應(yīng)和setTimeout中待執(zhí)行的事件猾封,每個(gè)異步任務(wù)都和回調(diào)函數(shù)相關(guān)聯(lián)澄耍。
JS引擎線程用來(lái)執(zhí)行棧中的同步任務(wù),當(dāng)所有同步任務(wù)執(zhí)行完畢后晌缘,棧被清空齐莲,然后讀取消息隊(duì)列中的一個(gè)待處理任務(wù),并把相關(guān)回調(diào)函數(shù)壓入棧中枚钓,單線程開始執(zhí)行新的同步任務(wù)铅搓。
JS引擎線程從消息隊(duì)列中讀取任務(wù)是不斷循環(huán)的,每次棧被清空后搀捷,都會(huì)在消息隊(duì)列中讀取新的任務(wù),如果沒有新的任務(wù)多望,就會(huì)等待嫩舟,直到有新的任務(wù),這就叫事件循環(huán)怀偷。
上圖以AJAX異步請(qǐng)求為例家厌,發(fā)起異步任務(wù)后,由AJAX線程執(zhí)行耗時(shí)的異步操作椎工,而JS引擎線程繼續(xù)執(zhí)行堆中的其他同步任務(wù)饭于,直到堆中的所有異步任務(wù)執(zhí)行完畢。然后维蒙,從消息隊(duì)列中依次按照順序取出消息作為一個(gè)同步任務(wù)在JS引擎線程中執(zhí)行掰吕,那么AJAX的回調(diào)函數(shù)就會(huì)在某一時(shí)刻被調(diào)用執(zhí)行。
四颅痊、示例
引用一篇文章中提到的考察JavaScript異步機(jī)制的面試題來(lái)具體介紹殖熟。
執(zhí)行下面這段代碼,執(zhí)行后斑响,在 5s 內(nèi)點(diǎn)擊兩下菱属,過(guò)一段時(shí)間(>5s)后钳榨,再點(diǎn)擊兩下,整個(gè)過(guò)程的輸出結(jié)果是什么纽门?
setTimeout(function(){
for(var i = 0; i < 100000000; i++){}
console.log('timer a');
}, 0)
for(var j = 0; j < 5; j++){
console.log(j);
}
setTimeout(function(){
console.log('timer b');
}, 0)
function waitFiveSeconds(){
var now = (new Date()).getTime();
while(((new Date()).getTime() - now) < 5000){}
console.log('finished waiting');
}
document.addEventListener('click', function(){
console.log('click');
})
console.log('click begin');
waitFiveSeconds();
要想了解上述代碼的輸出結(jié)果薛耻,首先介紹下定時(shí)器。
setTimeout
的作用是在間隔一定的時(shí)間后赏陵,將回調(diào)函數(shù)插入消息隊(duì)列中昭卓,等棧中的同步任務(wù)都執(zhí)行完畢后,再執(zhí)行瘟滨。因?yàn)闂V械耐饺蝿?wù)也會(huì)耗時(shí)候醒,所以間隔的時(shí)間一般會(huì)大于等于指定的時(shí)間。
setTimeout(fn, 0)
的意思是杂瘸,將回調(diào)函數(shù)fn立刻插入消息隊(duì)列倒淫,等待執(zhí)行,而不是立即執(zhí)行败玉〉型粒看一個(gè)例子:
setTimeout(function() {
console.log("a")
}, 0)
for(let i=0; i<10000; i++) {}
console.log("b")
b a
打印結(jié)果表明回調(diào)函數(shù)并沒有立刻執(zhí)行,而是等待棧中的任務(wù)執(zhí)行完畢后才執(zhí)行的运翼。棧中的任務(wù)執(zhí)行多久返干,它就得等多久。
理解了定時(shí)器的作用血淌,那么對(duì)于輸出結(jié)果就容易得出了矩欠。
首先,先執(zhí)行同步任務(wù)悠夯。其中waitFiveSeconds
是耗時(shí)操作癌淮,持續(xù)執(zhí)行長(zhǎng)達(dá)5s。
0
1
2
3
4
click begin
finished waiting
然后沦补,在JS引擎線程執(zhí)行的時(shí)候乳蓄,'timer a'對(duì)應(yīng)的定時(shí)器產(chǎn)生的回調(diào)、 'timer b'對(duì)應(yīng)的定時(shí)器產(chǎn)生的回調(diào)和兩次 click 對(duì)應(yīng)的回調(diào)被先后放入消息隊(duì)列夕膀。由于JS引擎線程空閑后虚倒,會(huì)先查看是否有事件可執(zhí)行,接著再處理其他異步任務(wù)产舞。因此會(huì)產(chǎn)生 下面的輸出順序魂奥。
click
click
timer a
timer b
最后,5s 后的兩次 click 事件被放入消息隊(duì)列庞瘸,由于此時(shí)JS引擎線程空閑捧弃,便被立即執(zhí)行了。
click
click
參考文章
JavaScript:徹底理解同步、異步和事件循環(huán)(Event Loop)
從setTimeout說(shuō)事件循環(huán)模型
JavaScript單線程和異步機(jī)制
JavaScript的單線程機(jī)制
JavaScript單線程異步的背后——事件循環(huán)機(jī)制
JavaScript 運(yùn)行機(jī)制詳解:再談Event Loop