本文是我翻譯《JavaScript Concurrency》書籍的第六章 實用的并發,該書主要以Promises、Generator、Web workers等技術來講解JavaScript并發編程方面的實踐,
完整書籍翻譯地址:https://github.com/yzsunlei/javascript_concurrency_translation ,由于能力有限,肯定存在翻譯不清楚甚至翻譯錯誤的地方,歡迎朋友們提issue指出,感謝,
在上一章中,我們大致學習了Web workers的基本功能,我們在瀏覽器中使用Web worker實作真正的并發,因為它們映射到實際的執行緒上,而這些執行緒又映射到獨立的CPU上,本章,首次提供設計并行代碼的一些實用方法,
我們首先簡要介紹一下從函式式編程中可以借鑒的一些方法,以及它們如何能很好的適用于并發性問題,然后,我們將決定應該通過并行計算還是簡單地在一個CPU上運行來解決并行有效性的問題,然后,我們將深入研究一些可以從并行運行的任務中受益的并發問題,我們還將解決在使用workers執行緒時保持DOM回應的問題,
函式式編程
函式顯然是函式式編程的核心,其實,就是資料在我們的應用程式中流轉而已,實際上,資料及其它在程式中的流轉可能與函式本身的實作同樣重要,至少就應用程式設計而言,
函式式編程和并發編程之間存在很強的親和力,在本節中,我們將看看為什么是這樣的,以及我們如何應用函式式編程技術撰寫更強壯的并發代碼,
資料輸入,資料輸出
函式式編程相對其他編程范式是很強大的,這是一個解決同樣問題的不同方式,我們使用一系列不同的工具,例如,函式就是積木,我們將利用它們來建立一個關于資料轉換的抽象,命令式編程,從另一方面來說,使用構造,比如說類來構建抽象,與類和物件的根本區別是它們喜歡封裝一些東西,而函式通常是資料流入,資料流出,
例如,假設我們有一個帶有enabled屬性的用戶物件,我們的想法是,enabled屬性在某些給定時間會有一個值,也可以在某些給定時間改變,換句話說,用戶改變狀態,如果我們將這個物件傳遞給我們應用程式的不同模塊,那么狀態也會隨之傳遞,它被封裝為一個屬性,參考用戶物件的這些組件中的任何一個都可以改變它,然后將其傳遞到其他地方,等等,下面的插圖顯示了一個函式在將用戶傳遞給另一個組件之前是如何改變其狀態的:
在函式式編程中不是這樣的,狀態不是封裝在物件內部,然后從組件傳遞到另一個組件;不是因為這樣做本質上是壞的,而是因為它只是解決問題的另一種方式,狀態封裝是面向物件編程的目標,而函式式編程的關注的是從A點到B點并沿途轉換資料,這里沒有C點,一旦函式完成其作業就沒有意義 - 它不關心資料的狀態,這里是上圖的函式替代方案:
我們可以看到,函式方法使用更新后的屬性值創建了一個新物件,該函式將資料作為輸入并回傳新資料作為輸出,換句話說,它不會修改輸入資料,這是一個簡單的方法,但會有重要的結果,如不變性,
不變性
不可變資料是一個重要的函式式編程概念,非常適合并發編程,JavaScript是一種多范式語言,也就是說,它是函式式的,但也可以是命令式的,一些函式式編程語言嚴格遵循不變性 - 你根本無法改變物件的狀態,這實際上是很好的,它擁有選擇何時保持資料不可變性以及何時不需要的靈活性,
在上一節的最后一張圖中,展示了enable()函式實際回傳一個具有與輸入值不同的屬性值的全新物件,這樣做是為了避免改變輸入值,雖然,這可能看起來很浪費 - 不斷建立新物件,但實際上并非如此,綜合考慮當物件永遠不會改變時我們不必寫的標記代碼,
例如,如果用戶的enabled屬性是可變的,則這意味著使用此物件的任何組件都需要不斷檢查enabled屬性,以下是對此的看法:
只要組件想要向用戶顯示,就需要不斷進行此檢查,我們實際上在使用函式方法時需要執行同樣的檢查,但是,函式式方法唯一有效的起點是創建路徑,如果我們系統中的其他內容可以更改enabled的屬性,那么我們需要擔心創建和修改路徑,消除修改路徑還消除了許多其他復雜性,這些被稱為副作用,
副作用和并發性并不好,事實上,這是一個可以改變物件的方法,這使得并發變得困難,例如,假設我們有兩個執行緒想要訪問我們的用戶物件,他們首先需要獲取對它的訪問權限,它可能已被鎖定,以下是該方法的示圖:
在這里,我們可以看到第一個執行緒鎖定用戶物件,阻止其他執行緒訪問它,第二個執行緒需要等到它解鎖才能繼續,這稱為資源占用,它減弱了利用多核CPU的整個設計目的,如果執行緒等待訪問某種資源,則它們并不真正的是在并行運行,不可變性可以解決資源占用問題,因為不需要鎖定不會改變的資源,以下是使用兩個執行緒的函式方法:
當物件不改變狀態,任意數量的執行緒可以同時訪問他們沒有任何風險破壞物件的狀態,由于亂序操作并且無需浪費寶貴的CPU時間等待的資源,
參考透明度和時間
將不可變資料作為輸入的函式稱為具有參考透明性的函式,這意味著給定相同的輸入物件,無論呼叫多少次,該函式將始侄訓傳相同的結果,這是一個有用的屬性,因為它意味著從處理中洗掉時間因素,也就是說,唯一可以改變函式輸出結果的因素是它的輸入 - 而不是相對于其他函式呼叫的時間,
換句話說,參考透明函式不會產生副作用,因為它們使用不可變資料,因此,時間缺乏是函式輸出的一個因素,它們非常適合并發環境,讓我們來看一個不是參考透明的函式:
//僅當物件“enabled”時,回傳給定物件的“name”屬性,
//這意味著如果傳遞給它的用戶永遠不更新
//“enabled”屬性,函式是參考透明的,
function getName(user) {
if (user.enabled) {
return user.name;
}
}
//切換傳入的“user.enabled”的屬性值,
//像這樣改變了物件狀態的函式
//使參考透明度難以實作
function updateUser(user) {
user.enabled = !user.enabled;
}
//我們的用戶物件
var user = {
name: 'ES6',
enabled: false
};
console.log('name when disabled', '"${getName(user)}"');
//→name when disabled “undefined”
//改變用戶狀態,現在傳遞這個物件
//給函式意味著它們不再存在
//參考透明,因為他們可以
//根據此更新生成不同的輸出,
updateUser(user);
console.log('name when enabled',`"${getName(user)}"`);
//→name when enabled "ES6"
該方式的getName()函式運行依賴于傳遞給它的用戶物件的狀態,如果用戶物件是enabled,則回傳name,否則,我們沒有回傳,這意味著如果函式傳入可變資料結構,則該函式不是參考透明的,在前面的示例中就是這種情況,enabled屬性改變,函式的結果也會改變,讓我們修復這種情況,并使用以下代碼使其具有參考透明性:
//“updateUser()”的參考透明版本,
//實際上它什么也沒有更新,它創造了一個
//具有與傳入的物件所有屬性值相同的新物件,
//除了改變“enabled”屬性值,
function updateUserRT(user) {
return Object.assign({}, user, {
enabled: !user.enabled
});
}
//這種方法對“user”沒有任何改變,
//表明使用“user”作為輸入的任何函式,
//都保持參考透明,
var updatedUser = updateUserRT(user);
//我們可以在任何時候呼叫referentially-transparent函式,
//并期望獲得相同的結果,
//當這個對我們的資料沒有副作用時,
//并發性就變得更容易,
setTimeout(()=> {
console.log('still enabled', `"${getName(user)}"`);
//→still enabled "ES6"
}, 1000);
console.log('updated user', `"${getName(updatedUser)}"`);
//→updated user "undefined"
我們可以看到,updateUserRT()函式實際上并沒有改變資料,它會創建一個包含更新的屬性值的副本,這意味著我們可以隨時使用原始用戶物件作為輸入來呼叫updateUser(),
這種函式式編程技術可以幫助我們撰寫并發代碼,因為我們執行操作的順序不是一個影響因素,讓異步操作有序執行很難,不可變資料帶來參考透明性,這帶來更強的并發語意,
我們需要并行嗎?
對于一些問題,并行性可以對我們非常有用,創建workers并同步他們之間的通信讓執行任務不是免費的,例如,我們可以使用這個,通過精心設計的并行代碼,很好的使用四個CPU內核,但事實證明,執行樣板代碼以促進這種并行性所花費的時間超過了在單個執行緒中簡單處理資料所花費的,
在本節中,我們將解決與驗證我們正在處理的資料以及確定系統硬體功能相關的問題,對于并行執行根本沒有意義的場景,我們總是希望有一個同步反饋,當我們決定設計并行時,我們的下一個作業就是弄清楚作業如何分配給worker,所有這些檢查都在運行時執行,
資料有多大?
有時,并行并不值得,并行的方法是在更短的時間內計算更多,這樣可以更快地得到我們的結果,最終帶來更迅速的用戶體驗,話雖如此,有些情況下我們處理簡單資料時使用多執行緒并不是合理的,即使是一些大型資料集也可能無法從并行中受益,
確定給定操作對于并行執行的適合程度的兩個因素是資料的大小以及我們對集合中的每個項執行的操作的時間復雜度,換句話說,如果我們有一個包含數千個物件的陣列,但是對每個物件執行的計算都很簡單,那么就沒有必要使用并行了,同樣,我們可能有一個只有很少物件的陣列,但操作很復雜,同樣,我們可能無法將作業細分為較小的任務,然后將它們分發給worker執行緒,
我們執行的各個項的計算是靜態因素,在設計時,我們必須要有一個總體思路,該代碼在CPU運行周期中是復雜的還是簡便的,這可能需要一些靜態分析,一些快速的基準,是一目了然的還是夾雜著一些訣竅和直覺,當我們制訂一個標準,來確定一個給定的操作是否非常適合于并行執行,我們需要結合計算本身與資料的大小,
讓我們看一個使用不同性能特征來確定給定函式是否應該使用并行的示例:
//此函式確定操作是否應該使用并行,
//它需要兩個引數 - 要處理的資料data
//和一個布爾標志expensiveTask,
//表示該任務對資料中的每個項執行是否復雜
function isConcurrent(data, expensiveTask) {
var size,
isSet = data instanceof Set,
isMap = data instanceof Map;
//根據data的型別,確定計算出資料的大小
if (Array.isArray(data)) {
size = data.length
} else if (isSet || isMap) {
size = data.size;
} else {
size = Object.keys(data).length;
}
//確定是否超過資料并行處理大小的門檻,
//門檻取決于“expensiveTask”值,
return size >= (expensiveTask ? 100: 1000);
}
var data = https://www.cnblogs.com/yzsunlei/p/new Array(138);
console.log('array with expensive task', isConcurrent(data, true));
//→array with expensive task true
console.log('array with inexpensive task', isConcurrent(data, false));
//→array with expensive task false
data = https://www.cnblogs.com/yzsunlei/p/new Set(new Array(100000).fill(null).map((x, i) => i));
console.log('huge set with inexpensive task', isConcurrent(data, false));
//→huge set with inexpensive task true
這個函式很方便,因為它是一個簡單的前置檢查讓我們執行 - 看需要并行還是不需要并行,如果不需要是,那么我們可以采取簡單計算結果的方法并將其回傳給呼叫者,如果它是需要的,那么我們將進入下一階段,弄清楚如何將操作細分為更小的任務,
該isParallel()函式考慮到的不僅是資料的大小,還有資料項中的任何一項執行計算的成本,這讓我們可以微調應用程式的并發性,如果開銷太大,我們可以增加并行處理閾值,如果我們對代碼進行了一些更改,這些更改讓以前簡便的函式,變得復雜,我們只需要更改expensiveTask標志,
當我們的代碼在主執行緒中運行時,它在worker執行緒中運行時會發生什么?這是否意味著我們必須寫下
兩次任務代碼:一次用于正常代碼,一次用于我們的workers?我們顯然想避免這種情況,所以我們需要
保持我們的任務代碼模塊化,它需要能在主執行緒和worker執行緒中都可用,
硬體并發功能
我們將在并發應用程式中執行的另一個高級檢查是我們正在運行的硬體的并發功能,這將告訴我們要創建多少web workers,例如,通過在只有四個CPU核心的系統上創建32個web workers,我們真的得不到什么好處的,在這個系統上,四個web workers會更合適,那么,我們如何得到這個數字呢?
讓我們創建一個通用函式,來解決這個問題:
//回傳理想的Web worker創建數量,
function getConcurrency(defaultLevel = 4) {
//如果“navigator.hardwareConcurrency”屬性存在,
//我們直接使用它,否則,我們回傳“defaultLevel”值,
//這個值在實際的硬體并發級別上是一個合理的猜測值,
return Number.isInteger(navigator.hardwareConcurrency) ?
navigator.hardwareConcurrency :
defaultLevel;
}
console.log('concurrency level', getConcurrency());
//→concurrency level 8
由于并非所有瀏覽器都實作了navigator.hardwareConcurrency屬性,因此我們必須考慮到這一點,如果我們不知道確切的硬體并發級別數,我們必須做下猜測,在這里,我們認為4是我們可能遇到的最常見的CPU核心數,由于這是一個默認引數值,因此它作用于兩點:呼叫者的特殊情況處理和簡單的全域更改,
還有其他技術試圖通過生成worker執行緒并對回傳資料的速率進行采樣來測量并發級別數,這是一種有趣的技術,
但由于涉及的開銷和一般不確定性,因此不適合生產級應用,換句話說,使用覆寫我們大多數用戶系統的靜態值
就足夠了,
創建任務和分配作業
一旦我們確定一個給定的操作應該并行執行,并且我們知道要根據并發級別創建多少workers,就可以創建一些任務,并將它們分配給workers,從本質上講,這意味著將輸入資料切分為較小的塊,并將這些資料傳遞給將我們的任務應用于資料子集的worker,
在前一章中,我們看到了第一個獲取輸入資料并將其轉化為任務的示例,一旦作業被拆分,我們就會產生一個新worker,并在任務完成時終止它,像這樣創建和終止執行緒根據我們正在構建的應用程式型別,這可能不是理想的方法,例如,如果我們偶爾運行一個可以從并行處理中受益的復雜操作,那么按需生成workers可能是有意義的,但是,如果我們頻繁的并行處理,那么在應用程式啟動時生成執行緒可能更有意義,并重用它們來處理很多型別的任務,以下是有多少操作可以為不同任務共享同一組worker的說明:
這種配置允許操作發送訊息到已在運行的worker執行緒,并得到回傳結果,當我們正在處理他們的時候,這里沒有與生成新worker和清理它們相關的開銷,目前仍然是問題的和解,我們將操作拆分為較小的任務,每個任務都回傳自己的結果,然而,該操作被期望回傳一個單一的結果,所以當我們將作業分成更小的任務,我們還需要一種方法將任務結果合并到一個整體中,
讓我們撰寫一個通用函式來處理將作業分成任務并將結果整合在一起以進行協調的樣板方法,當我們在用它的時候,我們也讓這個函式確定操作是否應該并行化,或者它是應該在主執行緒中同步運行,首先,讓我們看一下我們要針對每個資料塊并行運行的任務本身,因為它是切片的:
//根據提供的引數回傳總和的簡單函式,
function sum(...numbers) {
return numbers.reduce((result, item) => result + item);
}
此任務保持我們的worker代碼以及在主執行緒中運行的應用程式的其他部分分開,原因是我們要在以下兩個環境中使用此函式:主執行緒和worker執行緒,現在,我們將創建一個可以匯入此函式的worker,并將其與在訊息中傳遞給worker的任何資料一起使用:
//加載被這個worker執行的通用任務
importScripts('task.js');
if (chunk.length) {
addEventListener('message', (e) => {
//如果我們收到“sum”任務的訊息,
//然后我們呼叫我們的“sum()”任務,
//并發送帶有操作ID的結果,
if(e.data.task === 'sum') {
postMessage({
id: e.data.id,
value: sum(...e.data.chunk)
});
}
});
}
在本章的前面,我們實作了兩個工具函式,所述isConcurrent()函式確定運行的操作是否作為一組較小的并行任務,另一個函式getConcurrency()確定我們應該運行的并發級別數,我們將在這里使用這兩個函式,并將介紹兩個新的工具函式,事實上,這些是將在后面幫助使用我們的生成器,我們來看看這個:
//此生成器創建一系列的workers來匹配系統的并發級別,
//然后,作為呼叫者遍歷生成器,即下一個worker是
//yield的,直到最后結束,然后我們再重新開始,
//這就像一個回圈上用于選擇workers來發送訊息,
function* genWorkers() {
var concurrency = getConcurrency();
var workers = new Array(concurrency);
var index = 0;
//創建workers,將每個存盤在“workers”陣列中,
for (let i = 0; i < concurrency; i++) {
workers[i] = new Worker('worker.js');
//當我們從worker那里得到一個結果時,
//我們通過ID將它放在適當的回應中
workers[i].addEventListener('message', (e) => {
var result = results[e.data.id];
result.values.push(e.data.value);
//如果我們收到了預期數量的回應,
//我們可以呼叫該操作回呼,
//將回應作為引數傳遞,
//我們也可以洗掉回應,
//因為我們現在是在處理它,
if (result.values.length === result.size) {
result.done(...result.values);
delete results[e.data.id];
}
});
}
//只要他們需要,就繼續生成workers,
while (true) {
yield workers[index] ?
workers[index++] :
workers[index = 0];
}
}
//創建全域“worker”生成器,
var workers = genWorkers();
//這將生成唯一ID,我們需要它們
//將Web worker執行的任務映射到
//更大的創建它們的操作上,
function* genID() {
var id = 0;
while (true) {
yield id++;
}
}
//創建全域“id”生成器,
var id = genID();
伴隨著這兩個生成器的位置 - workers和id - 我們現在就已經可以實作我們的parallel()高階函式,我們的想法是將一個函式作為輸入以及一些其他引數,這些引數允許我們調整并行的行為并回傳一個可以在整個應用程式中正常呼叫的新函式,我們現在來看看這個函式:
//構建一個在呼叫時運行給定任務的函式
//在worker中將資料拆分成塊,
function parallel(expensive, taskName, taskFunc, doneFunc) {
//回傳的函式將資料作為引數處理,
//以及塊大小,具有默認值,
return function(data, size = 250) {
//如果資料不夠大,函式也并不復雜,
//那么只需在主執行緒中運行即可,
if (!isConcurrent(data, expensive)) {
if (typeof taskFunc === 'function') {
return taskFunc(data);
} else {
throw new Error('missing task function');
}
} else {
//此呼叫的唯一識別符號,
//用于協調worker結果時,
var operationID = id.next().value;
//當我們將它切成塊時,
//用于跟蹤資料的位置,
var index = 0;
var chunk;
//全域“results”物件得到一個包含有關此操作的資料物件,
//“size”屬性表示我們期待的回傳結果數量,
//“done”屬性是所有結果被傳遞給的回呼函式,
//并且“values”存著來自workers的結果,
result[operationID] = {
size: 0,
done: doneFunc,
values: []
};
while (true) {
//獲取下一個worker,
let worker = workers.next().value;
//從輸入資料中切出一個塊,
chunk = data.slice(index, index + size);
index += size;
//如果要處理一個塊,我們可以增加預期結果的大小,
//并發布一個給worker的訊息,
//如果沒有塊的話,我們就完成了,
if (chunk.length) {
results[operationID].size++;
worker.postMessage({
id: operationID,
task: taskName,
chunk: chunk
});
} else {
break;
}
}
}
};
}
//創建一個要處理的陣列,使用整數填充,
var array = new Array(2000).fill(null).map((v, i) => i);
//創建一個“sumConcurrent()”函式,
//在呼叫時,將處理worker中的輸入資料,
var sumConcurrent = parallel(true, 'sum', sum,
function(...results) {
console.log('results', results.reduce((r, v) => r + v));
});
sumConcurrent(array);
現在我們可以使用parallel()函式來構建在整個應用程式中呼叫的并發函式,例如,當我們必須計算大量輸入的總和時,就可以使用sumConcurrent()函式,唯一不同的是輸入資料,
這里一個明顯的限制是我們只有一個回呼函式,我們可以在并行化函式完成時指定,
而且,這里有很多標記要做 - 用ID來協調任務與他們的操作有些痛苦; 這感覺好像我們正在實作promise,
這是因為這基本上就是我們在這里所做的,下一章將詳細介紹如何將promise與worker相結合,以避免混亂的抽象,
例如我們剛剛實作的抽象,
候選的問題
在上一節中,你學習了如何創建一個通用函式,該函式將在運行中決定如何使用worker劃分和實施,或者在主執行緒中簡單地呼叫函式是否更有利,既然我們已經有了通用的并行機制,我們可以解決哪些問題?在本節中,我們將介紹從穩固的并發體系結構中受益的最典型的并發方案,
令人尷尬的并行
如何將較大的任務分解為較小的任務時,很明顯就是個令人尷尬的并行問題,這些較小的任務不依賴于彼此,這使得開始執行輸入并生成輸出而不依賴于其他workers狀態的任務變得更加容易,這又回到了函式式編程,以及參考透明性和沒有副作用的方法,
這些型別的問題是我們想要通過并發解決的 - 至少首先,在我們的應用首次實施時是困難的,就并發問題而言,這些都是懸而未決的結果,它們應該很容易解決而不會冒提供功能能力的風險,
我們在上一節中實作的最后一個示例是一個令人尷尬的并行問題,我們只需要每個子任務來添加輸入值并回傳它們,當集合很大且非結構化時,全域搜索是另一個例子,我們很少花費作業來分成較小的任務并將它們合并出結果,搜索大文本輸入是一個類似的例子,mapping和reducing是另一個需要作業相對較少的并行例子,
搜索集合
一些集合排過序,可以有效地搜索這些集合,因為二進制搜索演算法能夠簡單地基于資料被排序的前提來避免大部分的資料查找,然而,有時我們使用的是非結構化或未排序的集合,在有些情況下,時間復雜度可能是O(n),因為需要檢查集合中的每一項,不能做出任何假設,
大量文本是非結構化集合的一個典型的例子,如果我們要在這個文本中搜索一個子字串,那么就沒有辦法避免根據我們已經查找過的內容搜索文本的一部分 - 需要覆寫整個搜索空間,我們還需要計算大量文本中子字串出現次數,這是一個令人尷尬的并行問題,讓我們撰寫一些代碼來計算字串輸入中子字串出現次數,我們將復用在上一節中創建的并行工具函式,特別是parallel()函式,這是我們將要使用的任務:
//統計在“collection”中“item”出現的次數
function count(collection, item) {
var index = 0,
occurrences = 0;
while (true) {
//找到第一個索引,
index = collection.indexOf(item, index);
//如果我們找到了,就增加計數,
//然后增加下一個的起始索引,
//如果找不到,就退出回圈,
if (index > -1) {
occurrences += 1;
index += 1;
} else {
break;
}
}
//回傳找到的次數,
return occurrences;
}
現在讓我們創建一個文本塊供我們搜索,并使用并行函式來搜索它:
//我們需要查找的非結構化文本,
var string =`Lorem ipsum dolor sit amet,mei zril aperiam sanctus id,duo wisi aeque
molestiae ex,Utinam pertinacia ne nam,eu sed cibo senserit,Te eius timeam docendi quo,
vel aeque prompta philosophia id,necut nibh accusamus vituperata,Id fuisset qualisque
cotidieque sed,eu verterem recusabo eam,te agam legimus interpretaris nam,EOS
graeco vivendo et,at vis simul primis`;
//使用我們的“parallel()”工具函式構造一個新函式 - “stringCount()”,
//通過迭代worker計數結果來實作記錄字串的數量,
var stringCount = parallel(true, 'count', count,
function(...results) {
console.log('string', results.reduce((r, v) => r + v));
});
//開始子字串計數操作,
stringCount(string, 20, 'en');
在這里,我們將輸入字串拆分為20個字符塊,并且搜索輸入值en,最后找到3個結果,讓我們看看是否能夠使用這項任務,隨著我們并行worker工具和統計出現的次數在一個陣列中,
//創建一個介于1和5之間的10,000個整數的陣列,
var array = new Array(10000).fill(null).map(() => {
return Math.floor(Math.random() * (5 - 1)) + 1;
});
//創建一個使用“count”任務的并行函式,
//計算在陣列中出現的次數,
var arrayCount = parallel(true, 'count', count, function(...results) {
console.log('array', results.reduce((r, v) => r + v));
});
//我們查找數字2 - 可能會有很多,
arrayCount(array, 1000, 2);
由于我們使用隨機整數生成這個10,000個元素的陣列,因此每次運行時輸出都會有所不同,但是,我們的并行worker工具的優點是我們能夠以更大的塊呼叫arrayCount(),
您可能已經注意到我們正在過濾輸入,而不是在其中找到特定項,這是一個令人尷尬的并行
問題的例子,而不是使用并發解決的問題,我們之前的過濾代碼中的worker節點不需要彼此通信,
如果我們有幾個worker節點都尋找某一個項,我們將不可避免地面臨提前終止的情況,
但要處理提前終止,我們需要worker以某種方式相互通信,這不一定是壞事,只是更多的共享狀態和更多的
并發復雜性,這樣的結果在并發編程中變得相關 - 我們是否可以在其他地方進行優化以避免某些并發性挑戰呢?
Mapping和Reducing
JavaScript中的Array原生語法已經有了map()方法,我們現在知道,有兩個關鍵因素會影響給定輸入資料集運行給定操作的可伸縮性和性能,它是資料的大小乘以應用于此資料中每個項上的任務復雜度,如果我們將大量資料放到一個陣列,然后使用復雜的代碼處理每個陣列項,這些約束可能會導致我們的應用程式出現問題,
讓我們看看用于過去幾個代碼示例的方法是否可以幫助我們將一個陣列映射到另一個陣列,而不必擔心在單個CPU上運行的原生Array.map()方法 - 一個潛在的瓶頸,我們還將解決迭代大資料集合的問題,這與mapping類似,只有我們使用Array.reduce()方法,以下是任務函式:
//一個“plucks”給定的基本映射
//從陣列中每個項的“prop”,
function pluck(array, prop) {
return array.map((x) => x[prop]);
}
//回傳迭代陣列項總和的結果,
function sum(array) {
return array.reduce((r, v) => r + v);
}
現在我們有了可以從任何地方呼叫的泛型函式 - 主執行緒或worker執行緒,我們不會再次查看worker代碼,因為它使用與此之前的示例相同的模式,它確定要呼叫的任務,并格式化處理發送回主執行緒的回應,讓我們繼續使用parallel()工具函式來創建一個并發map函式和一個并發reduce函式:
//創建一個包含75,000個物件的陣列,
var array = new Array(75000).fill(null).map((v, i) => {
return {
id: i,
enabled: true
};
});
//創建一個并發版本的“sum()”函式
var sumConcurrent = parallel(true, 'sum', sum,
function(...results) {
console.log('total', sum(results));
});
//創建一個并發版本的“pluck()”函式,
//當并行任務完成時,將結果傳遞給“sumConcurrent()”,
var pluckConcurrent = parallel(true, 'pluck', pluck,
function(...results) {
sumConcurrent([].concat(...results));
});
//啟動并發pluck操作,
pluckConcurrent(array, 1000, 'id');
在這里,我們創建了75個任務分發給workers(75000/1000),根據我們的并發級別數,這意味著我們將同時從陣列項中提取多個屬性值,reduce任務以相同方式作業; 我們并發的計算映射的集合,我們仍然需要在sumConcurrent()回呼進行求和,但它很少,
執行并發迭代任務時我們需要謹慎,Mapping是簡單的,因為我們創建的是一個原始陣列的大小和排序
方面的克隆,這是不同的值,Reducing可能是依賴于該結果作為它目前的立場,不同的是,因為每個陣列
項通過迭代函式,它的結果,因為它被創建,可以改變的最終結果輸出,
并發使得這個變得困難,但在此之前的例子,該問題是尷尬的并行 - 不是所有的迭代作業都是,
保持DOM回應
到本章這里,重點已經被資料中心化了 - 通過使用web worker來對獲取輸入和轉換進行分割和控制,這不是worker執行緒的唯一用途; 我們也可以使用它們來保持DOM對用戶的回應,
在本節中,我們將介紹一個在Linux內核開發中使用的概念,將事件分成多個階段以獲得最佳性能,然后,我們將解決DOM與我們的worker之間進行通信的挑戰,反之亦然,
Bottom halves
Linux內核具有top-halves和bottom-halves的概念,這個想法被硬體中斷請求機制使用,問題是硬體中斷一直在發生,而這是內核的作業,以確保它們都是及時捕獲和處理的,為了有效地做到這一點,內核將處理硬體中斷的任務分為兩半 - top-halves和bottom-halves,
top-halves的作業是回應外部觸發,例如滑鼠點擊或擊鍵,但是,top-halves受到嚴格限制,這是故意的,處理硬體中斷請求的top-halves只能安排實際作業 - 所有其他系統組件的呼叫 - 以后再進行,后面的作業是在bottom-halves完成的,這種方法的副作用是中斷在低級別迅速處理,在優先級事件方面允許更大的靈活性,
什么內核開發作業必須用到JavaScript和并發?好了,它變成了我們可以借用這些方法,并且我們的“bottom-half”的作業委托給一個worker,我們的事件處理代碼回應DOM事件實際上什么也不做,除了傳遞訊息給worker,這確保了在主執行緒中只做它絕對需要做而沒有任何額外的處理,這意味著,如果Web worker回傳的結果要展示,它可以馬上這么做,請記住,在主執行緒包括渲染引擎,它阻止我們運行的代碼,反之亦然,這是處理外部觸發的top-halves和bottom-halves的示圖:
JavaScript是運行即完成的,我們現在已經很清楚了,這意味著在top-halves花費的時間越少,就越需要通過更新螢屏來回應用戶,與此同時,JavaScript也在我們的bottom-halves運行的Web worker中運行完成,這意味著同樣的限制適用于此; 如果我們的worker得到在短時間內發送給它的100條訊息,他們將以先入先出(FIFO)的順序進行處理,
不同之處在于,由于此代碼未在主執行緒中運行,因此UI組件在用戶與其互動時仍會回應,對于高要求的產品來說,這是一個至關重要的因素,值得花時間研究top-halves和bottom-halves,我們現在只需要弄清楚實作,
轉換DOM操作
如果我們將Web worker視為應用程式的bottom-halves,那么我們需要一種操作DOM的方法,同時在top-halves花費盡可能少的時間,也就是說,由worker決定在DOM樹中需要更改什么,然后通知主執行緒,接著,主執行緒必須做的就是在發布的訊息和所需的DOM API呼叫之間進行轉換,在接收這些訊息和將控制權移交給DOM之間沒有資料操作; 毫秒在主執行緒中是寶貴的,
讓我們看看這是多么容易實作,我們將從worker實作開始,該實作在想要更新UI中的內容時將DOM操作訊息發送到主執行緒:
//保持跟蹤我們渲染的串列項數量,
var counter = 0;
//主執行緒發送訊息通知所有必要的DOM操作資料內容,
function appendChild(settings) {
postMessage(settings);
//我們已經渲染了所有項,我們已經完成了,
if (counter === 3) {
return;
}
//調度下一個“appendChild()”訊息,
setTimeout(() => {
appendChild({
action: 'appendChild',
node: 'ul',
type: 'li',
content: `Item ${++counter}`
});
}, 1000);
}
//調度第一個“appendChild()”訊息,
//這包括簡單渲染到主執行緒中的DOM所需的資料,
setTimeout(() => {
appendChild({
action: 'appendChild',
node: 'ul',
type: 'li',
content: `Item ${++counter}`
});
}, 1000);
這項作業將三條訊息發回主執行緒,他們使用setTimeout()進行定時,因此我們可以期望的看到每秒渲染一個新的串列項,直到顯示所有三個,現在,讓我們看一下主執行緒代碼如何使用這些訊息:
//啟動worker(bottom-halves),
var worker = new Worker('worker.js');
worker.addEventListener('message', (e) => {
//如果我們收到“appendChild”動作的訊息,
//然后我們創建新元素并將其附加到
//適當的父級 - 在訊息資料中找到所有這些資訊,
//這個處理程式絕對是除了與DOM互動之外什么都沒有
if (e.data.action ==='appendChild') {
let child = document.createElement(e.data.type);
child.textContent = e.data.content;
};
document.querySelector(e.data.node).appendChild(child);
});
正如我們所看到的,我們有很少機會給top-halves(主執行緒)帶來瓶頸,導致用戶互動卡住,這很簡單 - 這里執行的唯一代碼是DOM操作代碼,這大大增加了快速完成的可能性,允許螢屏為用戶明顯更新,
另一個方向是什么,將外部事件放入系統而不干擾主執行緒?我們接下來會看看這個,
轉換DOM事件
一旦觸發了DOM事件,我們就希望將控制權移交給我們的Web worker,通過這種方式,主執行緒可以繼續運行,好像沒有其他事情發生 - 大家都很高興,不幸的是,還有一點,例如,我們不能簡單地監聽每個元素上的每一個事件,將每個元素轉發給worker,如果它不斷回應事件,那么它將破壞不在主執行緒中運行代碼的目的,
相反,我們只想監聽worker關心的DOM事件,這與我們實作任何其他Web應用程式的方式沒有什么不同;我們的組件會監聽他們關心的事件,要使用workers實作這一點,我們需要一種機制來告訴主執行緒在特定元素上設定DOM事件監聽器,然后,worker可以簡單地監聽傳入的DOM事件并做出相應的回應,我們先來看一下worker的實作:
//當“input”元素觸發“input”事件時,
//告訴主執行緒我們想要收到通知,
postMessage({
action: 'addEventListener',
selector: 'input',
event: 'input'
});
//當“button”元素觸發“click”事件時,
//告訴主執行緒我們想要收到通知,
postMessage({
action: 'addEventListener',
selector: 'button',
event: 'click'
});
//一個DOM事件被觸發了,
addEventListener('message', (e) => {
var data = https://www.cnblogs.com/yzsunlei/p/e.data;
//根據具體情況以不同方式記錄
//事件是由觸發的,
if(data.selector ==='input') {
console.log('worker', `typed "${data.value}"`);
} else if (data.selector === 'button') {
console.log('worker', 'clicked');
}
});
該worker要求有權訪問DOM的主執行緒設定兩個事件偵聽器,然后,它為DOM事件設定自己的事件偵聽器,最終進入worker,讓我們看看負責設定處理程式和向worker轉發事件的DOM代碼:
//啟動worker...
var worker = new Worker('worker.js');
//當我們收到訊息時,這意味著worker想要
//監聽DOM事件,所以我們必須設定代理,
worker.addEventListener('message', (msg) => {
var data = https://www.cnblogs.com/yzsunlei/p/msg.data;
if (data.action ==='addEventListener') {
//找到worker正在尋找的節點,
var nodes = document.querySelectorAll(data.selector);
//為給定的“event”添加一個新的事件處理程式
//我們剛剛找到的每個節點,當那個事件發生時觸發,
//我們只是發回一條訊息回傳到包含相關事件資料的worker,
for (let node of nodes) {
node.addEventListener(data.event, (e) => {
worker.postMessage({
selector: data.selector,
value: e.target.value
});
})
};
}
});
為簡潔起見,只有幾個事件屬性被發送回worker,由于Web worker訊息中的序列化限制,我們無法發送事件
物件,實際上,可以使用相同的模式,但我們可能會為此添加更多事件屬性,例如clientX和clientY,
小結
前一章向我們介紹了Web workers,重點介紹了這些組件的強大功能,本章改變了方向,重點關注并發的“why”方面,我們通過查看函式式編程的某些方面以及它們如何適合JavaScript中的并發編程來解決問題,
我們研究了確定跨worker同時執行給定操作的可行性所涉及的因素,有時,拆分大型任務并將其作為較小的任務分發給worker需要花費大量開銷,我們實作了一些通用工具函式,幫助我們實作并發函式,封裝一些相關的并發樣板代碼,
并非所有問題都非常適合并發解決方案,最好的方法是自上而下地作業,找出令人尷尬的并行問題,因為它們是懸而未決的成果,然后,我們將此原則應用于許多map-reduce問題,
我們簡要介紹了top-halves和bottom-halves的概念,這是一種策略,可以使主執行緒持續清除待處理的JavaScript代碼,以保持用戶界面的回應,我們在忙于思考關于我們最有可能遇到的并發問題的型別,以及解決它們的最佳方法,我們的代碼復雜性上升了一個檔次,下一章是關于將三個并發原則集合在一起的方式,它將并發性放在首位,而不會犧牲代碼的可讀性,
最后補充下書籍章節目錄
- 《JavaScript并發編程》第一章 JavaScript并發簡介
- 《JavaScript并發編程》第二章 JavaScript運行模型
- 《JavaScript并發編程》第三章 使用Promises實作同步
- 《JavaScript并發編程》第四章 使用Generators實作惰性計算
- 《JavaScript并發編程》第五章 使用Web Workers
- 《JavaScript并發編程》第六章 實用的并發
- 《JavaScript并發編程》第七章 抽取并發邏輯
另外還有講解兩章nodeJs后端并發方面的,和一章專案實戰方面的,這里就不再貼了,有興趣可轉向https://github.com/yzsunlei/javascript_concurrency_translation查看,
轉載請註明出處,本文鏈接:https://www.uj5u.com/qiye/180443.html
標籤:JavaScript
