你不知道JS:異步(翻譯)系列1

翻譯了You don't know JS:async & performance中的異步部分,原文地址:https://github.com/getify/You-Dont-Know-JS/tree/master/async%20%26%20performance。包括回調、Promise、生成器等。

你不知道JS:異步

第一章:異步:現在 & 以后(Now & Later)

在像JavaScript這種編程語言中,最重要也是常被誤解的一點是如何表示和控制程序的行為,使其擴展到一段時間內。

這并不只是從for循環開始到for循環結束所發生的那么簡單,當然,它會花費一定的時間(幾微秒到幾毫秒)。我們關注的是當你的部分程序現在運行,而另一部分之后運行時所發生的事情--在 現在以后 (對應的程序并不是主動執行的)之間有個界限(gap)。

日常開發中,所有寫過的優秀程序(尤其是在JS中)都或多或少地需要管理這個界限,不論是等待用戶輸入、從數據庫或文件系統請求數據、跨網絡發送數據和等待響應,還是定期執行一個重復任務(比如動畫)。在所有這些場景當中,你都得及時地在界限間管理狀態。正如倫敦地鐵著名的那句:小心間隙(mind the gap)。

事實上,程序中 現在以后 的關系是異步編程的核心。

可以確認的一點是,自出現JS,異步編程就一直貫穿始終。但絕大多數JS開發者沒有認真考慮過,他們的程序中是怎么以及為什么突然出現異步的,或者研究過如何用其它方法來實現。回調函數一直都是很棒的方法。至今仍有許多人堅持認為回調函數已經足夠了。

但是,作為運行在瀏覽器端和服務端,以及每個能想象的到的設備中的第一等語言,為了滿足廣泛的需求,JS在范圍和復雜度上面都在不斷增長,我們管理異步的痛苦與日俱增。所以需要尋求一種功能更好、更合理的方法。

盡管目前看起來十分抽象,隨著不斷深入此書,我向你們保證我們能夠更完整和徹底地處理異步。在之后幾章中,我們會探索JS中出現的各種異步編程技術。

但在開始之前,我們必須深刻理解下什么是異步,以及如何在JS中實現。

塊狀形式的程序(A Program in Chunks)

你可能在一個.js的文件中寫JS程序,但是你的程序絕大多數是由幾塊組成的,只有一部分是 現在 執行的,其余的是 以后 執行的。最常見的塊單元是function

絕大多數JS新手遇到的問題是,認為 以后 不是嚴格地在 現在 之后立即發生的。換句話說,從定義上講,不能 現在 完成的任務將會異步完成,因此不會出現你直觀上期望或想要的阻塞行為。

考慮如下代碼:

// ajax(..) is some arbitrary Ajax function given by a library
var data = ajax( "http://some.url.1" );

console.log( data );
// Oops! `data` generally won't have the Ajax results

你也許注意到了標準的Ajax請求并不是同步的,這意味著ajax(...)還沒有返回值用來賦給data變量。如果ajax(...)能夠阻塞程序直到返回響應,則data=...賦值操作能夠正常運行。

但這不是我們運用Ajax的方式。我們 現在 作了一個異步的Ajax請求,直到 以后 才獲得結果。

最簡單的(但顯然不是唯一,更不是最好的)從 現在 “等“,直到 以后 的方法是采用一個函數,通常稱為回調函數:

// ajax(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", function myCallbackFunction(data){

    console.log( data ); // Yay, I gots me some `data`!

} );

警告: 你可能聽說過可以使用同步Ajax請求。盡管技術上是可行的,但是絕對不要在任何情況下使用。因為它會鎖死瀏覽器UI(按鈕、菜單、滾動等),并且會阻止任何用戶交互。這是個很糟糕的想法,應極力避免。

在你提出異議之前,不,你的避免回調混亂的想法并不是采用阻塞的、同步Ajax的正當理由。

例如,考慮以下代碼:

function now() {
    return 21;
}

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

var answer = now();

setTimeout( later, 1000 ); // Meaning of life: 42

這段程序有兩大塊(chunk),一塊是 現在 執行的,另一塊是 以后 執行的。這兩塊應該很清楚就能看出來,讓我們明確解釋一下:

現在

function now() {
    return 21;
}

function later() { .. }

var answer = now();

setTimeout( later, 1000 );

以后

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

只要你執行程序,現在塊立即運行。但是setTimeout(...)也設置了一個事件(一個超時),用來以后執行,因此later()函數的內容會在之后的一個時間執行(從現在起1000毫秒后)。

任何時候把一段代碼包裹到function中,并指定到對應事件(定時器、鼠標單擊、Ajax請求等)執行,你就創建了一個以后代碼塊,從而在程序中引入異步。

異步 Console(Async Console)

沒有規范或要求定義console.*方法是如何運行的--它們并不是JavaScript的官方部分,而是主機環境(譯者注:如瀏覽器,服務端等)加入到JS當中的。

因此,不同的瀏覽器和JS環境隨意實現,有時可能會導致令人困惑的行為。

尤其是有些瀏覽器和環境中,console.log(...)并不會立即輸出給定的值。主要原因可能是I/O在許多程序中(不僅僅是JS)是一個非常緩慢和阻塞性的部分。因此,對瀏覽器來說,在后臺異步處理consoleI/O可能性能更好(從頁面/UI角度),你甚至不知道它的發生。

盡管不太常見,但可能看到這個場景(不是從代碼層面,而是從外部(譯者注:執行結果)):

var a = {
    index: 1
};

// later
console.log( a ); // ??

// even later
a.index++;

我們通常認為a對象在console.log(...)語句執行時是個快照,輸出比如{index:1},以至于當a.index++執行的時候,只是嚴格地在輸出 之后修改a

大多數時候,前面的代碼在你的開發工具中的控制臺能夠產生你期望的結果。但同樣的代碼也可能在覺得需要推遲consoleI/O到后臺的瀏覽器中運行,那樣的話,在瀏覽器控制臺中,顯示出a的時候,a.index++可能已經執行了,從而輸出{index:2}

到底在什么情況下consoleI/O會被推遲執行,或者是否能觀測到這一現象是不確定的。記住,每當你調試一個在console.log(...)語句后修改對象的代碼時,注意可能的異步I/O,你可能看到不可思議的結果出現。

注意: 如果你碰到這種很少見的情況,最好的選擇是采用JS調試器的斷點,而不是依賴console輸出。另一個較好的方法是將查詢的對象序列化為字符串,比如JSON.stringify(...)

事件輪詢(Event Loop)

讓我們小小抱怨一下:盡管允許異步JS代碼(正如我們剛剛看到的timeout一樣),直到最近(ES6),JS本身從來沒有內建的直接表示異步的概念。

納尼!?似乎很瘋狂對不對,事實上是真的。當被要求的時候,JS引擎除了在任何給定時刻執行程序中的單個代碼塊以外,什么都沒做。

"誰要求的?"這是關鍵所在!

JS引擎并不是孤立運行的,它處在主機環境中,對絕大多數開發者來說是普通的web瀏覽器。在過去的幾年里,JS已經從瀏覽器擴展到其它環境,比如,通過Node.js擴展到服務端。事實上,如今JS已經被嵌入到各種設備當中,大到機器人,小到燈泡。

但所有這些環境有一個通用的“線程”,即在每個激活JS引擎的時刻,按時間處理執行多個代碼塊的機制,這稱作“事件輪詢”。

換句話說,JS引擎天生就沒有時間的概念,但是有對任何JS代碼片段按需執行的環境。正是這個環境調度“事件”(JS代碼執行)。

因此,比如當你的JS代碼發出了一個Ajax請求從服務端獲取數據,你在一個函數(通常稱為“回調”)中設置了一個“響應”代碼,然后JS引擎告訴主機環境,“嗨,我現在準備中止執行了,但是當你完成網絡請求,并且有數據了,請調用后面的這個函數”。

然后瀏覽器對這個網絡響應設置監聽,當有東西返回的時候,瀏覽器通過把回調函數插入事件輪詢執行回調。

那么事件輪詢是什么?

讓我們通過一些偽代碼將它概念化:

// `eventLoop` is an array that acts as a queue (first-in, first-out)
var eventLoop = [ ];
var event;

// keep going "forever"
while (true) {
    // perform a "tick"
    if (eventLoop.length > 0) {
        // get the next event in the queue
        event = eventLoop.shift();

        // now, execute the next event
        try {
            event();
        }
        catch (err) {
            reportError(err);
        }
    }
}

當然,這只是用來說明這一概念的簡化偽代碼,但是幫助我們理解的話,已經足夠了。

如你所見,有如while循環所示的一個持續運行的循環,每次循環遍歷稱為一個“tick”。對每個tick來說,如果隊列中有一個事件正在等待,則離開隊列立即執行。這些事件就是你的回調函數。

需要注意的一點是,setTimeout(...)并不會把你的回調函數放入事件輪詢隊列,它干的只是設置一個定時器,當定時器過期時,主機環境把你的回調函數放入事件輪詢,這樣某個未來的tick就會拾起并執行這個回調函數。

假如那時事件隊列中已經有20項了呢?你的回調函數得等,排隊站在其它的后面。通常沒有辦法搶占隊列以跳到隊列前頭。這就解釋了為什么setTimeout(...)定時器可能沒恰好在設定的時間觸發。(通常來說)執行環境能夠確保你的回調不會在你設定的時間前觸發,但是可能在你設定的或者之后的時間觸發,依事件隊列的狀態而定。

因此,換句話說,你的程序通常被分為幾小塊,在事件隊列中一個接著一個的執行。從技術上講,和你的程序沒有直接關系的事件也能夠在隊列中插入。

注意: 我們之前提到的ES6改變了事件輪詢隊列的管理方法。它是一個正式的術語,但ES6指定了事件隊列如何運行,從技術角度講,它被納入了JS引擎的范疇,而不僅僅是主機環境。這一改變的主要原因是ES6 Promises的引入,我們會在第三章討論。因為他們需要能夠對事件輪詢隊列調度作直接、精細地控制(可見“Cooperation”小節中setTimeout(...0)的討論)

多線程(Parallel Threading)

把“異步”和“并行”理解為一個東西的想法很常見,但它們其實一點都不同。記住,異步是關于現在以后 的界限。但并行是允許事情能夠同時發生。

最常用的并行計算工具是多進程和多線程。多進程和多線程獨立運行,并且可能同時運行:在單獨的處理器或者單獨的電腦,但多個線程可共享單個進程的內存。

相反,事件輪詢把工作分為多個任務并按序執行,不允許對共享內存的并行訪問和修改。并行化和“序列化”可以在不同的線程中以協作的事件輪詢形式共存。

多線程執行的插入和異步事件的插入發生的粒度不同。

例如:

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

盡管later()的內容被視作單個事件輪詢入口,當考慮到這段代碼執行所在的線程時,事實上可能有許多低等級的操作。比如,answer = answer * 2需要首先加載answer的當前值,然后把2放在某個地方,然后執行乘法操作,獲得結果并把它存回answer變量中。

在單線程環境中,線程隊列中的任務項是低等級操作并不重要,因為無法中斷線程。但如果是在并行系統中,同一個程序中兩個不同的線程運行,你可能得到預料不到的結果。

考慮如下代碼:

var a = 20;

function foo() {
    a = a + 1;
}

function bar() {
    a = a * 2;
}

// ajax(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

在JS單線程下,如果foo()bar()之前運行,結果就是a為42,但是如果bar()foo()之前運行,則a為41。

然而,如果共享相同數據的JS事件并行執行,問題可能更難以捉摸。考慮如下兩個偽代碼任務,兩個線程可以各自運行foo()bar()代碼,如果它們同時運行,考慮下會發生什么:

線程1:(xy是臨時內存位置)

foo():
  a. load value of `a` in `X`
  b. store `1` in `Y`
  c. add `X` and `Y`, store result in `X`
  d. store value of `X` in `a`

線程2:(xy是臨時內存位置)

bar():
  a. load value of `a` in `X`
  b. store `2` in `Y`
  c. multiply `X` and `Y`, store result in `X`
  d. store value of `X` in `a`

現在,我們假設兩個線程是真正并行運行的。你可能注意到問題了,是吧?它們在臨時步驟中使用了共享內存位置xy

如果按如下步驟執行下來,a是多少?

1a  (load value of `a` in `X`   ==> `20`)
2a  (load value of `a` in `X`   ==> `20`)
1b  (store `1` in `Y`   ==> `1`)
2b  (store `2` in `Y`   ==> `2`)
1c  (add `X` and `Y`, store result in `X`   ==> `22`)
1d  (store value of `X` in `a`   ==> `22`)
2c  (multiply `X` and `Y`, store result in `X`   ==> `44`)
2d  (store value of `X` in `a`   ==> `44`)

結果a44,但是這個順序呢?

1a  (load value of `a` in `X`   ==> `20`)
2a  (load value of `a` in `X`   ==> `20`)
2b  (store `2` in `Y`   ==> `2`)
1b  (store `1` in `Y`   ==> `1`)
2c  (multiply `X` and `Y`, store result in `X`   ==> `20`)
1c  (add `X` and `Y`, store result in `X`   ==> `21`)
1d  (store value of `X` in `a`   ==> `21`)
2d  (store value of `X` in `a`   ==> `21`)

最終a21

因此,線程編程非常具有欺騙性。如果你不采取特別措施來阻止這種相互干擾/插入,可能會得到非常奇怪并且不確定的結果,著實讓人頭疼。

JS從來不在線程間共享數據,這意味著不確定性不是大問題。但那并不是說JS總是確定性的。記得早些時候的foo()bar()的相對順序導致的不同結果嗎(41還是42)?

注意: 盡管不明顯,但并不是所有的不確定性都是壞的。有時是不相關的,有時是特意的。在下文以及后面幾章中,我們會看到更多示例。

運行直至結束(Run-to-Completion)

因為JS是單線程的,foo()(和bar())中的代碼是原子性的,意思是一旦foo()開始運行,它的所有代碼將會在bar()中任何代碼執行前執行完,反之亦是如此。這就叫做“運行直至結束”。

事實上,如果foo()bar()的內部有更多代碼時,run-to-completion語義更明顯,比如:

var a = 1;
var b = 2;

function foo() {
    a++;
    b = b * a;
    a = b + 3;
}

function bar() {
    b--;
    a = 8 + b;
    b = a * 2;
}

// ajax(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

因為bar()無法中斷foo(),并且foo()也無法中斷bar(),這段代碼只有兩個可能的結果,取決于哪一個首先運行--假如有多線程的話,foo()bar()中的語句就可能相互交叉,可能的結果就會急劇增加!

塊1是同步的(現在 發生),但是塊2和3是異步的(以后 發生),這意味著它們的執行會被一個時間界限分隔開。

塊1:

var a = 1;
var b = 2;

塊2(foo()):

a++;
b = b * a;
a = b + 3;

塊3(bar()):

b--;
a = 8 + b;
b = a * 2;

塊2和3都有可能第一個運行,因此這段程序可能有兩個結果,如下:

結果1:

var a = 1;
var b = 2;

// foo()
a++;
b = b * a;
a = b + 3;

// bar()
b--;
a = 8 + b;
b = a * 2;

a; // 11
b; // 22

結果2:

var a = 1;
var b = 2;

// bar()
b--;
a = 8 + b;
b = a * 2;

// foo()
a++;
b = b * a;
a = b + 3;

a; // 183
b; // 180

同一段代碼有兩種結果表明仍然有不確定性,但這是在函數(事件)級的順序問題,不是在多線程時語句級(或者說是表達式操作級)的順序問題。換句話說,這比多線程更具確定性。

JS中這種函數順序的非確定性行為通常稱為“競態”(race condition),foo()bar()會相互競爭,看誰先運行完。特別地,之所以稱為“競態”,是因為你無法可靠地預測ab的結果會是怎樣。

注意: 如果JS中有一個函數,它并不是運行直至結束的(run-to-completion),我們可能得到更多的結果,對嗎?ES6確實引入了這么個東西(詳見第四章“生成器”),但別擔心,我們回頭會講的!

并發(Concurrency)

假設一個顯示狀態更新列表的站點(比如一個社交網絡的消息提示),當用戶向下滾動列表的時候,網站逐步加載內容。為了實現這一效果,(至少)需要同時(即在同一個時間窗口,并不一定要同一時刻)分別執行兩個“進程”。

注意: 此處我們在“進程”上使用了引號,因為這并不是計算機科學中的操作系統級的進程概念。這是虛擬進程,或者叫任務,代表了一個邏輯連接,序列化操作。相比于“任務”,我們更喜歡用“進程”,因為從術語層面來說,這更匹配我們所討論的概念定義。

當用戶向下滾動頁面以獲取新內容時,第一個“進程”負責onscroll事件(發Ajax請求獲取新內容)。第二個“進程”負責接收Ajax響應(把內容渲染到頁面上)。

很明顯,如果用戶快速滾動,在獲取第一個響應并處理的時候,你會發現兩個甚至更多的onscroll事件觸發。因此,你會發現onscroll事件和Ajax響應事件相互交叉,快速地觸發。

并發是指兩個或多個“進程”在同一時間段內同時執行,不管它們各自的操作是否是并行的(同一時刻在不同的處理器或者核心上)。相比于操作級并行(不同的處理器線程),你可以認為并發是“進程”級(或任務級)的并行。

注意: 并發同樣引入了一個可選的“多進程”交互的概念,之后會有所提及。

對于一個給定的時間窗口(幾秒鐘,大概為用戶的滾動時間),讓我們將每個獨立的“進程”想象為一系列事件/操作:

“進程”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響應事件):

response 1
response 2
response 3
response 4
response 5
response 6
response 7

很可能一個onscroll時間和一個Ajax響應事件同時得到處理。例如,從時間軸角度想象一下這些事件:

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先發生,要么response 1先發生,它們不可能在同一時刻發生。就像學校自助餐廳的孩子們一樣,不論在門外擠成什么樣,他們都得排成隊來吃午餐!

讓我們把所有這些事件想象為事件輪詢隊列。

事件輪詢隊列:

onscroll, request 1   <--- Process 1 starts
onscroll, request 2
response 1            <--- Process 2 starts
onscroll, request 3
response 2
response 3
onscroll, request 4
onscroll, request 5
onscroll, request 6
response 4
onscroll, request 7   <--- Process 1 finishes
response 6
response 5
response 7            <--- Process 2 finishes

“進程1“和”進程2“并發運行(任務級并行),但它們各自的事件在事件隊列中按序運行。

順便提一句,注意到response 6response 5是怎么沒有按照預期順序返回的嗎?

單線程事件輪詢是并發的一種表現形式(當然還有其它形式,后面會提及)。

非交互(Noninteracting)

盡管在同一個程序中,兩個或更多的”進程“并發地插入各自的步驟/事件,但如果任務不相關,它們沒必要交互。如果它們不交互,非確定性完全可以接受

例如:

var res = {};

function foo(results) {
    res.foo = results;
}

function bar(results) {
    res.bar = results;
}

// ajax(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

foo()bar()是兩個并發”進程“,無法確定哪一個先觸發。但我們構建的這個程序,不管哪個先觸發都沒關系,因為它們是相互獨立的,不需要交互。

這不是個”競態(race condition)“bug,因為不論順序如何,代碼都能正常運行。

交互(Interaction)

更多情況下,間接地通過作用域和/或DOM,并發”進程“有必要交互。當發生這種交互的時候,你需要協調這些交互來阻止之前提到的”競態“。

以下是一個由于潛在順序問題,兩個并發”進程“交互的簡單例子,有時可能導致程序異常:

var res = [];

function response(data) {
    res.push( data );
}

// ajax(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

并發”進程“是兩個response()調用,用來處理Ajax響應。它們誰都可能先發生。

我們假定預期的結果是res[0]保存了http://some.url.1調用返回的結果,res[1]保存了http://some.url.2調用返回的結果。有時是這樣,但有時結果完全反過來,依誰先完成而定。這一不確定性很像一個”競態“bug。

注意: 在類似這種情形下,作出假設前一定要謹慎。例如,或許根據處理任務(比如數據庫任務或者其它抓取靜態文件任務)的不同,開發人員注意到http://some.url.2的響應”總是“比http://some.url.1慢,因此,觀測的結果總是和預期的順序一致,這很常見。即使兩個請求發向了同一個服務器,并且服務器按照特定順序返回了響應,也不能保證返回瀏覽器的響應的順序。

因此,為了處理這種競態,你可以協調下有序交互:

var res = [];

function response(data) {
    if (data.url == "http://some.url.1") {
        res[0] = data;
    }
    else if (data.url == "http://some.url.2") {
        res[1] = data;
    }
}

// ajax(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

不管哪個Ajax響應先返回,我們檢查data.url(當然,可以假定是從服務器端返回的),確定響應數據應該占據res數組的哪個位置。res[0]永遠存放著http://some.url.1的結果,res[1]永遠存放著http://some.url.2的結果。通過簡單的協調,我們消除了競態不確定性。

同樣的道理,如果多個并發函數通過共享DOM交互調用,比如,其中一個更新<div>的內容,其它的更新<div>的樣式或者屬性(比如,一旦有內容,則讓DOM元素可見)。你可能不想在有內容前顯示DOM元素,因此協調必須確保合適的交互順序。

沒有協調交互的話,一些并發場景總是會導致異常(不只是有時)。考慮如下:

var a, b;

function foo(x) {
    a = x * 2;
    baz();
}

function bar(y) {
    b = y * 2;
    baz();
}

function baz() {
    console.log(a + b);
}

// ajax(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

在這個例子當中,不論foo()bar()誰先觸發,總會導致baz()過早運行(a或者b總有一個是undefined),但在第二次調用baz()時就正常了,因為ab都有值了。

有不同方法可以處理這種情況,以下是個簡單的方式:

var a, b;

function foo(x) {
    a = x * 2;
    if (a && b) {
        baz();
    }
}

function bar(y) {
    b = y * 2;
    if (a && b) {
        baz();
    }
}

function baz() {
    console.log( a + b );
}

// ajax(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

baz()外圍的if (a && b)條件一般稱為”門(gate)“,因為不確定ab的到達順序,但可以在門打開(調用baz())前,等兩個都到達。

另一個可能碰到的并發交互情形有時稱為”競爭(race)“,更確切地應該叫”閂(latch)“。它的特點是”只有第一個贏“。此時,非確定性是可以接受的,你明確說明了”競爭“直至終點,只產生一個勝者是OK的。

考慮如下異常代碼:

var a;

function foo(x) {
    a = x * 2;
    baz();
}

function bar(x) {
    a = x / 2;
    baz();
}

function baz() {
    console.log( a );
}

// ajax(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

無論哪一個(foo()或者bar())后觸發,不僅會重寫另一個對a的賦值,而且會重復調用baz()(這是我們不希望看到的)。

因此,我們可以采用一個簡單的閂來協調交互,只讓第一個通過:

var a;

function foo(x) {
    if (a == undefined) {
        a = x * 2;
        baz();
    }
}

function bar(x) {
    if (a == undefined) {
        a = x / 2;
        baz();
    }
}

function baz() {
    console.log( a );
}

// ajax(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", foo );
ajax( "http://some.url.2", bar );

if (a == undefined)條件語句只允許foo()bar()中的第一個通過,第二個(以及其后所有的)調用會被忽略。

注意: 在所有這些場景中,為便于說明,我們一直采用全局變量,但并沒有理由一定要這樣做。只要相關的函數能夠訪問變量(通過作用域),同樣能運行的很好。依賴詞法作用域變量,即此處的全局變量,是這些并發協調形式的一個明顯缺點。隨著我們深入之后的幾章,會看到其它一些更簡潔的形式。

協作(Cooperation)

另一種并發協調的表現形式是”并發協作“。此處的關注點不是通過作用域的值共享進行交互(盡管仍然允許!),而是運行一個長時的”進程“,并將其分成幾步或幾個批次,使得其它的并發”進程“有機會把它們的操作插入到事件輪詢隊列中。

例如,假設有一個AJax響應處理函數,需要遍歷一長串結果以改變值。此處為保持代碼簡潔,我們采用Array#map(..)

var res = [];

// `response(..)` receives array of results from the Ajax call
function response(data) {
    // add onto existing `res` array
    res = res.concat(
        // make a new transformed array with all `data` values doubled
        data.map( function(val){
            return val * 2;
        } )
    );
}

// ajax(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

如果"http://some.url.1"首先返回結果,整個結果會馬上映射到res中去。如果只是幾千或者更少的數據,這通常不是個大問題。但如果有1000萬條數據,可能需要花上一段時間(一臺性能強勁的筆記本可能要幾秒鐘,移動設備就更長了)。

當這樣一個”進程“運行的時候,頁面上的其它東西都會停止,包括其它response(..)調用、UI更新,更不用說像滾動、輸入、按鈕點擊以及諸如此類的用戶事件了。那真痛苦。

因此,為了讓并發系統更好地協作,可以采用一種更友好并且不會霸占事件輪詢隊列的方式,你可以采用異步批次的方式處理這些數據。在每個批次被推入事件隊列后,讓其它批次等待事件發生。

以下是一個非常簡單的方法;

var res = [];

// `response(..)` receives array of results from the Ajax call
function response(data) {
    // let's just do 1000 at a time
    var chunk = data.splice( 0, 1000 );

    // add onto existing `res` array
    res = res.concat(
        // make a new transformed array with all `chunk` values doubled
        chunk.map( function(val){
            return val * 2;
        } )
    );

    // anything left to process?
    if (data.length > 0) {
        // async schedule next batch
        setTimeout( function(){
            response( data );
        }, 0 );
    }
}

// ajax(..) is some arbitrary Ajax function given by a library
ajax( "http://some.url.1", response );
ajax( "http://some.url.2", response );

我們把數據集分成每個最大1000項的數據塊進行處理。這樣,我們能夠確保一個短時的”進程“,即使這意味著有更多的”進程“隨之而來。將這些”進程“插入到事件輪詢隊列能夠讓我的網站/應用響應性更好,性能更佳。

當然,我們沒有協調交互來控制這些”進程“的順序,因此結果的順序也是不可預測的。如果順序是必須的,你可以采用之前討論的交互技術,或者下一章中我們要講的技術。

我們采用setTimeout(..0)(hack)進行異步調度,這僅僅意味著”把這個函數插入到事件輪詢隊列的末尾“。

注意: 技術上來說,setTimeout(..0)并不會直接把任務項插入到事件輪詢隊列中,定時器會在下次機會中插入事件。例如,兩個先后setTimeout(..0)的調用并不能嚴格保證按照調用順序得到處理。所以,可能看到各種各樣的情形,比如定時器漂移,這樣事件的順序是不可預測的。在Node.js中,有一個類似的方法叫process.nextTick(..)。盡管很方便(通常性能也更好),但沒有一個直接、橫跨所有環境的確保異步事件順序的方式(至少目前如此)。在下一節中,我們會討論更多關于這個話題的細節。

作業(Jobs)

ES6中,除了事件隊列以外,還有一個新概念,叫”作業隊列“(Job queue)。你最有可能在Promises的異步行為中(詳見第三章)接觸到這一概念。

不幸的是,此時,它只是一個沒有外露接口的機制,因此,證明它可能有點復雜。因此,我們只從概念上描述它,以便當我們在第三章討論Promises的異步行為時,你能夠理解這些操作是怎樣調度和處理的。

因此,我認為對”作業隊列“理解的最好方式是,它是一個掛載在事件輪詢隊列中每個tick末尾的隊列。某些可能在事件隊列的tick期間發生的潛在異步操作并不會導致一個完整的新事件插入到事件輪詢隊列中,而是在當前tick的作業隊列末尾加入一個項目(即作業)。

就好像說:”哦,我這兒有個需要之后處理的其它東西,但確保在其它事情發生之前立即發生“。

或者,打個比方:事件隊列就像游樂場的過山車,一旦你玩過一次,你必須到隊尾去排隊。而作業隊列就像一旦你玩結束了,之后插隊又繼續玩了。

一個作業可能引發更多的作業插入到同一隊列的末尾。因此,從理論上來說,一個作業”循環“(一個作業不斷地加入另一個作業)很可能無限延伸,因而導致程序無法移到下一個事件循環tick中。從概念上來說,這跟一個長時運行或者無限循環(比如while(true)...)幾乎一模一樣。

作業有點像setTimeout(..0)hack,但是能夠更好地定義和確保順序:以后,但盡可能快點

假設一個調度Jobs(直接的,沒有采用hacks)的API,叫做schedule(..),如下:

console.log( "A" );

setTimeout( function(){
    console.log( "B" );
}, 0 );

// theoretical "Job API"
schedule( function(){
    console.log( "C" );

    schedule( function(){
        console.log( "D" );
    } );
} );

你可能認為會輸出A B C D,但其實是輸出A C D B,因為作業是在當前事件輪詢tick的末尾發生的,而定時器觸發時,是調度到下一個事件輪詢tick的(如果存在的話)。

在第三章,我們將會看到,Promises的異步行為是基于Jobs的,因此有必要搞清楚它和事件輪詢的關系。

語句排序(Statement Ordering)

我們代碼中寫的語句順序并不一定和JS引擎執行的順序一致。這一論斷看起來似乎有點奇怪,那么我們簡單地看下。

但在開始之前,我們應該搞清楚一些事情:從程序角度而言,語言的規則/語法決定了語句排序是可預測和可信賴的。因此,我們所討論的不是你在JS程序中能觀察到的東西

警告: 如果你能觀察到編譯器語句重排,就像我們將要舉例說明的那樣,這很明顯違背了規范的要求,毫無疑問會是JS引擎的一個大bug--一個應該立即報告并修復的bug!但是當那就是你程序中的一個bug時(比如一個”競態“),你懷疑JS引擎發生了一些不可思議的事情,這非常普遍--因此,首先看看是不是重排導致的問題,仔細看。采用JS調試器,設置斷點,一行行單步跟蹤代碼,會成為你揪出代碼中這一類bug最強有力的工具。

如下:

var a, b;

a = 10;
b = 30;

a = a + 1;
b = b + 1;

console.log( a + b ); // 42

這段代碼沒有異步操作(除了之前提到的console異步I/O),因此最有可能的假設是它會從頭到尾一行行地執行。

但很可能在JS引擎編譯完這段代碼后(是的,JS會編譯),發現重排(安全地)一下代碼可能讓執行速度更快些。本質而言,只要你看不到這種重排,任何事情都是焦點(譯者注:大概意思是關注點就不在重排上了)。

例如,引擎發現像這樣執行代碼也許更快些:

var a, b;

a = 10;
a++;

b = 30;
b++;

console.log( a + b ); // 42

或者這樣:

var a, b;

a = 11;
b = 31;

console.log( a + b ); // 42

甚至:

// because `a` and `b` aren't used anymore, we can
// inline and don't even need them!
console.log( 42 ); // 42

在以上所有情況當中,JS引擎在編譯階段進行安全的優化,最終的結果一樣。

但是有一種情況下,這種特定的優化可能不安全,因而不允許這樣(當然,并不是說一點都不優化):

var a, b;

a = 10;
b = 30;

// we need `a` and `b` in their preincremented state!
console.log( a * b ); // 300

a = a + 1;
b = b + 1;

console.log( a + b ); // 42

其它因編譯器重排導致副作用的例子(因此也必須禁止)可能包括一些像具有副作用的函數調用(尤其是getter函數),或者ES6的Proxy對象。

考慮如下代碼:

function foo() {
    console.log( b );
    return 1;
}

var a, b, c;

// ES5.1 getter literal syntax
c = {
    get bar() {
        console.log( a );
        return 1;
    }
};

a = 10;
b = 30;

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

console.log( a + b );   // 42

要不是因為console.log(...)語句(僅是為了說明這種可觀測的副作用而采用的簡便形式),如果樂意(誰知道它是否會呢),JS引擎早把代碼重排成這樣了:

// ...

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

// ...

謝天謝地,盡管JS語法能夠讓我們免于遭受編譯器語句重排帶來的噩夢困擾,但認識到編寫的源碼(至上而下的方式)和實際編譯后執行的方式之間的聯系有多薄弱是十分重要的。

編譯器語句重排是并發和交互(interaction)的微隱喻。作為一個普遍概念,知曉這些能夠幫助你更好地理解JS代碼的異步流問題。

回顧(Review)

日常開發中,一個JS程序總是被分成兩個或更多的塊,第一個塊現在執行,下一個塊以后執行,對應一個事件。盡管程序是按塊執行的,但所有塊共享對程序的作用域和狀態的訪問,因此,每次對狀態的修改都是在前一個狀態的基礎上的。

只要有事件要運行,事件輪詢會一直運行直至隊列為空。對事件輪詢的每次迭代稱為一個”tick“。用戶交互、IO和定時器都會在事件隊列中進行事件排隊。

并發是兩條或多條事件鏈隨著時間相互交叉,以至于從更高層面來看,他們似乎是同時運行的(即使在任何給定時刻,只有一個事件被處理)。

比如,為了確保順序或者為了阻止”競態“,通常很有必要在這些并發的”進程“(有別于操作系統級的進程概念)間進行某種形式的交互協調。也可以通過把這些”進程“拆分成多個小塊,從而允許其它”進程“插入進來。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容