原文: https://github.com/getify/You-Dont-Know-JS/blob/master/async%20%26%20performance/ch1.md#concurrency
譯者:熊賢仁
讓我們想象有個網(wǎng)站,展示狀態(tài)更新的列表(類似社交網(wǎng)絡(luò)信息流),當(dāng)用戶下滑列表時,漸進式地加載數(shù)據(jù)。為了讓這一特性正確工作,(至少)兩個獨立的 “進程” 需要同時執(zhí)行(也就是說,在同一段窗口時間下,而不必是同一時刻)。
注意: 我們使用了給 “進程” 加了引號,因為它們不是計算機科學(xué)意義上的真正的操作系統(tǒng)級別的進程。它們是虛擬進程,或者說任務(wù),用來表示一系列邏輯相關(guān)的連續(xù)的操作。我們簡單地使用了 “進程” 而不是 “任務(wù)”,因為這個術(shù)語更加匹配我們正在探討的定義。
當(dāng)用戶向下滾動頁面后,第一個 “進程” 會響應(yīng) onscroll 事件(發(fā)起 Ajax 請求去獲取新內(nèi)容)。第二個 “進程” 會接收 Ajax 返回的響應(yīng)(去渲染內(nèi)容到頁面上)。
顯然,如果用戶向下滾動頁面足夠快的話,你可能在頁面接收到第一個響應(yīng)并處理時,看到不止一個 onscroll 事件被觸發(fā)。因此你將得到快速觸發(fā)并交錯執(zhí)行的onscroll 事件和 Ajax 響應(yīng)事件。
并發(fā)是在同一段時間內(nèi)兩個或多個 “進程” 同時運行,不管它們的獨立構(gòu)成的運算是否 “并行” (多個獨立的處理器或核心同一時刻)執(zhí)行。你可以把并發(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)事件很可能在同一時刻準(zhǔn)備執(zhí)行。舉個例子,讓我們設(shè)想這些事件在一個時間線上:
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
但是,回到我們之前章節(jié)講過的事件循環(huán)的概念,JS 同一時刻只能處理一個事件,onscroll 事件也不例外。要么請求 2 先執(zhí)行,要么響應(yīng) 1 先執(zhí)行,但它們字面上不能同時執(zhí)行。就像學(xué)校食堂的孩子們,不管門外擠進了多少人,他們也必須排隊取餐。
我們設(shè)想所有這些事件在事件循環(huán)隊列中交替執(zhí)行。
事件循環(huán)隊列:
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” 并發(fā)運行(任務(wù)級并發(fā)),而他們獨立的事件在事件循環(huán)隊列中按序運行。
順便說一下,注意到響應(yīng) 6 和響應(yīng) 5 那令人意外的順序了嗎?
基于單線程的事件循環(huán)是一種并發(fā)的形式(當(dāng)然還有其他的,我們后面講介紹)。
非交互
當(dāng)兩個或多個 “進程” 在同一個程序內(nèi)并發(fā)地交替執(zhí)行它們的步驟或事件,如果彼此任務(wù)不相關(guān),它們不必有交互。如果它們沒有交互,程序的不確定性將相當(dāng)?shù)汀?/strong>
比如:
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() 是兩個并發(fā) “進程”,它們哪個先執(zhí)行是不確定的。但它們的執(zhí)行順序?qū)@段程序來說都無關(guān)緊要的,因為它們是獨立執(zhí)行的,故不需要交互。
這不是一個 “競態(tài)條件” bug,不管執(zhí)行順序如何,因為這段代碼總是正常工作。
交互
更常見的是,并發(fā) “進程” 直接通過作用域和 DOM 進行必要的交互。如前面所述,你需要協(xié)調(diào)這些交互,從而防止 “競態(tài)條件”。
這里有個簡單的例子,有兩個并發(fā) “進程”,它們由于隱含的排列順序而產(chǎn)生交互,這個順序有時會被破壞:
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 );
并發(fā) “進程” 是兩個用于處理 Ajax 響應(yīng)而調(diào)用的 response() 方法。它們?nèi)我庖粋€都可以先執(zhí)行。
我們假設(shè)期待的行為是,res[0] 存放調(diào)用 "http://some.url.1" 的結(jié)果,res[1]存放調(diào)用 "http://some.url.2" 的結(jié)果。但有時并非如此,這取決于哪個調(diào)用先結(jié)束。這種不確定性很可能就是一個 “競態(tài)條件” bug。
注意: 你要對這些情況保持極度警惕。比如,開發(fā)者如果觀察到響應(yīng)
"http://some.url.2" 總是 比 "http://some.url.1" 慢得多,這可能是由于它們處理的任務(wù)不同(比如,一個執(zhí)行數(shù)據(jù)庫任務(wù),另一個只是在獲取某個靜態(tài)文件),因此觀察到的執(zhí)行順序看起來總是在我們意料之中。即使這兩個請求都訪問同一個服務(wù)器,然后按照確定的順序返回響應(yīng),也不能完全保證瀏覽器中響應(yīng)返回的順序。
為了處理這種競態(tài)條件,你可以調(diào)整交互順序:
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 響應(yīng),我們都要通過檢查 data.url (當(dāng)然,假設(shè)其中一個返回自服務(wù)器)來確定響應(yīng)數(shù)據(jù)應(yīng)該放在 res 數(shù)組的哪個位置。 res[0] 總是保存 "http://some.url.1" 返回的結(jié)果,res[1] 總是保存 "http://some.url.2" 的結(jié)果。通過簡單的調(diào)整,我們排除了 “競態(tài)條件” 不確定性。
如果調(diào)用了多個并發(fā)函數(shù),這些函數(shù)通過共享內(nèi)存產(chǎn)生交互,上面場景下的論證也可以得到應(yīng)用。比如其中一個更新一個 <div> 的內(nèi)容,另一個更新 <div> 的樣式或?qū)傩裕ū热纾坏?DOM 元素有內(nèi)容,就使其可見)。你可能不想在 DOM 元素有內(nèi)容之前展示它,那么調(diào)整必須保證正確的交互順序。
若沒有調(diào)整交互順序,有些并發(fā)場景總是會出錯(并非偶爾),考慮:
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() 先執(zhí)行,總是會造成 baz() 過早執(zhí)行(a 和 b 都還是 unfined),但 baz() 的第二次調(diào)用會正常工作,因為 a 和 b 都已被初始化了。
有多種方法可以處理此類情況。這里有一個簡單的辦法:
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() 調(diào)用的 if (a && b) 條件語句通常被稱為一個 “門”,因為不確定 a 和b 的到達順序,我們要在開門之前(調(diào)用 baz())等待這兩者的到達。
另一種可能遇到的并發(fā)交互條件有時被稱為 “競態(tài)”(race),更準(zhǔn)確的說應(yīng)該是 “門閂”(latch)。它的特征是 “先到先贏” 行為。這里,不確定性是可接受的,因為你明確說明了 “競態(tài)” 中為了成為那個唯一的贏家,需要競爭到終點。
考慮這段有問題的代碼:
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())先執(zhí)行,不僅 a 的賦值會被另一個覆蓋,而且還會重復(fù)調(diào)用 baz() (很可能并不希望這樣)。
所以,我們可以通過一個簡單的門閂來調(diào)整交互過程,以只讓第一個通過:
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() 中的第一個通過,第二個調(diào)用(事實上是任何后續(xù)調(diào)用的)的會被忽略。第二名并不光榮!
注意:在以上所有場景中,我們都使用了全局變量來做簡單的示范,但對于我們的論證來說,這樣做并不是必要的。只要問題中的函數(shù)可以訪問變量(通過作用域),它們都會正常工作。依賴于詞法作用域變量(見本書的作用域和閉包部分),以上例子中的全局變量,事實上對于那些并發(fā)調(diào)整來說是一個明顯的負(fù)面因素。隨著后面章節(jié)的展開,我們會看到更多種干凈得多的調(diào)整方式。
協(xié)作
協(xié)調(diào)并發(fā)的另一個形式被稱為 “協(xié)作式并發(fā)”。在這里,我們要關(guān)注的點不再是通過共享作用域內(nèi)的值來進行交互(盡管這顯然也是可以的)。通過使用一個長期運行的 “進程” ,并把 “進程” 分成多個步驟或批次,并發(fā) “進程” 可以在事件循環(huán)隊列中交叉執(zhí)行它們的各種操作。
舉個例子,考慮一個 Ajax 響應(yīng)處理器,它需要遍歷一長串結(jié)果列表來轉(zhuǎn)換值。我們會使用 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" 先得到返回結(jié)果,整個列表立即會被 map 進 res。如果只有幾千或者更少的數(shù)據(jù),通常問題不大。但如果說有個一千萬條數(shù)據(jù),這需要運行好一會兒(性能不錯的筆記本上要幾秒種,移動設(shè)備上會久得多,等待)。
當(dāng)這樣一個 “進程” 在運行,頁面中的其他任務(wù)就可以去一邊呆著了,包括沒有其他的response(...) 函數(shù)調(diào)用,沒有任何 UI 更新,甚至像滾動、輸入、按鈕點擊這樣的用戶事件也沒法工作了。這相當(dāng)痛苦。
所以,為了實現(xiàn)一個協(xié)作更好的并發(fā)系統(tǒng),它更友好,而且不會霸占事件循環(huán)隊列,你可以異步地批處理這些結(jié)果,每次 “yielding” 后返回事件循環(huán),讓其他等待事件執(zhí)行。
這里有個很簡單的方法:
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 );
我們把數(shù)據(jù)集處理成一些最大容量為 1000 的塊。這樣做可以保證 “進程” 運行時間比較短,即使這樣也意味著會有更多后續(xù)的 “進程”,因為事件循環(huán)隊列的交叉執(zhí)行會提升網(wǎng)站/APP 的響應(yīng)性能。
當(dāng)然,我們沒有針對這些 “進程” 的執(zhí)行順序做任何的交互調(diào)整,因此 res 中結(jié)果的順序?qū)⑹遣淮_定的。如果要求保證順序,你需要使用像我們前面討論過的交互技巧,或者本書后面章節(jié)要介紹的技術(shù)。
為了實現(xiàn)異步調(diào)度,我們使用了 setTimeout(..0)(hack),基本上,這里的意思是 “把這個函數(shù)插入到事件循環(huán)隊列的末尾”。
注意: 嚴(yán)格來說,setTimeout(..0) 并沒有直接往事件循環(huán)隊列插入處理函數(shù)。定時器將在有機會的情況下插入事件。比如說,兩個相繼的 setTimeout(..0) 調(diào)用并不能嚴(yán)格保證按照調(diào)用順序處理,所以可以各種不同的情況都可能發(fā)生,比如定時器漂移,這時這些事件是不可預(yù)測的。Node.js 中,一個相似的方法是 process.nextTick(..)。盡管這很方便(通常性能也更好),但還沒有直接的方法(至少現(xiàn)在還沒有)可以跨所有環(huán)境來保證異步事件順序。我們會在下一節(jié)深入討論這個話題。