淺談Javascript中的異步

2018-06-09 18:20 更新

最近心血來潮,準(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是單線程的。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ì)改變這一特性。

Javascript中的異步原理

在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中的異步

讓我們言歸正傳。在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í)行。

事件和任務(wù)隊(duì)列

往往在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ì)列。

EventLoop的解釋

上圖中還有一個(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中的定時(shí)器

Javascript中內(nèi)置了兩個(gè)方法,用于設(shè)置定時(shí)任務(wù)。分別是setTimeoutsetInterval。它們的具體用法我這里不展開說了。

前文有提到過,在Javascript中常見的有兩種異步場(chǎng)景,一種是異步IO,另一種就是定時(shí)任務(wù)。

JQuery的作者John Resig有一篇文章,就是探討Javascript中的定時(shí)器是如何工作的。我覺得大神文章中的示例對(duì)setTimeoutsetInterval的運(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ā)生哪些事情呢?

  • 初始化了一個(gè)10ms的setTimeout定時(shí)器
  • 鼠標(biāo)被點(diǎn)擊了一次
  • 初始化了一個(gè)10ms的setInterval定時(shí)器
  • 觸發(fā)10ms的Timer定時(shí)器

我們發(fā)現(xiàn)

  • 發(fā)生了一次鼠標(biāo)點(diǎn)擊,將產(chǎn)生一個(gè)鼠標(biāo)click事件,此事件將會(huì)壓入任務(wù)隊(duì)列,等待執(zhí)行。
  • Timer計(jì)時(shí)器的回調(diào)實(shí)際上會(huì)在第一個(gè)代碼塊執(zhí)行完畢前被觸發(fā)。但是這里注意的是,它不會(huì)立即執(zhí)行(單線程上不能這樣做,因?yàn)楫?dāng)前的Javascript線程還在處理第一個(gè)代碼塊呢)。實(shí)際上,觸發(fā)的回調(diào)將被排成一個(gè)隊(duì)列,等待下一個(gè)可執(zhí)行時(shí)間。

直到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)鍵的地方來了,這里setTimeoutsetInterval使用的回調(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í)行了。

定時(shí)器的時(shí)間精度問題

根據(jù)前文所述,其實(shí)不管是setTimeout還是setInterval定時(shí)器,因?yàn)镴avascript的單線程緣故,他們當(dāng)在任務(wù)量較多的時(shí)候,都會(huì)涉及到一個(gè)排隊(duì)等待執(zhí)行的問題。所以定時(shí)器的對(duì)于時(shí)間間隔的把握其實(shí)不是那么精確的。

就拿前文的例子來說,如果click事件的回調(diào)要消耗1000ms,那么后面的TimerInterval定時(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)求)。

setTimeout(fn, 0)的理解

記得以前有被人問過這句代碼的含義????????????。

按照常規(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í)器的區(qū)別

其實(shí)setTimeoutsetInterval這兩個(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的功能。


以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)