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

本文是我翻譯《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屬性在某些給定時間會有一個值,也可以在某些給定時間改變。換句話說,用戶改變狀態。如果我們將這個對象傳遞給我們應用程序的不同模塊,那麼狀態也會隨之傳遞。它被封裝爲一個屬性。引用用戶對象的這些組件中的任何一個都可以改變它,然後將其傳遞到其他地方,等等。下面的插圖顯示了一個函數在將用戶傳遞給另一個組件之前是如何改變其狀態的:

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章