1. 程式人生 > >[書籍翻譯] 《JavaScript併發程式設計》第五章 使用Web Workers

[書籍翻譯] 《JavaScript併發程式設計》第五章 使用Web Workers

本文是我翻譯《JavaScript Concurrency》書籍的第五章 使用Web Workers,該書主要以Promises、Generator、Web workers等技術來講解JavaScript併發程式設計方面的實踐。

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

Web workers在Web瀏覽器中實現了真正的併發。它們花了很多時間改進,現在已經有了很好的瀏覽器支援。在Web workers之前,我們的JavaScript程式碼侷限於CPU,我們的執行環境在頁面首次載入時啟動。Web workers發展起來後 - Web應用程式越來越強大。他們也開始需要更多的計算能力。與此同時,多核CPU現在很常見 - 即使是在一些低端裝置上。

在本章中,我們將介紹Web workers的思想,以及它們如何與我們努力在應用中實現的併發性原則產生關聯。然後,將通過示例學習如何使用Web worker,以便在本書的後面部分,我們可以開始將併發與我們已經探索過的其他一些想法聯絡起來,例如promises和generators。

什麼是Web workers?

在深入研究實現示例之前,本節將簡要介紹Web workers的概念。搞清楚Web workers如何與引擎下的其他系統協作的。Web workers是作業系統執行緒 - 我們可以排程事件的物件,它們以真正的併發正規化來執行我們的JavaScript程式碼。

OS執行緒

從本質上講,Web workers只不過是作業系統級執行緒。執行緒有點像程序,除了它們需要更少的開銷,因為它們與建立它們的程序共享記憶體地址。由於為Web workers提供支援的執行緒處於作業系統級別,因此受系統及其程序排程程式的管理。實際上,這正是我們想要的 - 讓核心清楚我們的JavaScript程式碼應該什麼時候執行,這樣才能充分地利用CPU。

下面的示圖展示了瀏覽器如何將其Web workers對映到OS執行緒,以及這些執行緒如何對映到CPU上:

在日常活動結束時,作業系統最好能放下其他任務來負責它擅長的 - 處理物理硬體上的軟體任務排程。在傳統的多執行緒程式設計環境中,程式碼更接近作業系統核心。Web workers不是這種情況。雖然底層機制是一個執行緒,但是暴露的程式設計介面看起來更像是你可能在DOM中查詢的東西。

事件物件

Web workers實現了熟悉的事件物件介面。這使得Web workers的行為類似於我們使用的其他元件,例如DOM元素或XHR請求。Web workers觸發事件,這就是我們在主執行緒中從他們那裡接收資料的方式。我們也可以向Web workers傳送資料,這使用一個簡單的方法呼叫。

當我們將資料傳遞給Web workers時,我們實際上會觸發另一個事件;只有這時候,它位於Web workers的執行上下文中,而不是在主頁面的執行上下文。沒有更多的事情要處理:資料輸入,資料輸出。沒有互斥結構或任何此類結構。這實際上是一件好事,因為作為平臺的Web瀏覽器已經有許多模組。想象一下,如果我們投入很複雜的多執行緒模型而不是一個簡單的基於事件物件的方法。我們每天已經有足夠多的bugs需要處理。

以下是關於Web worker排布的樣子,相對於生成這些Web workers的主執行緒:

真正的併發

Web workers是在我們的架構中實現併發原則的方法。我們知道,Web workers是作業系統執行緒,這意味著在它們內部執行的JavaScript程式碼可能在與主執行緒中的某些DOM事件處理程式程式碼相同的例項上執行。能夠做這樣的事情已經在很長一段時間成為JavaScript程式設計師的目標了。在Web workers之前,真正的併發性是不可能的。我們所做的最好的就是模擬它,給使用者一種許多事情同時發生的的假象。

但是,始終在同一CPU核心上執行是存在問題的。我們從根本上限制了在給定時間視窗內可以執行多少次計算。當引入真正的併發性時,此限制會被打破,因為可以執行計算的時間視窗會隨著新增的CPU而增加。

話雖這麼說,對於我們的應用程式所做的大多數事情,單執行緒模型工作的也很好。現在的機器都很強大。我們可以在很短的時間內完成很多工作。當我們臨近峰值時會出現問題。這些可能是一些事件中斷了我們程式碼處理程序。我們的應用程式不斷被要求做得更多 - 更多功能,更多資料。

Web workers所關心的就是我們可以更好地利用我們面前的硬體的方法。Web workers,如果使用得當,它不一定是我們在專案中永遠不會使用的不可逾越的新東西,因為它的概念超出我們之前的理解。

workers的種類

在開發併發JavaScript應用程式中,我們可能會見到三種類型的Web workers。在本節中,我們將比較這三種類型,以便可以瞭解在給定的上下文中哪種型別的workers更有用。

專用workers

專用workers可能是最常見的workers型別。它們被作為是Web worker的預設型別。當我們的頁面建立一個新的Web worker時,它專門用於頁面的執行上下文而不是其他內容。當我們的頁面銷燬時,頁面建立的所有專用workers也會銷燬。

頁面與其建立的任何專用worker之間的通訊方式非常簡單。該頁面將訊息傳送給workers,workers又將訊息發回頁面。這些訊息的順序取決於我們嘗試使用Web worker解決的問題。我們將在本書中深入研究這些訊息傳遞模式。

術語主執行緒和頁面在本書中是同義詞。主執行緒是典型的執行上下文,我們可以在這裡操作頁面並監聽輸入。
Web worker上下文基本相同,但只能訪問較少的Web元件。我們將很快討論這些限制。

以下是頁面與專用workers通訊的描述:

正如我們所看到的那樣,專用workers是專注的。它們僅用來服務建立它們的頁面。他們不直接與其他Web workers通訊,也無法與任何其他頁面進行通訊。

子workers

子workers與專用workers非常相似。主要區別在於它們是由專門的Web worker建立的,而不是由主執行緒建立的。例如,如果專用workers的任務可以從併發執行中受益,則可以生成子workers並協調子workers之間的任務執行。

除了擁有不同的建立者之外,子workers還具有一些與專用workers相同的特徵。子workers不直接與主執行緒中執行的JavaScript通訊。由建立它們的worker來協調他們的通訊。以下有張示圖,說明子workers如何按照約定來執行的:

共享workers

第三類Web worker被稱為一個共享worker。共享workers被如此命名是因為多個頁面可以共享這種型別worker的同一個例項。在該頁面可以訪問一個給定的共享workers例項由同源策略所限制,這意味著,如果一個頁面跟這個worker不同域,該worker是不被允許與此頁面通訊的。

共享workers解決的問題與專用workers解決的問題不同。將專用workers視為沒有副作用的函式。你將資料傳遞給它們並獲得不同的返回資料。將共享workers視為遵循單例模式的應用程式物件。它們是在不同上下文之間共享狀態的方法。因此,例如,我們不會僅僅為了處理數字而建立一個共享worker; 我們可以使用一個專用worker。

當記憶體中的應用程式資料來自同一應用程式的其他頁面時,我們使用共享workers就有意義了。想想使用者在新選項卡中開啟連結。這將建立一個新的上下文。這也意味著我們的JavaScript元件需要經歷獲取頁面所需的所有資料,執行所有初始化步驟等過程。這造成重複和浪費。為什麼不通過在不同的瀏覽上下文之間共享的方式來儲存這些資源呢?以下有個示圖說明來自同一應用程式的多個頁面與共享workers例項通訊:

實際上還有第四種類型稱為服務workers。這些是共享worker,其中包含與快取網路資源和離線功能相關的其他功能。服務workers仍處於規範的早期階段,但他們看起來很有意義。如果服務workers成為可行的Web技術,我們今天瞭解的關於共享workers的任何內容都將適用於服務workers。

這裡要考慮的另一個重要因素是服務workers的複雜性。主執行緒和服務worker之間的通訊機制涉及使用埠。同樣,在共享workers中執行的程式碼需要確保它通過正確的埠進行通訊。我們將在本章後面更深入地介紹共享workers的通訊。

Web workers環境

Web worker環境與我們的程式碼通常執行的JavaScript環境不同。在本節中,我們將指出主執行緒的JavaScript環境與Web worker執行緒之間的主要區別。

什麼是可用的,什麼不是?

對Web workers的一個常見誤解是,它們與預設的JavaScript執行上下文完全不同。確實,他們是不同的,但沒有那麼不同以至於沒有可比性。也許,正是由於這個原因,JavaScript開發人員在可能的時候迴避使用Web worker是有好處的。

明顯的差距是DOM - 它在Web worker執行環境中不存在。它不存在是規範起草者有意識決定的。通過避免DOM整合到worker執行緒中,瀏覽器提供商可以避免許多潛在的特殊情況。我們都非常重視瀏覽器的穩定性,或者至少我們應該重視。從Web worker那裡獲取DOM訪問許可權真的很方便嗎?我們將在本書接下來的幾章中看到,workers擅長許多其他任務,這些任務最終有助於成功實現併發原則。

由於我們的Web worker程式碼沒有DOM訪問許可權,因此我們不太可能自找麻煩。它實際上迫使我們去思考為什麼我們要使用Web workers。我們實際上可能退後一步,重新思考我們的方法。除了DOM之外,我們日常使用的大部分功能許可權都有,這正是我們所期望的。這包括在Web workers中使用我們喜歡的類庫。

有關Web worker執行環境中缺少功能的更詳細分類,請參閱此頁面
https://developer.mozilla.org/en-US/docs/Web/API/Worker/Functions_
and_classes_available_to_workers

載入指令碼

我們絕不會將整個應用程式編寫在一個JavaScript檔案中。相反,我們通過將原始碼劃分為檔案的方式來便於模組化,從邏輯上可以將設計分解為我們想對映的內容。同樣,我們可能不希望有由數千行程式碼組成的Web workers。幸運的是,Web worker提供了一種機制,允許我們將程式碼匯入到我們的Web worker中。

第一種場景是將我們自己的程式碼匯入到一個Web worker上下文。我們很可能有許多低級別的工具方法是專門針對我們的應用程式。有很大可能,我們就需要在兩個環境使用這些工具:一個普通的指令碼環境和一個worker執行緒。我們想要保持程式碼的模組化,並希望程式碼以相同的方式作用於Web workers環境,就像它會在任何其他環境下執行。

第二種場景是在Web workers中載入第三方庫。這與將我們自己的模組載入到Web workers中的原理相同 - 我們的程式碼可以在任何上下文中使用,但有一些例外,例如DOM程式碼。讓我們看一個建立Web worker並載入lodash庫的示例。首先,我們將啟動Web worker:

//載入Web worker指令碼,
//然後啟動Web worker執行緒。
var worker = new Worker('worker.js');

接下來,我們將使用loadScripts()函式將lodash庫匯入我們的庫:

//匯入lodash庫,
//讓全域性“_”變數在Web worker上下文中可用。
importScripts('lodash.min.js');

//我們現在可以在Web worker中使用庫。
console.log('in worker', _.at([1, 2, 3], 0, 2));
//→in worker[1,3]

在開始使用指令碼之前,我們不需要擔心等待指令碼載入 - importScripts()是一個阻塞的操作。

與Web workers通訊

前面的示例建立了一個Web worker,它確實在自己的執行緒中執行。但是,這對我們沒有多大幫助,因為我們需要能夠與我們創造的workers通訊。在本節中,我們將介紹從Web workers傳送和接收訊息所涉及的基本機制,包括如何序列化這些訊息。

釋出訊息

當我們想要將資料傳遞給Web worker時,我們使用postMessage()方法。顧名思義,此方法將給定的訊息傳送給worker。如果在worker中設定了任何訊息事件處理程式,它們將響應此呼叫。讓我們看一個將字串傳送給worker的基本示例:

//啟動Web worker執行緒。
var worker = new Worker('worker.js');

//向Web worker傳送訊息,
//觸發“message”事件處理程式。
worker.postMessage('hello world');

現在讓我們看看worker通過為訊息物件設定事件處理程式來檢視此響應訊息:

//為任何“message”設定事件監聽器
//排程給該worker的事件。
addEventListener('message', (e) => {

    //可以通過事件物件的“data”屬性訪問傳送的資料
    console.log(e.type, `"${e.data}"`);
    //→message “hello world”
});

addEventListener()函式是在全域性專用Web workers環境呼叫的。
我們可以將其視為Web workers的視窗物件。

訊息序列化

從主執行緒傳遞到worker執行緒的訊息資料要經過序列化轉換。當此序列化資料到達worker執行緒時,它被反序列化,並且資料可用作JavaScript基本型別。當worker執行緒想要將資料傳送回主執行緒時,使用同樣的過程。

毋庸置疑,這是一個多餘的步驟,給我們可能已經過度工作的應用程式增加了開銷。因此,必須考慮線上程之間來回傳遞資料,因為從CPU成本方面來說這不是輕鬆的操作。在本書的Web worker程式碼示例中,我們將訊息序列化視為我們併發決策過程中的關鍵因素。

所以問題是 - 為什麼要這麼長?如果我們在JavaScript程式碼中使用的worker只是執行緒,我們應該在技術上能夠使用相同的物件,因為這些執行緒使用相同的記憶體地址段。當執行緒共享資源(例如記憶體中的物件)時,可能會發生具有挑戰性的資源搶佔情況。例如,如果一個worker鎖定一個物件而另一個worker試圖使用它,則這會發生錯誤。我們必須實現邏輯來優雅地等待物件變得可用,並且我們必須在worker中實現邏輯來釋放鎖定的資源。

簡而言之,這是一個容易出錯的令人頭痛的問題,如果沒有這個問題我們會好得多。值得慶幸的是,在僅序列化訊息的執行緒之間沒有共享資源。這意味著我們在實際傳遞給worker的東西方面受到限制。經驗上是傳遞可以編碼為JSON字串的東西通常是安全的。請記住,worker必須從此序列化字串重建物件,因此函式或類例項的字串表示根本將不起作用。讓我們通過一個例子來看看它是如何工作的。首先,看一個簡單的worker記錄它收到的訊息:

//簡單輸出收到的訊息。
addEventListener('message', (e) => {
    console.log('message', e.data);
});

現在讓我們看看使用postMessage()可以序列化哪種型別的資料併發送給這個worker:

//啟動Web worker
var worker = new Worker('worker.js');

//傳送一個普通物件。
worker.postMessage({hello: 'world'});
//→訊息{hello:"world"}

//傳送一個數組。
worker.postMessage([1, 2, 3]);
//→訊息[1,2,3]

//試圖傳送一個函式,結果丟擲錯誤
worker.postMessage(setTimeout);
//→未捕獲的DataCloneError

我們可以看到,當我們嘗試將函式傳遞給postMessage()時會出現一些問題。這種資料型別一旦到達worker執行緒就無法重建,因此,postMessage()只能丟擲異常。這些型別的限制可能看起來過於侷限,但它們確實消除了許多可能出現的併發問題。

接收來自workers的訊息

如果沒有將資料傳回主執行緒的能力,workers對我們來說就沒什麼用了。在某些時候,workers執行的任務需要顯示在UI中。我們可能還記得,worker例項是事件物件。這意味著我們可以監聽訊息事件,並在workers發回資料時做出相應的響應。可以將此視為向workers傳送資料的反向。workers通過向主執行緒傳送訊息將主執行緒視為另一個workers執行緒,而主執行緒則偵聽訊息。我們在上一節中探討的序列化限制在這裡也是一樣的。

讓我們看一下將訊息傳送回主執行緒的一些worker程式碼:

//2秒後,使用“postMessage()”函式將一些資料發回給主執行緒。
setTimeout(() => {
    postMessage('hello world');
}, 2000);

我們可以看到,這個worker啟動了,2秒後,將一個字串傳送回主執行緒。現在,讓我們看看如何在主JavaScript環境中處理這些傳入的訊息:

//啟動一個worker執行緒。
var worker = new Worker('worker.js');

//為“message”物件新增一個事件偵聽器,
//注意“data”屬性包含實際的訊息資料,
//與傳送訊息給workers的方式相同。
worker.addEventListener('message', (e) => {
    console.log('from worker', `"$ {e.data}"`);
});

您可能已經注意到我們沒有顯式終止任何worker執行緒。這沒關係。當瀏覽上下文終止時,所有活動工作
執行緒都將終止。我們也可以使用terminate()方法顯式的終止worker,這將顯式停止執行緒而無需等待任何
現有程式碼執行完成。但是,很少去顯式終止worker。一旦建立,workers通常在頁面整個生命週期記憶體活。
生成worker不是免費的,它會產生開銷,所以如果可能的話,我們應該只做一次。

共享應用狀態

在本節中,我們將介紹共享workers。首先,我們將瞭解多個瀏覽上下文如何訪問記憶體中的相同資料物件。然後,我們將介紹如何獲取遠端資源,以及如何通知多個瀏覽上下文有關新資料的返回。最後,我們將瞭解如何利用共享workers來允許瀏覽上下文之間的直接訊息傳遞。

考慮下本節用於實驗編碼的高階特性。瀏覽器對共享workers的支援目前還不是很好(只有Firefox和Chrome)。
Web worker仍處於W3C的候選推薦階段。一旦他們成為推薦併為共享workers提供了更好的瀏覽器支援,
我們就可以使用它們了。對於額外的意義,當服務workers規範成熟,共享Worker能力將更加重要。

共享記憶體

到目前為止我們已經看到了Web workers的序列化機制,因為我們不能直接從多個執行緒引用同一個物件。但是,共享worker的記憶體空間不僅限於一個頁面,這意味著我們可以通過各種訊息傳遞方法間接訪問記憶體中的這些物件。實際上,這是一個展示我們如何使用埠傳遞訊息的好機會。讓我們來看看吧。

埠的概念對於共享worker是很必要的。沒有它們,就沒有管理機制來控制來自共享worker的訊息的流入和流出。例如,假設我們有三個頁面使用相同的共享worker,那麼我們必須建立三個埠來與該workers通訊。將埠視為workers通往外部世界的入口。這是一個小的間接的過程。

這是一個基本的共享worker,讓我們瞭解設定這些型別的workers所涉及的內容:

//這是連線到worker的頁面之間的共享狀態資料
var connections = 0;

//偵聽連線到此worker的頁面,
//我們可以設定訊息埠。
addEventListener('connect', (e) => {
    //“source”屬性代表由連線到這個worker頁面建立的訊息埠,
    //我們實際上要通過呼叫“start()”建立連線。
    e.source.start();
});

//我們將訊息發回頁面,資料是更新的連線數。
e.source.postMessage(++connections);

一旦頁面與此worker連線,就會觸發一個connect事件。該connect事件具有一個source屬性,這是訊息埠。我們必須通過呼叫start()來告訴這個worker已準備開始與它通訊。請注意,我們必須在埠上呼叫postMessage(),而不是在全域性上下文中呼叫。worker怎麼知道要將訊息傳送到哪個頁面?該埠充當worker和頁面之間的代理,如下圖所示:

現在讓我們看看如何在多個頁面中使用這個共享worker:

//啟動共享worker。
var worker = new SharedWorker('worker.js');

//設定“message”事件處理程式。
//通過連線共享worker,我們實際上是在建立一個訊息
//傳送到訊息傳遞埠。
worker.port.addEventListener('message', (e) => {
    console.log('connections made', e.data);
});

//啟動訊息傳遞埠,
//表明我們是準備開始傳送和接收訊息。
worker.port.start();

這個共享worker和專用worker之間只有兩個主要區別。它們如下:

• 我們有一個port物件,我們可以通過釋出訊息和附加事件監聽器來與worker通訊。

• 我們告訴worker我們已準備好通過呼叫埠上的start()方法來啟動通訊,就像worker一樣。

將這兩個start()呼叫視為共享worker與其客戶端之間的握手。

獲取資源

前面的示例讓我們瞭解了來自同一應用程式的不同頁面如何共享資料,從而無需在載入頁面時分配兩次完全相同的結構。讓我們以這個方法為基礎,使用共享worker來獲取遠端資源,以便與任何依賴它的頁面共享返回的結果。這是worker執行緒程式碼:

//我們儲存連線頁面的埠,
//以便我們可以廣播訊息。
var ports = [];

//從API獲取資源。
function fetch() {
    var request = new XMLHttpRequest();
    
    //當介面響應時,我們只需解析JSON字串一次,
    //然後將它廣播到所有埠。
    request.addEventListener('load', (e) => {
        var resp = JSON.parse(e.target.responseText);
        for (let port of ports) {
            port.postMessage(resp);
        }
    });

    request.open('get', 'api.json');
    request.send();
}

//當一個頁面連線到這個worker時,
//我們儲存到“ports”陣列,
//以便worker可以持續跟蹤它。
addEventListener('connect', (e) => {
    ports.push(e.source);
    e.source.start();
});

//現在我們可以“poll”API,並廣播結果到所有頁面。
setInterval(fetch, 1000);

我們只是在ports陣列中儲存對它的引用,而不是在頁面連線到worker時響應埠。這就是我們如何跟蹤連線到worker頁面的方式,這很重要,因為並非所有訊息都遵循命令響應模式。在這種情況下,我們希望將更新的API資源廣播到正在監聽它的所有頁面。一個常見的情況是在同一個應用程式,如果有許多瀏覽器選項卡開啟檢視同一個頁面,我們可以使用相同的資料。

例如,如果API資源是一個很大的JSON陣列需要被解析,如果三個不同的瀏覽器選項卡解析完全相同的資料,則會很浪費資源。另一個好處是我們不會輪詢API 3次,如果每個頁面都執行自己的輪詢程式碼就會是這種情況。當它在共享worker上下文中時,它只發生一次,並且資料被分發到連線的頁面。這對後端的負擔也較少,因為總體而言,發起的請求要少得多。我們現在來看看這個worker使用的程式碼:

//啟動共享worker
var worker = new SharedWorker('worker.js');

//監聽“message”事件,
//並列印從worker發回的任何資料。
worker.port.addEventListener('message', (e) => {
    console.log('from worker', e.data);
});

//通知共享worker我們已經準備好了開始接收訊息
worker.port.start();

在頁面間進行通訊

到目前為止,我們已經處理過以共享worker中的資料為中心的資料資源。也就是說,它來自於一個集中的地方,比如作為一個API,隨後頁面通過連線worker來讀取資料。我們實際上沒有從頁面修改任何的資料。例如,我們甚至沒有連線到後端,連線共享worker的頁面也沒有產生任何資料。現在其他頁面都需要知道這些改變。

但是,讓我們說使用者切換到其中一個頁面並進行一些調整。我們必須支援雙向更新。讓我們來看看如何使用共享worker來實現這些功能:

//儲存所有連線頁面的埠。
var ports = [];

addEventListener('connect', (e) => {

    //收到的訊息資料被分發給任何連線到此worker的頁面。
    //頁面程式碼邏輯決定如何處理資料。
    e.source.addEventListener('message', (e) => {
        for (let port of ports) {
            port.postMessage(e.data);
        }
    });
});

//儲存連線頁面的埠引用,
//使用“start()”方法開始通訊。
ports.push(e.source);
e.source.start();

這個worker就像是一顆衛星; 它只是將收到的所有內容傳輸到已連線的埠。這就是我們所需要的,為什麼還需要更多?我們來看看連線到這個worker的頁面程式碼:

//啟動共享worker,
//並儲存我們正在使用的UI元素的引用。
var worker = new SharedWorker('worker.js');
var input = document.querySelector('input');

//每當輸入值改變時,傳送輸入值資料
//到worker以供其他需要的頁面使用。
input.addEventListener('input', (e) => {
    worker.port.postMessage(e.target.value);
});

//當我們收到輸入資料時,更新我們文字輸入框的值,
//也就是說,除非值已經更新。
worker.port.addEventListener('message', (e) => {
    if (e.data !== input.value) {
        input.value = e.data;
    }
});

//啟動worker開始通訊。
worker.port.start();

有趣!現在,如果我們繼續開啟兩個或更多瀏覽器選項卡,我們對輸入值的任何更改都將立即反映在其他頁面中。這個設計的優點在於它的表現一致; 無論哪個頁面執行更新,任何其他頁面都會收到更新的資料。換句話說,這些頁面承擔著資料生產者和資料消費者的雙重角色。

您可能已經注意到,最後一個示例中的worker向所有埠傳送訊息,包括髮送訊息的埠。我們肯定不想這樣做。
為避免向傳送方傳送訊息,我們需要以某種方式排除for..of迴圈中的傳送埠。

這實際上並不容易,因為訊息事件物件沒有與一起傳送埠的識別資訊。我們可以建立埠識別符號並使​​訊息包含ID。
這裡需要有很多工作,好處並不是那麼好。這裡的併發設計 - 只是簡單地檢查頁面程式碼,該訊息實際上與頁面相關。

通過子workers執行子任務

我們在本章中建立的所有workers - 專用workers和共享workers - 都是由主執行緒生成的。在本節中,我們將討論子workers。它們與專用worker相似,只是建立者不同。例如,子worker不能直接與主執行緒互動,只能通過產生子workers的代理進行互動。

我們將看看將較大的任務劃分為較小的任務,並且我們還將看看圍繞子worker的一些挑戰性問題。

將工作分為任務

我們的Web worker的工作是以這樣的方式執行任務,即主執行緒可以繼續服務於一些事情,例如DOM事件,而不會中斷。對於Web worker執行緒來說,某些任務很簡單。它們接受輸入,計算結果,並將結果作為輸出返回。但是,如果任務很複雜,該怎麼辦?如果它涉及許多較小的分散步驟,需要我們將較大的任務分解為較小的任務,該怎麼辦?

像這些任務,通過將它們分解為更小的子任務是有意義的,這樣我們就可以進一步利用所有可用的CPU。然而,將任務分解為較小的任務本身會導致嚴重的效能損失。如果任務分解放在主執行緒中,我們的使用者體驗可能會受到影響。我們在這裡使用的一種技術涉及啟動一個Web worker,其工作是將任務分解為更小的步驟,併為每個步驟啟動子worker。

讓我們建立一個在陣列中搜索指定項的worker,如果該項存在則返回true。如果輸入陣列很大,我們會將它分成幾個較小的陣列,每個陣列都是並行搜尋的。這些並行搜尋任務將作為子worker建立。首先,我們來看看子worker:

//偵聽傳入的訊息。
addEventListener('message', (e) => {
    
    //將結果發回給worker。
    //我們在輸入陣列上呼叫“indexOf()”,尋找“search”資料。
    postMessage({
        result: e.data.array.indexOf(e.data.search) > -1
    });
});

所以,我們現在有一個子worker可以獲取一個數組的塊並返回一個結果。這很簡單。現在,對於棘手的部分,讓我們實現將輸入陣列劃分為較小輸入的worker,然後將其輸入子worker。

addEventListener('message', (e) => {

    //我們將要分成4個較小塊的陣列。
    var array = e.data.array;

    //大致計算陣列四分之一的大小,
    //這將是我們的塊大小。
    var size = Math.floor(0.25 * array.length);

    //我們正在尋找的搜尋資料。
    var search = e.data.search;

    //用於在下面的“while”迴圈將陣列分成塊。
    var index = 0;

    //一旦被切片,我們的塊就會去執行。
    var chunks = [];

    //我們需要儲存對子worker的引用,
    //這樣我們可以終止它們。
    var workers = [];

    //這用於統計從子workers返回的結果數
    var results = 0;

    //將陣列拆分為按比例大小的塊。
    while (index < array.length) {
        chunks.push(array.slice(index, index + size));
        index += size;
    }

    //如果還有剩下的(第5塊),
    //把它放到它之前的塊中。
    if (chunks.length> 4) {
        chunks[3] = chunks[3].concat(chunks[4]);
        chunks = chunks.slice(0, 4);
    }

    for (let chunk of chunks) {
        
        //啟動我們的子worker並在“workers”中儲存它的引用。
        let worker = new Worker('sub-worker.js');
        workers.push(worker);

        //當子worker有返回結果時。
        worker.addEventListener('message', (e) => {
            results++;

            //結果是“truthy”,我們可以傳送一個響應給主執行緒。
            //否則,我們檢查是否全部子workers都返回了。
            //如果是這樣,我們可以傳送一個false返回值。
            //然後,終止所有子workers。
            if (e.data.result) {
                postMessage({
                    search: search,
                    result: true
                });
                
                workers.forEach(x => x.terminate());
            } else if (results === 4) {
                postMessage({
                    search: search,
                    result: false
                });

                workers.forEach(x => x.terminate());
            }
        });

        //為worker提供一大塊陣列進行搜尋。
        worker.postMessage({
            array: chunk,
            search: search
        });
    }
});

這種方法的優點是,一旦我們得到了正確的結果,我們就可以終止所有現有的子worker。因此,如果我們執行一個特別大的資料集,就可以避免讓一個或多個子worker在後臺進行不必要的運算。

我們在這裡採用的方法是將輸入陣列切成四個比例(25%)的塊。這樣,我們將併發級別限制為四級。在下一章中,我們將進一步討論細分任務和技巧,以確定要使用的併發級別。

現在,讓我們通過編寫一些程式碼在頁面上使用這個worker以完成示例:

//啟動worker...
var worker = new Worker('worker.js');

//生成一些輸入資料,一個數字0 - 1041陣列。
var input = new Array(1041).fill(true).map((v, i) => i);

//當worker返回時,顯示我們搜尋的結果。
worker.addEventListener('message', (e) => {
    console.log(`${e.data.search} exists?`, e.data.result);
});

//搜尋一個存在的項。
worker.postMessage({
    array: input,
    search: 449
});
//→449存在?真

//搜尋一個不存在的項。
worker.postMessage({
    array: input,
    search: 1045
});
//→1045存在?假

我們能夠與worker通訊,傳遞輸入陣列和資料進行搜尋。結果傳遞給主執行緒,它們包含搜尋詞,因此我們能夠通過傳送給worker執行緒的原始訊息對輸出進行協調。然而,這裡有一些困難需要克服。雖然這非常有用,能夠細分任務以更好地利用多核CPU,但涉及到很多複雜性。一旦我們得到每個子worker的結果,我們就必須進行協調。

如果這個簡單的例子可以變得像它一樣複雜,那麼想象一下大型應用程式的上下文中的類似程式碼。我們可以從兩個角度解決這些併發問題。首先是關於併發的前期設計挑戰。這將在下一個章節解決。然後,還有是同步挑戰,我們如何避免回撥地獄?這個話題比較深,將在“第7章,抽取併發邏輯”討論。

提醒一下

雖然前面的示例是一種強大的併發技術,可以提供很大的效能提升,但還有一些問題需要注意。因此,在深入涉及子worker的實現之前,請考慮其中的一些挑戰以及必須做出的權衡。

子workers沒有一個父頁面來直接通訊。這是一個複雜的設計,因為即使一個來自子worker簡單響應也需要子worker通過代理從而在執行的JavaScript主執行緒進行建立。而這樣做得到的是一堆讓人困惑的通訊過程。換句話說,它很容易導致複雜化的設計,因為要通過比實際上需要的更多元件來完成。所以,在決定使用子workers作為設計選項之前,讓我們看看是否可以只依賴於專用worker來實現。

第二個問題是,由於Web worker仍然是候選推薦的W3C規範,並非所有瀏覽器都能一致的實現Web worker的所有功能。共享workers和子workers是我們可能遇到跨瀏覽器問題的兩個部分。另一方面,專用workers具有很好的瀏覽器支援,並且在大部分瀏覽器中表現一致。再一次說明,從簡單的專用worker設計開始,如果這不滿足需要,再考慮引入共享workers和子workers。

Web workers中的錯誤處理

本章中的所有程式碼都假設我們的worker程式中執行的程式碼不會出錯。顯然,我們的workers會遇到異常被丟擲的情況,或者是我們在開發過程中編寫有bug的程式碼 - 這是我們作為程式設計師所必須面臨的事實。但是,如果沒有適當的錯誤事件處理程式,Web worker可能很難除錯。我們可以採取的另一種方法是顯式發回一條訊息,標識自己已經出錯。我們將在本節中介紹兩個錯誤處理話題。

錯誤條件檢查

假設我們的主應用程式程式碼向worker執行緒傳送訊息,並期望得到一些返回結果。如果出現問題,那麼等待資料的程式碼需要知道該怎麼辦?一種可能性是仍然傳送主執行緒期望的訊息; 只是它有一個欄位表示操作錯誤的狀態。下圖讓我們瞭解下它是怎麼樣的:

現在讓我們看一下實現這種方法的程式碼。首先,worker確定訊息返回成功或錯誤狀態:

//當訊息返回時,檢查提供的訊息資料是否是一個數組。
//如果不是,返回一個設定了“error”屬性的資料。
//否則,計算並返回結果。
addEventListener('message', (e) => {
    if(!Array.isArray(e.data)) {
        postMessage({
            error: 'expecting an array'
        });
    } else {
        postMessage({
            error: e.data[0]
        });
    }
});

該worker總是會通過傳送一個訊息進行響應,但它並不總是返回一個計算結果。首先,它會檢查,以確保該輸入值是可以接受的。如果沒有得到期望的資料,它傳送一個附加錯誤狀態的訊息。否則,它正常的傳送返回結果。現在,讓我們編寫一些程式碼來使用這個worker:

//啟動worker
var worker = new Worker('worker.js');

//監聽來自worker的訊息。
//如果收到錯誤,我們會記錄錯誤資訊。
//否則,我們記錄成功的結果。
worker.addEventListener('message', (e) => {
    if (e.data.error) {
        console.error(e.data.error);
    } else {
        console.log('result', e.data.result);
    }
});

worker.postMessage([3, 2, 1]);
//→result 3

worker.postMessage({});
//→expecting an array

異常處理

即使我們在上一個示例中明確檢查了workers程式中的錯誤情況,也可能會丟擲異常。從我們的主應用程式執行緒的角度來看,我們需要處理這些未捕獲型別的錯誤。如果沒有適當的錯誤處理機制,我們的Web workers將悄然無聲地失敗。有時候,workers甚至都不載入 - 遇到這種悄無聲息的程式碼除錯。

我們來看一個偵聽Web worker error事件的示例。這是一個Web worker嘗試訪問不存在的屬性:

//當一個訊息陣列返回時,
//傳送一個包含的“name”屬性輸入資料作為響應,
//如果資料沒有定義怎麼辦?
addEventListener('message', (e) => {
    postMessage(e.data.name);
});

這裡沒有錯誤處理程式碼。我們所做的只是通過讀取name屬性並將其發回來作為響應訊息。讓我們看一下使用這個worker的一些程式碼,以及它如何響應這個worker中引發的異常:

//啟動我們的worker
var worker = new Worker('worker.js');

//監聽從worker發回的訊息,
//並列印結果資料。
worker.addEventListener('message', (e) => {
    console.log('result', `"${e.data}"`);
});

//監聽從worker發回的錯誤,
//並列印錯誤訊息。
worker.addEventListener('error', (e) => {
    console.error(e.message);
});

worker.postMessage(null);
//→Uncaught TypeError:Cannot read property "name" of null

worker.postMessage({name: 'JavaScript'});
//→result "JavaScript"

在這裡,我們可以看到的是第一個釋出訊息的worker導致異常被丟擲。然而,此異常被封裝在worker內部,它不是丟擲在我們的主執行緒。如果我們在主執行緒監聽error事件,我們就可以做出相應的響應。在這裡,我們只是列印錯誤訊息。然而,在其他情況下,我們可能需要採取更復雜的糾正措施,例如釋放資源或傳送一個不同的訊息給worker。

小結

在本章中,我們介紹了使用Web worker併發執行的概念。在Web worker之前,我們的JavaScript無法利用當今硬體上的多核CPU。

我們首先對Web worker進行了大致的概述。它們是作業系統級的執行緒。從JavaScript的角度來看,它們是可以傳送訊息和監聽message事件的事件物件。Web worker主要分為三種 - 專用workers,共享workers和子workers。

然後,學習瞭如何通過傳送訊息和監聽事件來與Web worker進行通訊。並且瞭解到,在訊息中傳遞的內容方面存在限制。這是因為所有訊息資料都在目標執行緒中被序列化和重建。

我們以如何處理Web worker中的錯誤和異常來結束本章。在下一章中,我們將討論併發的實際應用 - 我們應該使用並行執行的任務型別,以及實現它的最佳方法