最近心血來潮,準(zhǔn)備整理一下Javascript中有關(guān)異步編程方面的東西,寫幾篇文章。當(dāng)然同時(shí)也是為了沉淀一下自己所學(xué)。
暫時(shí)將這個(gè)專題命名為Javascript異步編程專題吧,目前包含以下幾篇文章,
本篇文章是本專題的第一篇文章。
正文開始。
Javascript這門語言出身“低賤”,但是隨著近年來社區(qū)的活躍,反而給人以耀眼的活躍。同時(shí)帶來的也是各種各樣的對(duì)這門原先被當(dāng)作玩具語言的解析。本篇文章將會(huì)帶了Javascript中關(guān)于異步編程以及其周邊內(nèi)容的介紹。
Javascript是單線程的。javascript的程序員一定要時(shí)刻謹(jǐn)記這句話。
可以說單線程是Javascript語言最本質(zhì)的特性之一。言外之意,Javascript引擎在運(yùn)行js代碼的時(shí)候,同一個(gè)時(shí)間只能執(zhí)行單個(gè)任務(wù)。
這是為何呢?
其實(shí)這跟Javascript的歷史有關(guān)系。Javascript最初被設(shè)計(jì)成一種瀏覽器腳本語言,決策者們僅僅是需要一門輕量的瀏覽器腳本語言來做一些簡(jiǎn)單的用戶交互及DOM操作。在這樣一個(gè)背景下,據(jù)說Javascript的設(shè)計(jì)者僅僅花了10天就完成了這門語言的設(shè)計(jì)。
相比多線程,單線程不需要考慮創(chuàng)建線程、銷毀線程、線程間通信等等諸如此類的復(fù)雜問題。所以,為了避免復(fù)雜性,Javascript從誕生伊始就是單線程,而且日后也應(yīng)該不會(huì)改變這一特性。
在C#、Java等語言中,異步操作往往是創(chuàng)建一個(gè)線程來執(zhí)行異步任務(wù),異步任務(wù)執(zhí)行完畢之后再將執(zhí)行結(jié)果通知給主線程。這其中往往會(huì)伴隨著,多線程、線程池、并發(fā)等相關(guān)術(shù)語。但是在Javascript中不存在這些。
如前文所述,Javascript是單線程的,那么Javascript的異步是怎么回事呢?Javascript又是如何在單線程上給出所謂的異步編程呢?
在解釋這個(gè)問題之前,我們應(yīng)該首先明確兩組概念。就是同步和異步以及阻塞和非阻塞這兩組概念。
首先這是兩組不同的概念。同步、異步一般指的是消息的通信機(jī)制;而阻塞、非阻塞一般強(qiáng)調(diào)的是程序在等待(消息)時(shí)的狀態(tài)。
他們的概念如下,
名稱 | 解釋 | 備注 |
---|---|---|
同步 | 發(fā)出一個(gè)功能調(diào)用(執(zhí)行函數(shù))時(shí),在沒有得到調(diào)用結(jié)果之前,該調(diào)用就不會(huì)返回 | - |
異步 | 發(fā)出一個(gè)調(diào)用后,不關(guān)心結(jié)果返回,而是立即返回。結(jié)果出來后,將會(huì)通過一些額外手段通知調(diào)用者 | 注1 |
阻塞 | 在調(diào)用結(jié)果返回之前,當(dāng)前線程會(huì)被掛起等待。直到得到結(jié)果之后函數(shù)調(diào)用才會(huì)返回。 | - |
非阻塞 | 在不能立刻得到結(jié)果之前,該函數(shù)不會(huì)阻塞當(dāng)前線程,而會(huì)立刻返回。 | - |
注1:這里所說的額外手段,往往是指異步調(diào)用的一些具體實(shí)現(xiàn)方式,比如事件監(jiān)聽、輪詢等等。
可見,同步、異步和阻塞、非阻塞是兩組概念,我們不應(yīng)該混淆他們。同時(shí),這兩組概念其實(shí)是可以共存,可以混合搭配最多四種模式。
后面我們會(huì)說到其實(shí)Javascript中的異步并非真正意思上的異步,它最多能做到非阻塞的地步。所以Node.js官網(wǎng)在介紹自己時(shí)僅僅是說“基于事件驅(qū)動(dòng)”,“非阻塞IO模型”,并沒有提到異步的字眼。
好了,關(guān)于這兩組概念雖然還有許多可以說的內(nèi)容,不過這篇文章的重點(diǎn)并不是介紹這兩組概念,有興趣的可自行查閱相關(guān)資料。
讓我們言歸正傳。在Javascript中,什么樣的調(diào)用可以算是異步調(diào)用呢?
我個(gè)人認(rèn)為,Javascript中異步的應(yīng)用場(chǎng)景主要有兩個(gè),其一是基于瀏覽器的各種異步IO和事件監(jiān)聽,其二是Javascript代碼中的關(guān)于定時(shí)器(setTimeout
、setInterval
)的應(yīng)用。
對(duì)于異步IO,比如ajax請(qǐng)求(當(dāng)然,ajax分為同步ajax和異步ajax,這里我們僅用異步ajax來作示例)。當(dāng)js代碼發(fā)出一個(gè)ajax請(qǐng)求時(shí),瀏覽器仍然會(huì)接著往下執(zhí)行js代碼。我們將處理ajax返回的邏輯綁定在ajax回調(diào)函數(shù)上,當(dāng)ajax請(qǐng)求完畢成功返回時(shí)會(huì)觸發(fā)回調(diào)函數(shù),然后接著執(zhí)行我們綁定的處理邏輯。如下圖,
這種場(chǎng)景下的異步其實(shí)涉及到了兩個(gè)線程,一個(gè)是javascript線程,一個(gè)是瀏覽器為了發(fā)出ajax請(qǐng)求而開辟的線程(它屬于瀏覽器進(jìn)程的子線程)。
對(duì)于定時(shí)器的異步場(chǎng)景,我們可以用一段示例代碼來說明,
function foo(callback) {
console.log('foo start');
setTimeout(function() {
callback();
}, 2000);
console.log('foo end');
}
function foo2() {
console.log('Hello!');
}
foo(foo2);
這段代碼的執(zhí)行過程如下圖,
從圖中我們可以看出執(zhí)行foo(foo2)
使用了50ms,其中在1010ms時(shí)開始執(zhí)行setTimeout
語句。由于我們使用setTimeout
設(shè)置了一個(gè)延時(shí),所以在執(zhí)行foo(foo2)
時(shí)并沒有真正的執(zhí)行foo2()
,而是將其時(shí)機(jī)推遲了。直到3010ms左右時(shí)才去真正的執(zhí)行foo2()
的代碼。
(注,為了方便敘述,上述時(shí)間都是假設(shè)的,不必糾結(jié)。下同。)
從上圖中可以看出,整個(gè)執(zhí)行過程中只有一個(gè)javascript線程,異步調(diào)用的部分的代碼,最終也是在javascript線程上執(zhí)行的,只不過被延遲了。
從上面的兩個(gè)示例中,我們足以管中窺豹?;\統(tǒng)的說,單線程的Javascript中其實(shí)沒辦法實(shí)現(xiàn)真正意義上的異步,Javascript中所謂的異步可以理解成延遲執(zhí)行。
往往在Javascript運(yùn)行環(huán)境中,除了Javascript線程之外,還有會(huì)有一個(gè)專門維護(hù)任務(wù)隊(duì)列的線程。說到這里,有人會(huì)問,不是說好的Javascript是單線程嗎?怎么這里會(huì)有兩個(gè)線程?
好問題!注意這里我并沒有說Javascript語言,而是Javascript運(yùn)行環(huán)境(Javascript Runtime)。常見的運(yùn)行環(huán)境有各種瀏覽器(瀏覽器中的Javascript解釋引擎),Nodejs環(huán)境等等。
這里我們就以瀏覽器環(huán)境作為示例來進(jìn)行相關(guān)說明。首先試想一下我們現(xiàn)在有這樣的一個(gè)場(chǎng)景。我們?cè)谝欢蝚s代碼中發(fā)出了一個(gè)ajax請(qǐng)求,同時(shí)給click和press注冊(cè)了事件監(jiān)聽。那么當(dāng)我們執(zhí)行這段代碼后,其效果圖如下,
因?yàn)镴avascript是單線程的,就意味著Javascript同一時(shí)間只能處理一個(gè)任務(wù)。如果一旦任務(wù)產(chǎn)生的速度過快,那么后續(xù)的任務(wù)就得排隊(duì)。每一個(gè)任務(wù)往往與一個(gè)事件相對(duì)應(yīng)。
如上圖所示,當(dāng)瀏覽器的用于ajax的線程得到ajax返回結(jié)果后會(huì)產(chǎn)生一個(gè)事件,將其放入任務(wù)隊(duì)列。(其實(shí)這里還涉及到Ajax線程如何通知任務(wù)隊(duì)列等等問題,這里我們就不討論了)
除此之外,可能用戶還在頁面進(jìn)行了鼠標(biāo)點(diǎn)擊操作和按下鍵盤輸入。這兩個(gè)動(dòng)作都將會(huì)產(chǎn)生一個(gè)事件,將產(chǎn)生的事件壓入任務(wù)隊(duì)列。
上圖中還有一個(gè)叫做Event Loop的東西,這個(gè)東西是什么東西呢?
Event Loop是一個(gè)程序結(jié)構(gòu),用于等待和發(fā)送消息和事件。(a programming construct that waits for and dispatches events or messages in a program.)
簡(jiǎn)單來說,EventLoop也是一個(gè)程序,它會(huì)不停的輪詢?nèi)蝿?wù)隊(duì)列中的事件。如果Javascript線程空了,則取出任務(wù)隊(duì)列的第一個(gè)事件,然后找到此事件的處理函數(shù)并執(zhí)行處理函數(shù)。當(dāng)前一個(gè)事件執(zhí)行完了(此時(shí)Javascript又會(huì)空下來),那么繼續(xù)取出任務(wù)隊(duì)列中的下一個(gè)事件。如此往復(fù)。
回到我們上面的例子中,當(dāng)從任務(wù)隊(duì)伍隊(duì)列中取出第一個(gè)事件時(shí)(Ajax返回事件),Javascript線程將會(huì)去執(zhí)行函數(shù)foo1
。其實(shí)從圖中可以看出,先前綁定的三個(gè)回調(diào)函數(shù)最終都是在Javascript線程上執(zhí)行的,只不過由于任務(wù)隊(duì)列的存在,導(dǎo)致了他們的執(zhí)行時(shí)機(jī)是不一樣的。
Javascript中內(nèi)置了兩個(gè)方法,用于設(shè)置定時(shí)任務(wù)。分別是setTimeout
和setInterval
。它們的具體用法我這里不展開說了。
前文有提到過,在Javascript中常見的有兩種異步場(chǎng)景,一種是異步IO,另一種就是定時(shí)任務(wù)。
JQuery的作者John Resig有一篇文章,就是探討Javascript中的定時(shí)器是如何工作的。我覺得大神文章中的示例對(duì)setTimeout
和setInterval
的運(yùn)行原理,以及它們?cè)贘avascript中是如何與異步任務(wù)協(xié)調(diào)工作的,說明的都非常好。我這里就直接拿過來用了。
首先我們來看一張場(chǎng)景圖,
乍一看這張圖一坨方塊加一坨英文,看不出頭緒。其實(shí)Javascript定時(shí)器的秘密就隱藏在其中,讓我們一步一步來解析。
圖中深藍(lán)色的方塊可以理解成Javascript線程的執(zhí)行過程,左側(cè)是時(shí)序。右側(cè)是對(duì)每一個(gè)Javascript代碼發(fā)生了什么事作的說明。
先看第一個(gè)Javascript代碼塊,它在整個(gè)時(shí)序中占用了不到20ms(大概18ms的樣子),它依次發(fā)生哪些事情呢?
setTimeout
定時(shí)器setInterval
定時(shí)器我們發(fā)現(xiàn)
直到Javascript執(zhí)行完第一塊js代碼,我們的任務(wù)隊(duì)列中已經(jīng)有兩個(gè)待執(zhí)行的任務(wù)了。如下,
現(xiàn)在Javascript線程已經(jīng)處于空閑狀態(tài)了,此時(shí)任務(wù)隊(duì)列已經(jīng)有2個(gè)任務(wù)在等待執(zhí)行了,按照入隊(duì)順序,我們先處理click事件。隊(duì)列后面的任務(wù)繼續(xù)排隊(duì)等待。
回到場(chǎng)景圖中,我們現(xiàn)在開始處理click事件,即執(zhí)行click事件綁定的回調(diào)函數(shù)。這其中觸發(fā)了一次10ms的Interval定時(shí)器。但是此時(shí)Javascript線程上正在執(zhí)行click事件的回調(diào),所以即使Interval觸發(fā)了,但是并不能執(zhí)行。怎么辦呢?只能對(duì)它說不好意思了,讓它去排隊(duì)吧。
此時(shí),任務(wù)隊(duì)列的情況如下,
可以看到,之前的click事件已經(jīng)出隊(duì)了,但是又新增了一個(gè)Interval定時(shí)器回調(diào)。
隨著時(shí)間的推移,在完成click事件任務(wù)完成之后,繼續(xù)從任務(wù)隊(duì)列中取出任務(wù)。從上圖可知,下一個(gè)任務(wù)是Timer定時(shí)器的回調(diào)。
我們?cè)趫?zhí)行Timer定時(shí)器任務(wù)時(shí),發(fā)現(xiàn)又有一個(gè)Interval定時(shí)器觸發(fā)了。不過此時(shí)Javascript線程還在執(zhí)行Timer定時(shí)器的任務(wù)呢,所以剛觸發(fā)的Interval定時(shí)器任務(wù)只能去排隊(duì)了。
此時(shí),任務(wù)隊(duì)列如下,
現(xiàn)在任務(wù)隊(duì)列里面已經(jīng)有兩個(gè)一樣的Interval定時(shí)器任務(wù)在等待執(zhí)行了。
注:John Resig的原文中說,在Timer定時(shí)器執(zhí)行期間觸發(fā)的Interval定時(shí)器會(huì)被Dropped。起先我百思不得其解。按道理應(yīng)該將其入隊(duì)才對(duì)啊。后又仔細(xì)研讀了原文,外加查閱了相關(guān)資料,終于知道是為何了。
在John Resig的原文中,他給出的兩個(gè)定時(shí)器的初始化如下,
var id = setTimeout(fn, delay);
var id = setInterval(fn, delay);
關(guān)鍵的地方來了,這里setTimeout
和setInterval
使用的回調(diào)函數(shù)都是同一個(gè)函數(shù)fn
。在這種先提條件下,在前面的分析中,當(dāng)Javascript正在執(zhí)行Timer定時(shí)器的回調(diào)時(shí),雖然又一次觸發(fā)了Interval回調(diào),但是由于兩個(gè)定時(shí)器采用回調(diào)都是同一個(gè)函數(shù)。所以導(dǎo)致此時(shí)被觸發(fā)的Interval定時(shí)器由于綁定的回調(diào)函數(shù)正在被執(zhí)行,所以此次觸發(fā)就被拋棄了。
讓我們繼續(xù)回到分析之中來。
當(dāng)Timer定時(shí)器的回調(diào)執(zhí)行完畢之后,我們與之前一樣,從任務(wù)隊(duì)列中取出下一個(gè)任務(wù)。這次是Interval定時(shí)器的回調(diào)。
因?yàn)镴ohn Resig給的示例中,Interval定時(shí)器的回調(diào)執(zhí)行將在10ms之內(nèi)完成,所以按照上述的分析如此往復(fù)之后,最后將會(huì)出現(xiàn)Javascript線程空等的情況。
在40-50ms這段時(shí)間之內(nèi),我們將積累的兩個(gè)Interval定時(shí)器任務(wù)執(zhí)行完畢了,發(fā)現(xiàn)任務(wù)隊(duì)列中沒有等待執(zhí)行的任務(wù)了。那么接下來Javascript線程就會(huì)處于“空等”狀態(tài)。
在50ms的時(shí)候,有一個(gè)Interval定時(shí)器觸發(fā)了。我們同樣將其入隊(duì)。不過由于之前任務(wù)隊(duì)列本來就是空的,所以這個(gè)觸發(fā)就立馬執(zhí)行了。
根據(jù)前文所述,其實(shí)不管是setTimeout
還是setInterval
定時(shí)器,因?yàn)镴avascript的單線程緣故,他們當(dāng)在任務(wù)量較多的時(shí)候,都會(huì)涉及到一個(gè)排隊(duì)等待執(zhí)行的問題。所以定時(shí)器的對(duì)于時(shí)間間隔的把握其實(shí)不是那么精確的。
就拿前文的例子來說,如果click事件的回調(diào)要消耗1000ms,那么后面的Timer和Interval定時(shí)器的回調(diào)都得等著,直接click事件的回調(diào)執(zhí)行完畢。所以真正等到定時(shí)器回調(diào)執(zhí)行時(shí),可能早就過了原先設(shè)定的10ms了。
這點(diǎn)對(duì)于setInterval
的影響更為嚴(yán)重。比如,
setInterval(function(){
console.log(2);
},1000);
(function (){
sleeping(3000);
})();
上面的第一行語句要求每隔1000毫秒,就輸出一個(gè)2。但是,第二行語句需要3000毫秒才能完成,請(qǐng)問會(huì)發(fā)生什么結(jié)果?
結(jié)果就是等到第二行語句運(yùn)行完成以后,立刻連續(xù)輸出三個(gè)2,然后開始每隔1000毫秒,輸出一個(gè)2。也就是說,setIntervel
具有累積效應(yīng),如果某個(gè)操作特別耗時(shí),超過了setInterval
的時(shí)間間隔,排在后面的操作會(huì)被累積起來,然后在很短的時(shí)間內(nèi)連續(xù)觸發(fā),這可能或造成性能問題(比如集中發(fā)出Ajax請(qǐng)求)。
記得以前有被人問過這句代碼的含義????????????。
按照常規(guī)理解,定時(shí)器的延時(shí)參數(shù)為0,表示是立即執(zhí)行么?顯然不是。
不管如何,只要經(jīng)過setTimeout
操作,那么fn
將會(huì)被放到任務(wù)隊(duì)列中排隊(duì)。我們說一旦進(jìn)行了任務(wù)隊(duì)列,何時(shí)能執(zhí)行那就由不得你了。得看排在你前面的任務(wù)是不是耗時(shí)任務(wù)。
所以這里延時(shí)參數(shù)的含義只有一個(gè)。那就是讓fn
早點(diǎn)去排隊(duì)!如果任務(wù)隊(duì)列中沒有其他待執(zhí)行任務(wù),那么此時(shí)將會(huì)直接執(zhí)行fn
,其實(shí)這跟直接執(zhí)行fn
的區(qū)別不大。如果隊(duì)列前面有待執(zhí)行任務(wù),那不好意思,你等著吧。
其實(shí)setTimeout
和setInterval
這兩個(gè)定時(shí)器是有區(qū)別的。當(dāng)然我想說的區(qū)別并不是指它們功能上的區(qū)別。
Timer定時(shí)器在觸發(fā)時(shí),會(huì)將其回調(diào)任務(wù)壓入任務(wù)隊(duì)列進(jìn)行排隊(duì)。而Interval定時(shí)器在觸發(fā)時(shí),它不管當(dāng)前Javascript線程的狀態(tài),它會(huì)無差別的往任務(wù)隊(duì)列中壓任務(wù)。如果某一個(gè)任務(wù)執(zhí)行時(shí)占用了較多的Javascript線程時(shí)間,可能會(huì)導(dǎo)致Interval定時(shí)器連續(xù)壓入多個(gè)回調(diào)。導(dǎo)致本來應(yīng)該存在時(shí)間間隔的Interval定時(shí)器回調(diào)連續(xù)執(zhí)行。
此外,當(dāng)Interval定時(shí)器回調(diào)正在被當(dāng)前Javascript線程運(yùn)行時(shí),此次Interval定時(shí)器觸發(fā)將會(huì)被拋棄。因?yàn)榇舜斡|發(fā)的Interval定時(shí)器回調(diào)不會(huì)被入隊(duì)?!禞avascript高級(jí)程序設(shè)計(jì)》一書中也提到了這一點(diǎn),
當(dāng)使用
setInterval()
時(shí),僅當(dāng)沒有該定時(shí)器的任何其他代碼實(shí)例時(shí),才將定時(shí)器代碼添加到隊(duì)列中。
如果你對(duì)Interval定時(shí)器的時(shí)間間隔要求比較嚴(yán)格的話,可以嘗試使用循環(huán)加setTimeout
的方式來模擬setInterval
的功能。
更多建議: