你不懂JS: 異步與性能 第一章: 異步: 現(xiàn)在與稍后

你不懂JS: 異步與性能 第一章: 異步: 現(xiàn)在與稍后

?

HetfieldJoe?關(guān)注

2016.05.12 17:40*?字數(shù) 10040?閱讀 3408評論 8喜歡 20

官方中文版原文鏈接

感謝社區(qū)中各位的大力支持,譯者再次奉上一點點福利:阿里云產(chǎn)品券,享受所有官網(wǎng)優(yōu)惠,并抽取幸運大獎:點擊這里領(lǐng)取

在像JavaScript這樣的語言中最重要但經(jīng)常被誤解的編程技術(shù)之一,就是如何表達和操作跨越一段時間的程序行為。

這不僅僅是關(guān)于從for循環(huán)開始到for循環(huán)結(jié)束之間發(fā)生的事情,當然它確實要花?一些時間(幾微秒到幾毫秒)才能完成。它是關(guān)于你的程序?現(xiàn)在?運行的部分,和你的程序?稍后?運行的另一部分之間發(fā)生的事情——現(xiàn)在?和?稍后?之間有一個間隙,在這個間隙中你的程序沒有活躍地執(zhí)行。

幾乎所有被編寫過的(特別是用JS)大型程序都不得不用這樣或那樣的方法來管理這個間隙,不管是等待用戶輸入,從數(shù)據(jù)庫或文件系統(tǒng)請求數(shù)據(jù),通過網(wǎng)絡(luò)發(fā)送數(shù)據(jù)并等待應(yīng)答,還是在規(guī)定的時間間隔重復(fù)某些任務(wù)(比如動畫)。在所有這些各種方法中,你的程序都不得不跨越時間間隙管理狀態(tài)。就像在倫敦眾所周知的一句話(地鐵門與月臺間的縫隙):“小心間隙。”

實際上,你程序中?現(xiàn)在?與?稍后?的部分之間的關(guān)系,就是異步編程的核心。

可以確定的是,異步編程在JS的最開始就出現(xiàn)了。但是大多數(shù)開發(fā)者從沒認真地考慮過它到底是如何,為什么出現(xiàn)在他們的程序中的,也沒有探索過?其他?處理異步的方式。足夠好?的方法總是老實巴交的回調(diào)函數(shù)。今天還有許多人堅持認為回調(diào)就綽綽有余了。

但是JS在使用范圍和復(fù)雜性上不停地生長,作為運行在瀏覽器,服務(wù)器和每種可能的設(shè)備上的頭等編程語言,為了適應(yīng)它不斷擴大的要求,我們在管理異步上感受到的痛苦日趨嚴重,人們迫切地需要一種更強大更合理的處理方法。

雖然眼前這一切看起來很抽象,但我保證,隨著我們通讀這本書你會更完整且堅實地解決它。在接下來的幾章中我們將會探索各種異步JavaScript編程的新興技術(shù)。

但在接觸它們之前,我們將不得不更深刻地理解異步是什么,以及它在JS中如何運行。

塊兒(Chunks)中的程序

你可能將你的JS程序?qū)懺谝粋€?.js?文件中,但幾乎可以確定你的程序是由幾個代碼塊兒構(gòu)成的,僅有其中的一個將會在?現(xiàn)在?執(zhí)行,而其他的將會在?稍后?執(zhí)行。最常見的?代碼塊兒?單位是function。

大多數(shù)剛接觸JS的開發(fā)者都可能會有的問題是,稍后?并不嚴格且立即地在?現(xiàn)在?之后發(fā)生。換句話說,根據(jù)定義,現(xiàn)在?不能完成的任務(wù)將會異步地完成,而且我們因此不會有你可能在直覺上期望或想要的阻塞行為。

考慮這段代碼:

// ajax(..)是某個包中任意的Ajax函數(shù)vardata = ajax("http://some.url.1");console.log( data );// 噢!`data`一般不會有Ajax的結(jié)果

你可能意識到Ajax請求不會同步地完成,這意味著ajax(..)函數(shù)還沒有任何返回的值可以賦值給變量data。如果ajax(..)在應(yīng)答返回之前?能夠?阻塞,那么data = ..賦值將會正常工作。

但那不是我們使用Ajax的方式。我們?現(xiàn)在?制造一個異步的Ajax請求,直到?稍后?我們才會得到結(jié)果。

從?現(xiàn)在?“等到”?稍后?最簡單的(但絕對不是唯一的,或最好的)方法,通常稱為回調(diào)函數(shù):

// ajax(..) 是某個包中任意的Ajax函數(shù)ajax("http://some.url.1",functionmyCallbackFunction(data){console.log( data );// Yay, 我得到了一些`data`!} );

警告:?你可能聽說過發(fā)起同步的Ajax請求是可能的。雖然在技術(shù)上是這樣的,但你永遠,永遠不應(yīng)該在任何情況下這樣做,因為它將鎖定瀏覽器的UI(按鈕,菜單,滾動條,等等)而且阻止用戶與任何東西互動。這是一個非常差勁的主意,你應(yīng)當永遠回避它。

在你提出抗議之前,不,你渴望避免混亂的回調(diào)不是使用阻塞的,同步的Ajax的正當理由。

舉個例子,考慮下面的代碼:

functionnow(){return21;}functionlater(){? ? answer = answer *2;console.log("Meaning of life:", answer );}varanswer = now();setTimeout( later,1000);// Meaning of life: 42

這個程序中有兩個代碼塊兒:現(xiàn)在?將會運行的東西,和?稍后?將會運行的東西。這兩個代碼塊分別是什么應(yīng)當十分明顯,但還是讓我們以最明確的方式指出來:

現(xiàn)在:

functionnow(){return21;}functionlater(){ .. }varanswer = now();setTimeout( later,1000);

稍后:

answer = answer *2;console.log("Meaning of life:", answer );

你的程序一執(zhí)行,現(xiàn)在?代碼塊兒就會立即運行。但setTimeout(..)還設(shè)置了一個?稍后?會發(fā)生的事件(一個超時事件),所以later()函數(shù)的內(nèi)容將會在一段時間后(從現(xiàn)在開始1000毫秒)被執(zhí)行。

每當你將一部分代碼包進function并且規(guī)定它應(yīng)當為了響應(yīng)某些事件而執(zhí)行(定時器,鼠標點擊,Ajax應(yīng)答等等),你就創(chuàng)建了一個?稍后?代碼塊兒,也因此在你的程序中引入了異步。

異步控制臺

關(guān)于console.*方法如何工作,沒有相應(yīng)的語言規(guī)范或一組需求——它們不是JavaScript官方的一部分,而是由?宿主環(huán)境?添加到JS上的(見本叢書的?類型與文法)。

所以,不同的瀏覽器和JS環(huán)境各自為戰(zhàn),這有時會導(dǎo)致令人困惑的行為。

特別地,有些瀏覽器和某些條件下,console.log(..)實際上不會立即輸出它得到的東西。這個現(xiàn)象的主要原因可能是因為I/O處理很慢,而且是許多程序的阻塞部分(不僅是JS)。所以,對一個瀏覽器來說,可能的性能更好的處理方式是(從網(wǎng)頁/UI的角度看),在后臺異步地處理consoleI/O,而你也許根本不知道它發(fā)生了。

雖然不是很常見,但是一種可能被觀察到(不是從代碼本身,而是從外部)的場景是:

vara = {index:1};// 稍后console.log( a );// ??// 再稍后a.index++;

我們一般希望看到的是,就在console.log(..)語句被執(zhí)行的那一刻,對象a被取得一個快照,打印出如{ index: 1 }的內(nèi)容,如此在下一個語句a.index++執(zhí)行時,它修改不同于a的輸出,或者嚴格的在a的輸出之后的某些東西。

大多數(shù)時候,上面的代碼將會在你的開發(fā)者工具控制臺中產(chǎn)生一個你期望的對象表現(xiàn)形式。但是同樣的代碼也可能運行在這樣的情況下:瀏覽器告訴后臺它需要推遲控制臺I/O,這時,在對象在控制臺中被表示的那個時間點,a.index++已經(jīng)執(zhí)行了,所以它將顯示{ index: 2 }。

到底在什么條件下consoleI/O將被推遲是不確定的,甚至它能不能被觀察到都是不確定的。只能當你在調(diào)試過程中遇到問題時——對象在console.log(..)語句之后被修改,但你卻意外地看到了修改后的內(nèi)容——意識到I/O的這種可能的異步性。

注意:?如果你遇到了這種罕見的情況,最好的選擇是使用JS調(diào)試器的斷點,而不是依賴console的輸出。第二好的選擇是通過將目標對象序列化為一個string強制取得一個它的快照,比如用JSON.stringify(..)。

事件輪詢(Event Loop)

讓我們來做一個(也許是令人震驚的)聲明:盡管明確地允許異步JS代碼(就像我們剛看到的超時),但是實際上,直到最近(ES6)為止,JavaScript本身從來沒有任何內(nèi)建的異步概念。

什么????這聽起來簡直是瘋了,對吧?事實上,它是真的。JS引擎本身除了在某個在被要求的時刻執(zhí)行你程序的一個單獨的代碼塊外,沒有做過任何其他的事情。

“被'誰'要求”?這才是重要的部分!

JS引擎沒有運行在隔離的區(qū)域。它運行在一個?宿主環(huán)境?中,對大多數(shù)開發(fā)者來說這個宿主環(huán)境就是瀏覽器。在過去的幾年中(但不特指這幾年),JS超越了瀏覽器的界限進入到了其他環(huán)境中,比如服務(wù)器,通過Node.js這樣的東西。其實,今天JavaScript已經(jīng)被嵌入到所有種類的設(shè)備中,從機器人到電燈泡兒。

所有這些環(huán)境的一個共通的“線程”(一個“不那么微妙”的異步玩笑,不管怎樣)是,他們都有一種機制:在每次調(diào)用JS引擎時,可以?隨著時間的推移?執(zhí)行你的程序的多個代碼塊兒,這稱為“事件輪詢(Event Loop)”。

換句話說,JS引擎對?時間?沒有天生的感覺,反而是一個任意JS代碼段的按需執(zhí)行環(huán)境。是它周圍的環(huán)境在不停地安排“事件”(JS代碼的執(zhí)行)。

那么,舉例來說,當你的JS程序發(fā)起一個從服務(wù)器取得數(shù)據(jù)的Ajax請求時,你在一個函數(shù)(通常稱為回調(diào))中建立好“應(yīng)答”代碼,然后JS引擎就會告訴宿主環(huán)境,“嘿,我就要暫時停止執(zhí)行了,但不管你什么時候完成了這個網(wǎng)絡(luò)請求,而且你還得到一些數(shù)據(jù)的話,請?回來調(diào)?這個函數(shù)?!?/p>

然后瀏覽器就會為網(wǎng)絡(luò)的應(yīng)答設(shè)置一個監(jiān)聽器,當它有東西要交給你的時候,它會通過將回調(diào)函數(shù)插入?事件輪詢?來安排它的執(zhí)行。

那么什么是?事件輪詢?

讓我們先通過一些假想代碼來對它形成一個概念:

// `eventLoop`是一個像隊列一樣的數(shù)組(先進先出)vareventLoop = [ ];varevent;// “永遠”執(zhí)行while(true) {// 執(zhí)行一個"tick"if(eventLoop.length >0) {// 在隊列中取得下一個事件event = eventLoop.shift();// 現(xiàn)在執(zhí)行下一個事件try{? ? ? ? ? ? event();? ? ? ? }catch(err) {? ? ? ? ? ? reportError(err);? ? ? ? }? ? }}

當然,這只是一個用來展示概念的大幅簡化的假想代碼。但是對于幫助我們建立更好的理解來說應(yīng)該夠了。

如你所見,有一個通過while循環(huán)來表現(xiàn)的持續(xù)不斷的循環(huán),這個循環(huán)的每一次迭代稱為一個“tick”。在每一個“tick”中,如果隊列中有一個事件在等待,它就會被取出執(zhí)行。這些事件就是你的函數(shù)回調(diào)。

很重要并需要注意的是,setTimeout(..)不會將你的回調(diào)放在事件輪詢隊列上。它設(shè)置一個定時器;當這個定時器超時的時候,環(huán)境才會把你的回調(diào)放進事件輪詢,這樣在某個未來的tick中它將會被取出執(zhí)行。

如果在那時事件輪詢隊列中已經(jīng)有了20個事件會怎么樣?你的回調(diào)要等待。它會排到隊列最后——沒有一般的方法可以插隊和跳到隊列的最前方。這就解釋了為什么setTimeout(..)計時器可能不會完美地按照預(yù)計時間觸發(fā)。你得到一個保證(粗略地說):你的回調(diào)不會再你指定的時間間隔之前被觸發(fā),但是可能會在這個時間間隔之后被觸發(fā),具體要看事件隊列的狀態(tài)。

換句話說,你的程序通常被打斷成許多小的代碼塊兒,它們一個接一個地在事件輪詢隊列中執(zhí)行。而且從技術(shù)上說,其他與你的程序沒有直接關(guān)系的事件也可以穿插在隊列中。

注意:?我們提到了“直到最近”,暗示著ES6改變了事件輪詢隊列在何處被管理的性質(zhì)。這主要是一個正式的技術(shù)規(guī)范,ES6現(xiàn)在明確地指出了事件輪詢應(yīng)當如何工作,這意味著它技術(shù)上屬于JS引擎應(yīng)當關(guān)心的范疇內(nèi),而不僅僅是?宿主環(huán)境。這么做的一個主要原因是為了引入ES6的Promises(我們將在第三章討論),因為人們需要有能力對事件輪詢隊列的排隊操作進行直接,細粒度的控制(參見“協(xié)作”一節(jié)中關(guān)于setTimeout(..0)的討論)。

并行線程

將“異步”與“并行”兩個詞經(jīng)常被混為一談,但它們實際上是十分不同的。記住,異步是關(guān)于?現(xiàn)在?與?稍后?之間的間隙。但并行是關(guān)于可以同時發(fā)生的事情。

關(guān)于并行計算最常見的工具就是進程與線程。進程和線程獨立地,可能同時地執(zhí)行:在不同的處理器上,甚至在不同的計算機上,而多個線程可以共享一個進程的內(nèi)存資源。

相比之下,一個事件輪詢將它的工作打碎成一系列任務(wù)并串行地執(zhí)行它們,不允許并行訪問和更改共享的內(nèi)存。并行與“串行”可能以在不同線程上的事件輪詢協(xié)作的形式共存。

并行線程執(zhí)行的穿插,與異步事件的穿插發(fā)生在完全不同的粒度等級上:

比如:

functionlater(){? ? answer = answer *2;console.log("Meaning of life:", answer );}

雖然later()的整個內(nèi)容將被當做一個事件輪詢隊列的實體,但當考慮到將要執(zhí)行這段代碼的線程時,實際上也許會有許多不同的底層操作。比如,answer = answer * 2首先需要讀取當前answer的值,再把2放在某個地方,然后進行乘法計算,最后把結(jié)果存回到answer。

在一個單線程環(huán)境中,線程隊列中的內(nèi)容都是底層操作真的無關(guān)緊要,因為沒有什么可以打斷線程。但如果你有一個并行系統(tǒng),在同一個程序中有兩個不同的線程,你很可能會得到無法預(yù)測的行為:

考慮這段代碼:

vara =20;functionfoo(){? ? a = a +1;}functionbar(){? ? a = a *2;}// ajax(..) 是一個給定的庫中的隨意Ajax函數(shù)ajax("http://some.url.1", foo );ajax("http://some.url.2", bar );

在JavaScript的單線程行為下,如果foo()在bar()之前執(zhí)行,結(jié)果a是42,但如果bar()在foo()之前執(zhí)行,結(jié)果a將是41。

如果JS事件共享相同的并列執(zhí)行數(shù)據(jù),問題將會變得微妙得多??紤]這兩個假想代碼段,它們分別描述了運行foo()和bar()中代碼的線程將要執(zhí)行的任務(wù),并考慮如果它們在完全相同的時刻運行會發(fā)生什么:

線程1(X和Y是臨時的內(nèi)存位置):

foo():? ? a. 將`a`的值讀取到`X`b. 將`1`存入`Y`c. 把`X`和`Y`相加,將結(jié)果存入`X`d. 將`X`的值存入`a`

線程2(X和Y是臨時的內(nèi)存位置):

bar():? a. 將`a`的值讀取到`X`b. 將`2`存入`Y`c. 把`X`和`Y`相乘,將結(jié)果存入`X`d. 將`X`的值存入`a`

現(xiàn)在,讓我們假定這兩個線程在并行執(zhí)行。你可能發(fā)現(xiàn)了問題,對吧?它們在臨時的步驟中使用共享的內(nèi)存位置X和Y。

如果步驟像這樣發(fā)生,a的最終結(jié)果什么?

1a? (將`a`的值讀取到`X`==>`20`)2a? (將`a`的值讀取到`X`==>`20`)1b? (將`1`存入`Y`==>`1`)2b? (將`2`存入`Y`==>`2`)1c? (把`X`和`Y`相加,將結(jié)果存入`X`==>`22`)1d? (將`X`的值存入`a`==>`22`)2c? (把`X`和`Y`相乘,將結(jié)果存入`X`==>`44`)2d? (將`X`的值存入`a`==>`44`)

a中的結(jié)果將是44。那么這種順序呢?

1a? (將`a`的值讀取到`X`==>`20`)2a? (將`a`的值讀取到`X`==>`20`)2b? (將`2`存入`Y`==>`2`)1b? (將`1`存入`Y`==>`1`)2c? (把`X`和`Y`相乘,將結(jié)果存入`X`==>`20`)1c? (把`X`和`Y`相加,將結(jié)果存入`X`==>`21`)1d? (將`X`的值存入`a`==>`21`)2d? (將`X`的值存入`a`==>`21`)

a中的結(jié)果將是21。

所以,關(guān)于線程的編程十分刁鉆,因為如果你不采取特殊的步驟來防止這樣的干擾/穿插,你會得到令人非常詫異的,不確定的行為。這通常讓人頭疼。

JavaScript從不跨線程共享數(shù)據(jù),這意味著不必關(guān)心這一層的不確定性。但這并不意味著JS總是確定性的。記得前面foo()和bar()的相對順序產(chǎn)生兩個不同的結(jié)果嗎(41或42)?

注意:?可能還不明顯,但不是所有的不確定性都是壞的。有時候它無關(guān)緊要,有時候它是故意的。我們會在本章和后續(xù)幾章中看到更多的例子。

運行至完成

因為JavaScript是單線程的,foo()(和bar())中的代碼是原子性的,這意味著一旦foo()開始運行,它的全部代碼都會在bar()中的任何代碼可以運行之前執(zhí)行完成,反之亦然。這稱為“運行至完成”行為。

事實上,運行至完成的語義會在foo()與bar()中有更多的代碼時更明顯,比如:

vara =1;varb =2;functionfoo(){? ? a++;? ? b = b * a;? ? a = b +3;}functionbar(){? ? b--;? ? a =8+ b;? ? b = a *2;}// ajax(..) 是某個包中任意的Ajax函數(shù)ajax("http://some.url.1", foo );ajax("http://some.url.2", bar );

因為foo()不能被bar()打斷,而且bar()不能被foo()打斷,所以這個程序根據(jù)哪一個先執(zhí)行只有兩種可能的結(jié)果——如果線程存在,foo()和bar()中的每一個語句都可能被穿插,可能的結(jié)果數(shù)量將會極大地增長!

代碼塊兒1是同步的(現(xiàn)在?發(fā)生),但代碼塊兒2和3是異步的(稍后?發(fā)生),這意味著它們的執(zhí)行將會被時間的間隙分開。

代碼塊兒1:

vara =1;varb =2;

代碼塊兒2 (foo()):

a++;

b = b * a;

a = b + 3;

代碼塊兒3 (bar()):

b--;

a = 8 + b;

b = a * 2;

代碼塊兒2和3哪一個都有可能先執(zhí)行,所以這個程序有兩個可能的結(jié)果,正如這里展示的:

結(jié)果1:

vara =1;varb =2;// foo()a++;b = b * a;a = b +3;// bar()b--;a =8+ b;b = a *2;a;// 11b;// 22

結(jié)果2:

vara =1;varb =2;// bar()b--;a =8+ b;b = a *2;// foo()a++;b = b * a;a = b +3;a;// 183b;// 180

同一段代碼有兩種結(jié)果仍然意味著不確定性!但是這是在函數(shù)(事件)順序的水平上,而不是在使用線程時語句順序的水平上(或者說,實際上是表達式操作的順序上)。換句話說,他比線程更具有?確定性

當套用到JavaScript行為時,這種函數(shù)順序的不確定性通常稱為“競合狀態(tài)”,因為foo()和bar()在互相競爭看誰會先運行。明確地說,它是一個“競合狀態(tài)”因為你不能可靠地預(yù)測a與b將如何產(chǎn)生。

注意:?如果在JS中不知怎的有一個函數(shù)沒有運行至完成的行為,我們會有更多可能的結(jié)果,對吧?ES6中引入一個這樣的東西(見第四章“生成器”),但現(xiàn)在不要擔(dān)心,我們會回頭討論它。

并發(fā)

讓我們想象一個網(wǎng)站,它顯示一個隨著用戶向下滾動而逐步加載的狀態(tài)更新列表(就像社交網(wǎng)絡(luò)的新消息)。要使這樣的特性正確工作,(至少)需要兩個分離的“進程”?同時執(zhí)行(在同一個時間跨度內(nèi),但沒必要是同一個時間點)。

注意:?我們在這里使用帶引號的“進程”,因為它們不是計算機科學(xué)意義上的真正的操作系統(tǒng)級別的進程。它們是虛擬進程,或者說任務(wù),表示一組邏輯上關(guān)聯(lián),串行順序的操作。我們將簡單地使用“進程”而非“任務(wù)”,因為在術(shù)語層面它與我們討論的概念的定義相匹配。

第一個“進程”將響應(yīng)當用戶向下滾動頁面時觸發(fā)的onscroll事件(發(fā)起取得新內(nèi)容的Ajax請求)。第二個“進程”將接收返回的Ajax應(yīng)答(將內(nèi)容繪制在頁面上)。

顯然,如果用戶向下滾動的足夠快,你也許會看到在第一個應(yīng)答返回并處理期間,有兩個或更多的onscroll事件被觸發(fā),因此你將使onscroll事件和Ajax應(yīng)答事件迅速觸發(fā),互相穿插在一起。

并發(fā)是當兩個或多個“進程”在同一時間段內(nèi)同時執(zhí)行,無論構(gòu)成它們的各個操作是否?并行地(在同一時刻不同的處理器或內(nèi)核)發(fā)生。你可以認為并發(fā)是“進程”級別的(或任務(wù)級別)的并行機制,而不是操作級別的并行機制(分割進程的線程)。

注意:?并發(fā)還引入了這些“進程”間彼此互動的概念。我們稍后會討論它。

在一個給定的時間跨度內(nèi)(用戶可以滾動的那幾秒),讓我們將每個獨立的“進程”作為一系列事件/操作描繪出來:

“線程”1 (onscroll事件):

onscroll, request 1

onscroll, request 2

onscroll, request 3

onscroll, request 4

onscroll, request 5

onscroll, request 6

onscroll, request 7

“線程”2 (Ajax應(yīng)答事件):

response 1

response 2

response 3

response 4

response 5

response 6

response 7

一個onscroll事件與一個Ajax應(yīng)答事件很有可能在同一個?時刻?都準備好被處理了。比如我們在一個時間線上描繪一下這些事件的話:

onscroll, request 1

onscroll, request 2? ? ? ? ? response 1

onscroll, request 3? ? ? ? ? response 2

response 3

onscroll, request 4

onscroll, request 5

onscroll, request 6? ? ? ? ? response 4

onscroll, request 7

response 6

response 5

response 7

但是,回到本章前面的事件輪詢概念,JS一次只能處理一個事件,所以不是onscroll, request 2首先發(fā)生就是response 1首先發(fā)生,但是他們不可能完全在同一時刻發(fā)生。就像學(xué)校食堂的孩子們一樣,不管他們在門口擠成什么樣,他們最后都不得不排成一個隊來打飯!

讓我們來描繪一下所有這些事件在事件輪詢隊列上穿插的情況:

事件輪詢隊列:

onscroll, request 1? <--- 進程1開始

onscroll, request 2

response 1? ? ? ? ? ? <--- 進程2開始

onscroll, request 3

response 2

response 3

onscroll, request 4

onscroll, request 5

onscroll, request 6

response 4

onscroll, request 7? <--- 進程1結(jié)束

response 6

response 5

response 7? ? ? ? ? ? <--- 進程2結(jié)束

“進程1”和“進程2”并發(fā)地運行(任務(wù)級別的并行),但是它們的個別事件在事件輪詢隊列上順序地運行。

順便說一句,注意到response 6和response 5沒有按照預(yù)想的順序應(yīng)答嗎?

單線程事件輪詢是并發(fā)的一種表達(當然還有其他的表達,我們稍后討論)。

非互動

在同一個程序中兩個或更多的“進程”在穿插它們的步驟/事件時,如果它們的任務(wù)之間沒有聯(lián)系,那么他們就沒必要互動。如果它們不互動,不確定性就是完全可以接受的。

舉個例子:

varres = {};functionfoo(results){? ? res.foo = results;}functionbar(results){? ? res.bar = results;}// ajax(..) 是某個包中任意的Ajax函數(shù)ajax("http://some.url.1", foo );ajax("http://some.url.2", bar );

foo()和bar()是兩個并發(fā)的“進程”,而且它們被觸發(fā)的順序是不確定的。但對我們的程序的結(jié)構(gòu)來講它們的觸發(fā)順序無關(guān)緊要,因為它們的行為相互獨立所以不需要互動。

這不是一個“競合狀態(tài)”Bug,因為這段代碼總能夠正確工作,與順序無關(guān)。

互動

更常見的是,通過作用域和/或DOM,并發(fā)的“進程”將有必要間接地互動。當這樣的互動將要發(fā)生時,你需要協(xié)調(diào)這些互動行為來防止前面講述的“競合狀態(tài)”。

這里是兩個由于隱含的順序而互動的并發(fā)“進程”的例子,它?有時會出錯

varres = [];functionresponse(data){? ? res.push( data );}// ajax(..) 是某個包中任意的Ajax函數(shù)ajax("http://some.url.1", response );ajax("http://some.url.2", response );

并發(fā)的“進程”是那兩個將要處理Ajax應(yīng)答的response()調(diào)用。它們誰都有可能先發(fā)生。

假定我們期望的行為是res[0]擁有"http://some.url.1"調(diào)用的結(jié)果,而res[1]擁有"http://some.url.2"調(diào)用的結(jié)果。有時候結(jié)果確實是這樣,而有時候則相反,要看哪一個調(diào)用首先完成。很有可能,這種不確定性是一個“競合狀態(tài)”Bug。

注意:?在這些情況下要極其警惕你可能做出的主觀臆測。比如這樣的情況就沒什么不尋常:一個開發(fā)者觀察到"http://some.url.2"的應(yīng)答“總是”比"http://some.url.1"要慢得多,也許有賴于它們所做的任務(wù)(比如,一個執(zhí)行數(shù)據(jù)庫任務(wù)而另一個只是取得靜態(tài)文件),所以觀察到的順序看起來總是所期望的。就算兩個請求都發(fā)到同一個服務(wù)器,而且它故意以確定的順序應(yīng)答,也不能?真正?保證應(yīng)答回到瀏覽器的順序。

所以,為了解決這樣的競合狀態(tài),你可以協(xié)調(diào)互動的順序:

varres = [];functionresponse(data){if(data.url =="http://some.url.1") {? ? ? ? res[0] = data;? ? }elseif(data.url =="http://some.url.2") {? ? ? ? res[1] = data;? ? }}// ajax(..) 是某個包中任意的Ajax函數(shù)ajax("http://some.url.1", response );ajax("http://some.url.2", response );

無論哪個Ajax應(yīng)答首先返回,我們都考察它的data.url(當然,假設(shè)這樣的數(shù)據(jù)會從服務(wù)器返回)來找到應(yīng)答數(shù)據(jù)應(yīng)當在res數(shù)組中占有的位置。res[0]將總是持有"http://some.url.1"的結(jié)果,而res[1]將總是持有"http://some.url.2"的結(jié)果。通過簡單的協(xié)調(diào),我們消除了“競合狀態(tài)”的不確定性。

這個場景的同樣道理可以適用于這樣的情況:多個并發(fā)的函數(shù)調(diào)用通過共享的DOM互動,比如一個在更新

的內(nèi)容而另一個在更新
的樣式或?qū)傩裕ū热缫坏〥OM元素擁有內(nèi)容就使它變得可見)。你可能不想在DOM元素擁有內(nèi)容之前顯示它,所以協(xié)調(diào)工作就必須保證正確順序的互動。

沒有協(xié)調(diào)的互動,有些并發(fā)的場景?總是出錯(不僅僅是?有時)??紤]下面的代碼:

vara, b;functionfoo(x){? ? a = x *2;? ? baz();}functionbar(y){? ? b = y *2;? ? baz();}functionbaz(){console.log(a + b);}// ajax(..) 是某個包中任意的Ajax函數(shù)ajax("http://some.url.1", foo );ajax("http://some.url.2", bar );

在這個例子中,不管foo()和bar()誰先觸發(fā),總是會使baz()運行的太早了(a和b之一還是空的時候),但是第二個baz()調(diào)用將可以工作,因為a和b將都是可用的。

有許多不同的方法可以解決這個狀態(tài)。這是簡單的一種:

vara, b;functionfoo(x){? ? a = x *2;if(a && b) {? ? ? ? baz();? ? }}functionbar(y){? ? b = y *2;if(a && b) {? ? ? ? baz();? ? }}functionbaz(){console.log( a + b );}// ajax(..) 是某個包中任意的Ajax函數(shù)ajax("http://some.url.1", foo );ajax("http://some.url.2", bar );

baz()調(diào)用周圍的if (a && b)條件通常稱為“大門”,因為我們不能確定a和b到來的順序,但在打開大門(調(diào)用baz())之前我們等待它們?nèi)康竭_。

另一種你可能會遇到的并發(fā)互動狀態(tài)有時稱為“競爭”,單更準確地說應(yīng)該叫“門閂”。它的行為特點是“先到者勝”。在這里不確定性是可以接受的,因為你明確指出“競爭”的終點線上只有一個勝利者。

考慮這段有問題的代碼:

vara;functionfoo(x){? ? a = x *2;? ? baz();}functionbar(x){? ? a = x /2;? ? baz();}functionbaz(){console.log( a );}// ajax(..) 是某個包中任意的Ajax函數(shù)ajax("http://some.url.1", foo );ajax("http://some.url.2", bar );

不管哪一個函數(shù)最后觸發(fā)(foo()或bar()),它不僅會覆蓋前一個函數(shù)對a的賦值,還會重復(fù)調(diào)用baz()(不太可能是期望的)。

所以,我們可以用一個簡單的門閂來協(xié)調(diào)互動,僅讓第一個過去:

vara;functionfoo(x){if(a ==undefined) {? ? ? ? a = x *2;? ? ? ? baz();? ? }}functionbar(x){if(a ==undefined) {? ? ? ? a = x /2;? ? ? ? baz();? ? }}functionbaz(){console.log( a );}// ajax(..) 是某個包中任意的Ajax函數(shù)ajax("http://some.url.1", foo );ajax("http://some.url.2", bar );

if (a == undefined)條件僅會讓foo()或bar()中的第一個通過,而第二個(以及后續(xù)所有的)調(diào)用將會被忽略。第二名什么也得不到!

注意:?在所有這些場景中,為了簡化說明的目的我們都用了全局變量,這里我們沒有任何理由需要這么做。只要我們討論中的函數(shù)可以訪問變量(通過作用域),它們就可以正常工作。依賴于詞法作用域變量(參見本叢書的?作用域與閉包?),和這些例子中實質(zhì)上的全局變量,是這種并發(fā)協(xié)調(diào)形式的一個明顯的缺點。在以后的幾章中,我們會看到其他的在這方面干凈得多的協(xié)調(diào)方法。

協(xié)作

另一種并發(fā)協(xié)調(diào)的表達稱為“協(xié)作并發(fā)”,它并不那么看重在作用域中通過共享值互動(雖然這依然是允許的?。?。它的目標是將一個長時間運行的“進程”打斷為許多步驟或批處理,以至于其他的并發(fā)“進程”有機會將它們的操作穿插進事件輪詢隊列。

舉個例子,考慮一個Ajax應(yīng)答處理器,它需要遍歷一個很長的結(jié)果列表來將值變形。我們將使用Array#map(..)來讓代碼短一些:

varres = [];// `response(..)`從Ajax調(diào)用收到一個結(jié)果數(shù)組functionresponse(data){// 連接到既存的`res`數(shù)組上res = res.concat(// 制造一個新的變形過的數(shù)組,所有的`data`值都翻倍data.map(function(val){returnval *2;? ? ? ? } )? ? );}// ajax(..) 是某個包中任意的Ajax函數(shù)ajax("http://some.url.1", response );ajax("http://some.url.2", response );

如果"http://some.url.1"首先返回它的結(jié)果,整個結(jié)果列表將會一次性映射進res。如果只有幾千或更少的結(jié)果記錄,一般來說不是什么大事。但假如有1千萬個記錄,那么就可能會花一段時間運行(在強大的筆記本電腦上花幾秒鐘,在移動設(shè)備上花的時間長得多,等等)。

當這樣的“處理”運行時,頁面上沒有任何事情可以發(fā)生,包括不能有另一個response(..)調(diào)用,不能有UI更新,甚至不能有用戶事件比如滾動,打字,按鈕點擊等。非常痛苦。

所以,為了制造協(xié)作性更強、更友好而且不獨占事件輪詢隊列的并發(fā)系統(tǒng),你可以在一個異步批處理中處理這些結(jié)果,在批處理的每一步都“讓出”事件輪詢來讓其他等待的事件發(fā)生。

這是一個非常簡單的方法:

varres = [];// `response(..)`從Ajax調(diào)用收到一個結(jié)果數(shù)組functionresponse(data){// 我們一次只處理1000件varchunk = data.splice(0,1000);// 連接到既存的`res`數(shù)組上res = res.concat(// 制造一個新的變形過的數(shù)組,所有的`data`值都翻倍chunk.map(function(val){returnval *2;? ? ? ? } )? ? );// 還有東西要處理嗎?if(data.length >0) {// 異步規(guī)劃下一個批處理setTimeout(function(){? ? ? ? ? ? response( data );? ? ? ? },0);? ? }}// ajax(..) 是某個包中任意的Ajax函數(shù)ajax("http://some.url.1", response );ajax("http://some.url.2", response );

我們以每次最大1000件作為一個塊兒處理數(shù)據(jù)。這樣,我們保證每個“進程”都是短時間運行的,即便這意味著會有許多后續(xù)的“進程”,在事件輪詢隊列上的穿插將會給我們一個響應(yīng)性(性能)強得多的網(wǎng)站/應(yīng)用程序。

當然,我們沒有對任何這些“進程”的順序進行互動協(xié)調(diào),所以在res中的結(jié)果的順序是不可預(yù)知的。如果要求順序,你需要使用我們之前討論的互動技術(shù),或者在本書后續(xù)章節(jié)中介紹的其他技術(shù)。

我們使用setTimeout(..0)(黑科技)來異步排程,基本上它的意思是“將這個函數(shù)貼在事件輪詢隊列的末尾”。

注意:?從技術(shù)上講,setTimeout(..0)沒有直接將一條記錄插入事件輪詢隊列。計時器將會在下一個運行機會將事件插入。比如,兩個連續(xù)的setTimeout(..0)調(diào)用不會嚴格保證以調(diào)用的順序被處理,所以我們可能看到各種時間偏移的情況,使這樣的事件的順序是不可預(yù)知的。在Node.js中,一個相似的方式是process.nextTick(..)。不管那將會有多方便(而且通常性能更好),(還)沒有一個直接的方法可以橫跨所有環(huán)境來保證異步事件順序。我們會在下一節(jié)詳細討論這個話題。

Jobs

在ES6中,在事件輪詢隊列之上引入了一層新概念,稱為“工作隊列(Job queue)”。你最有可能接觸它的地方是在Promises(見第三章)的異步行為中。

不幸的是,它目前是一個沒有公開API的機制,因此要演示它有些兜圈子。我們不得不僅僅在概念上描述它,這樣當我們在第三章中討論異步行為時,你將會理解那些動作行為是如何排程與處理的。

那么,我能找到的考慮它的最佳方式是:“工作隊列”是一個掛靠在事件輪詢隊列的每個tick末尾的隊列。在事件輪詢的一個tick期間內(nèi),某些可能發(fā)生的隱含異步動作的行為將不會導(dǎo)致一個全新的事件加入事件輪詢隊列,而是在當前tick的工作隊列的末尾加入一個新的記錄(也就是一個Job)。

它好像是在說,“哦,另一件需要我?稍后?去做的事兒,但是保證它在其他任何事情發(fā)生之間發(fā)生?!?/p>

或者,用一個比喻:事件輪詢隊列就像一個游樂園項目,一旦你乘坐完一次,你就不得不去隊尾排隊來乘坐下一次。而工作隊列就像乘坐完后,立即插隊乘坐下一次。

一個Job還可能會導(dǎo)致更多的Job被加入同一個隊列的末尾。所以,一個在理論上可能的情況是,Job“輪詢”(一個Job持續(xù)不斷地加入其他Job等)會無限地轉(zhuǎn)下去,從而拖住程序不能移動到一下一個事件輪詢tick。這與在你的代碼中表達一個長時間運行或無限循環(huán)(比如while (true) ..)在概念上幾乎是一樣的。

Job的精神有點兒像setTimeout(..0)黑科技,但以一種定義明確得多的方式實現(xiàn),而且保證順序:?稍后,但盡快。

讓我們想象一個用于Job排程的API,并叫它schedule(..)。考慮如下代碼:

console.log("A");setTimeout(function(){console.log("B");},0);// 理論上的 "Job API"schedule(function(){console.log("C");? ? schedule(function(){console.log("D");? ? } );} );

你肯能會期望它打印出A B C D,但是它將會打出A C D B,因為Job發(fā)生在當前的事件輪詢tick的末尾,而定時器會在?下一個?事件輪詢tick(如果可用的話?。┯|發(fā)排程。

在第三章中,我們會看到Promises的異步行為是基于Job的,所以搞明白它與事件輪詢行為的聯(lián)系是很重要的。

語句排序

我們在代碼中表達語句的順序沒有必要與JS引擎執(zhí)行它們的順序相同。這可能看起來像是個奇怪的論斷,所以我們簡單地探索一下。

但在我們開始之前,我們應(yīng)當對一些事情十分清楚:從程序的角度看,語言的規(guī)則/文法(參見本叢書的?類型與文法)為語句的順序決定了一個非??深A(yù)知、可靠的行為。所以我們將要討論的是在你的JS程序中?應(yīng)當永遠觀察不到的東西。

警告:?如果你曾經(jīng)?觀察到?過我們將要描述的編譯器語句重排,那明顯是違反了語言規(guī)范,而且無疑是那個JS引擎的Bug——它應(yīng)當被報告并且修復(fù)!但是更常見的是你?懷疑JS引擎里發(fā)生了什么瘋狂的事,而事實上它只是你自己代碼中的一個Bug(可能是一個“競合狀態(tài)”)——所以先檢查那里,多檢查幾遍。在JS調(diào)試器使用斷點并一行一行地步過你的代碼,將是幫你在?你的代碼?中找出這樣的Bug的最強大的工具。

考慮下面的代碼:

vara, b;a =10;b =30;a = a +1;b = b +1;console.log( a + b );// 42

這段代碼沒有任何異步表達(除了早先討論的罕見的console異步I/O),所以最有可能的推測是它會一行一行地、從上到下地處理。

但是,JS引擎?有可能,在編譯完這段代碼后(是的,JS是被編譯的——見本叢書的?作用域與閉包)發(fā)現(xiàn)有機會通過(安全地)重新安排這些語句的順序來使你的代碼運行得更快。實質(zhì)上,只要你觀察不到重排,一切都是合理的。

舉個例子,引擎可能會發(fā)現(xiàn)如果實際上這樣執(zhí)行代碼會更快:

vara, b;a =10;a++;b =30;b++;console.log( a + b );// 42

或者是這樣:

vara, b;a =11;b =31;console.log( a + b );// 42

或者甚至是:

// 因為`a`和`b`都不再被使用,我們可以內(nèi)聯(lián)而且根本不需要它們!console.log(42);// 42

在所有這些情況下,JS引擎在它的編譯期間進行著安全的優(yōu)化,而最終的?可觀察到?的結(jié)果將是相同的。

但也有一個場景,這些特殊的優(yōu)化是不安全的,因而也是不被允許的(當然,不是說它一點兒都沒優(yōu)化):

vara, b;a =10;b =30;// 我們需要`a`和`b`遞增之前的狀態(tài)!console.log( a * b );// 300a = a +1;b = b +1;console.log( a + b );// 42

編譯器重排會造成可觀測的副作用(因此絕不會被允許)的其他例子,包括任何帶有副作用的函數(shù)調(diào)用(特別是getter函數(shù)),或者ES6的Proxy對象(參見本叢書的?ES6與未來)。

考慮如下代碼:

functionfoo(){console.log( b );return1;}vara, b, c;// ES5.1 getter 字面語法c = {? ? get bar() {console.log( a );return1;? ? }};a =10;b =30;a += foo();// 30b += c.bar;// 11console.log( a + b );// 42

如果不是為了這個代碼段中的console.log(..)語句(只是作為這個例子中觀察副作用的方便形式),JS引擎將會更加自由,如果它想(誰知道它想不想?。浚?,它會重排這段代碼:

// ...a =10+ foo();b =30+ c.bar;// ...

多虧JS語義,我們不會觀測到看起來很危險的編譯器語句重排,但是理解源代碼被編寫的方式(從上到下)與它在編譯后運行的方式之間的聯(lián)系是多么微弱,依然是很重要的。

編譯器語句重排幾乎是并發(fā)與互動的微型比喻。作為一個一般概念,這樣的意識可以幫你更好地理解異步JS代碼流問題。

復(fù)習(xí)

一個JavaScript程序總是被打斷為兩個或更多的代碼塊兒,第一個代碼塊兒?現(xiàn)在?運行,下一個代碼塊兒?稍后?運行,來響應(yīng)一個事件。雖然程序是一塊兒一塊兒地被執(zhí)行的,但它們都共享相同的程序作用域和狀態(tài),所以對狀態(tài)的每次修改都是在前一個狀態(tài)之上的。

不論何時有事件要運行,事件輪詢?將運行至隊列為空。事件輪詢的每次迭代稱為一個“tick”。用戶交互,IO,和定時器會將事件在事件隊列中排隊。

在任意給定的時刻,一次只有一個隊列中的事件可以被處理。當事件執(zhí)行時,他可以直接或間接地導(dǎo)致一個或更多的后續(xù)事件。

并發(fā)是當兩個或多個事件鏈條隨著事件相互穿插,因此從高層的角度來看,它們在?同時運行(即便在給定的某一時刻只有一個事件在被處理)。

在這些并發(fā)“進程”之間進行某種形式的互動協(xié)調(diào)通常是有必要的,比如保證順序或防止“競合狀態(tài)”。這些“進程”還可以?協(xié)作:通過將它們自己打斷為小的代碼塊兒來允許其他“進程”穿插。

小禮物走一走,來簡書關(guān)注我

贊賞支持

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容