本文是我翻譯《JavaScript Concurrency》書籍的第五章 使用Web Workers,該書主要以Promises、Generator、Web workers等技術來講解JavaScript併發編程方面的實踐。 完整書籍翻譯地址: "https://github.com/yzsunle ...
本文是我翻譯《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中的錯誤和異常來結束本章。在下一章中,我們將討論併發的實際應用 - 我們應該使用並行執行的任務類型,以及實現它的最佳方法。