[書籍翻譯] 《JavaScript併發編程》第六章 實用的併發

本文是我翻譯《JavaScript Concurrency》書籍的第六章 實用的併發,該書主要以Promises、Generator、Web workers等技術來說解JavaScript併發編程方面的實踐。javascript

完整書籍翻譯地址:https://github.com/yzsunlei/javascript_concurrency_translation 。因爲能力有限,確定存在翻譯不清楚甚至翻譯錯誤的地方,歡迎朋友們提issue指出,感謝。html

在上一章中,咱們大體學習了Web workers的基本功能。咱們在瀏覽器中使用Web worker實現真正的併發,由於它們映射到實際的線程上,而這些線程又映射到獨立的CPU上。本章,首次提供設計並行代碼的一些實用方法。java

咱們首先簡要介紹一下從函數式編程中能夠借鑑的一些方法,以及它們如何能很好的適用於併發性問題。而後,咱們將決定應該經過並行計算仍是簡單地在一個CPU上運行來解決並行有效性的問題。而後,咱們將深刻研究一些能夠從並行運行的任務中受益的併發問題。咱們還將解決在使用workers線程時保持DOM響應的問題。node

函數式編程

函數顯然是函數式編程的核心。其實,就是數據在咱們的應用程序中流轉而已。實際上,數據及其它在程序中的流轉可能與函數自己的實現一樣重要,至少就應用程序設計而言。git

函數式編程和併發編程之間存在很強的親和力。在本節中,咱們將看看爲何是這樣的,以及咱們如何應用函數式編程技術編寫更強壯的併發代碼。github

數據輸入,數據輸出

函數式編程相對其餘編程範式是很強大的。這是一個解決一樣問題的不一樣方式。咱們使用一系列不一樣的工具。例如,函數就是積木,咱們將利用它們來創建一個關於數據轉換的抽象。命令式編程,從另外一方面來講,使用構造,好比說類來構建抽象。與類和對象的根本區別是它們喜歡封裝一些東西,而函數一般是數據流入,數據流出。web

例如,假設咱們有一個帶有enabled屬性的用戶對象。咱們的想法是,enabled屬性在某些給定時間會有一個值,也能夠在某些給定時間改變。換句話說,用戶改變狀態。若是咱們將這個對象傳遞給咱們應用程序的不一樣模塊,那麼狀態也會隨之傳遞。它被封裝爲一個屬性。引用用戶對象的這些組件中的任何一個均可以改變它,而後將其傳遞到其餘地方,等等。下面的插圖顯示了一個函數在將用戶傳遞給另外一個組件以前是如何改變其狀態的:算法

image117.gif

在函數式編程中不是這樣的。狀態不是封裝在對象內部,而後從組件傳遞到另外一個組件;不是由於這樣作本質上是壞的,而是由於它只是解決問題的另外一種方式。狀態封裝是面向對象編程的目標,而函數式編程的關注的是從A點到B點並沿途轉換數據。這裏沒有C點,一旦函數完成其工做就沒有意義 - 它不關心數據的狀態。這裏是上圖的函數替代方案:編程

image119.gif

咱們能夠看到,函數方法使用更新後的屬性值建立了一個新對象。該函數將數據做爲輸入並返回新數據做爲輸出。換句話說,它不會修改輸入數據。這是一個簡單的方法,但會有重要的結果,如不變性。後端

不變性

不可變數據是一個重要的函數式編程概念,很是適合併發編程。JavaScript是一種多範式語言。也就是說,它是函數式的,但也能夠是命令式的。一些函數式編程語言嚴格遵循不變性 - 你根本沒法改變對象的狀態。這其實是很好的,它擁有選擇什麼時候保持數據不可變性以及什麼時候不須要的靈活性。

在上一節的最後一張圖中,展現了enable()函數實際返回一個具備與輸入值不一樣的屬性值的全新對象。這樣作是爲了不改變輸入值。雖然,這可能看起來很浪費 - 不斷創建新對象,但實際上並不是如此。綜合考慮當對象永遠不會改變時咱們沒必要寫的標記代碼。

例如,若是用戶的enabled屬性是可變的,則這意味着使用此對象的任何組件都須要不斷檢查enabled屬性。如下是對此的見解:

image120.gif

只要組件想要向用戶顯示,就須要不斷進行此檢查。咱們實際上在使用函數方法時須要執行一樣的檢查。可是,函數式方法惟一有效的起點是建立路徑。若是咱們系統中的其餘內容能夠更改enabled的屬性,那麼咱們須要擔憂建立和修改路徑。消除修改路徑還消除了許多其餘複雜性。這些被稱爲反作用。

反作用和併發性並很差。事實上,這是一個能夠改變對象的方法,這使得併發變得困難。例如,假設咱們有兩個線程想要訪問咱們的用戶對象。他們首先須要獲取對它的訪問權限,它可能已被鎖定。如下是該方法的示圖:

image122.gif

在這裏,咱們能夠看到第一個線程鎖定用戶對象,阻止其餘線程訪問它。第二個線程須要等到它解鎖才能繼續。這稱爲資源佔用,它減弱了利用多核CPU的整個設計目的。若是線程等待訪問某種資源,則它們並不真正的是在並行運行。不可變性能夠解決資源佔用問題,由於不須要鎖定不會改變的資源。如下是使用兩個線程的函數方法:

image123.gif

當對象不改變狀態,任意數量的線程能夠同時訪問他們沒有任何風險破壞對象的狀態,因爲亂序操做而且無需浪費寶貴的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 = 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 = 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的說明:

image130.gif

這種配置容許操做發送消息到已在運行的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的示圖:

image138.gif

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 = 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 = 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代碼,以保持用戶界面的響應。咱們在忙於思考關於咱們最有可能遇到的併發問題的類型,以及解決它們的最佳方法,咱們的代碼複雜性上升了一個檔次。下一章是關於將三個併發原則集合在一塊兒的方式,它將併發性放在首位,而不會犧牲代碼的可讀性。

最後補充下書籍章節目錄

另外還有講解兩章nodeJs後端併發方面的,和一章項目實戰方面的,這裏就再也不貼了,有興趣可轉向https://github.com/yzsunlei/javascript_concurrency_translation查看。

相關文章
相關標籤/搜索