Web Worker 性能優化初體驗
背景
近期在做用戶回放系統需求,其中有一環是從 indexedDB 中讀取日誌並做上報。然而,由於日誌的數據量太大,計算處理 indexedDB 的數據比較耗時,容易造成阻塞,導致用戶瀏覽器卡頓。爲了解決這個問題,我們想了幾種優化手段:
其中,由於沒有實踐的經驗,使用 Web Worker 的時候也踩了一些坑。在這裏對 Web Worker 的使用做一個小結。
基本介紹
我們都知道,JavaScript 是單線程的,也就是一次只能做一件事。所以,當一些低優先級但是耗時的任務 (日誌處理) 正在執行時,一些高優先級的任務 (業務相關) 就只能等着,可能導致 UI 交互不流暢,瀏覽器出現卡頓的情況,對於 CPU 來說,JS 單線程的帶來的不便就更加明顯了。
而 Web Worker 的出現,爲 JavaScript 創造了多線程的環境。(ps:這裏並不是說 JS 本身支持了多線程的能力,只是瀏覽器作爲宿主環境提供了 JS 一個多線程運行的環境)
W3C 定義:A web worker is a JavaScript that runs in the background, independently of other scripts, without affecting the performance of the page. You can continue to do whatever you want: clicking, selecting things, etc., while the web worker runs in the background.
在項目中,我們可以將一些複雜的計算任務分配給 Worker 運行,讓主線程專注於 UI 交互相關的任務,Worker 線程和主線程互不干擾,這樣用戶使用起來就會比較流暢,不會有卡頓之感。
使用方法
由於主線程和 Worker 線程不在同一個上下文中,他們使用數據通信的方式交互,通過 postMessage
發送消息、監聽 message 事件接收消息(可以通過 addEventListener
或 onmessage
這兩個 API)。
主線程
// 創建一個 Worker 線程,用於上報數據,傳入這個 Worker 對應的腳本文件
const worker = new Worker('reportWorker.ts');
// 主線程向 Worker 線程發送消息,讓 Worker 線程從 indexedDB 讀取 count 條數據
worker.postMessage({ type: WorkerReportType.ReadEventTblStart, data: count });
// 主線程監聽來自 Worker 的消息
worker.onmessage = (event: MessageEvent) => {
const { type, data } = event.data;
// 對不同類型的消息做不同處理
switch (type) {
case WorkerReportType.ReadEventTblFinish:
console.log('從worker中接收的數據', data);
// ...
break;
case ...
}
}
Worker 線程
// Worker 監聽來自主線程的消息
self.onmessage = (event: MessageEvent) => {
const { type, data } = event.data;
// 對不同類型的消息做不同處理
switch (type) {
case WorkerReportType.ReadEventTblStart:
// 讀取、處理日誌數據
readIndexedDB();
break;
case ...:
}
}
// Worker 向主線程發送消息
self.postMessage({ type: WorkerReportType.ReadEventTblFinish, data: result });
除了發送和接收消息這兩種最常用的 API,還可以監聽 Worker 線程的錯誤:
worker.onerror((event: MessageEvent) => {
console.log('worker error');
})
在 Worker 使用完畢時,應及時關閉:
// 主線程
worker.terminate();
// worker線程
self.close();
數據通信
雖然在 Worker 線程進行一些複雜的運算不會對主線程有影響,但如果主線程和 Worker 之間通信時,傳輸的數據量太大(比如 5-10MB,甚至更大),會不會對主線程的性能有影響呢?
拷貝傳輸
首先,我們瞭解一下主線程和 Worker 之間的默認數據傳輸方式,當像剛剛提到的基本用法那樣使用 postMessage
時,數據的通信是一種拷貝的關係,瀏覽器內部會先將內容序列化,發送給接收方,接收方再將其還原。因此,當我們傳輸一個 100MB 的數據時,會由於拷貝而增加一份內存消耗,複製的時間也會隨數據量增加而增加。通過這樣一段代碼,我們模擬線性增大傳輸數據量:
// Worker 中發送數據
for (let i = 0; i <= 50; i += 5) {
const mockData = new Uint8Array(1024 * 1024 * i);
const start = Date.now();
tasks.postMessage({ type: ReadEventTblFinish, data: { mockData, size: i, start } });
}
// 主線程接收數據
...
const now = Date.now();
const { size, start } = data;
const time = now - start;
console.log(`post message end, 大小:${size}MB, 耗時 ${time}ms`);
Chrome 瀏覽器輸出的結果如下:
可以看到,傳輸二進制數據時,傳輸時間基本是隨着傳輸數據大小線性增加的。
使用 Transferable 對象傳輸
爲了解決拷貝傳輸的問題,postMessage
這個還有第二個參數:transferableList
,即一個可轉移對象的列表。JavaScript 與 Worker 通信的時候,直接將對象轉移給接收方,一旦轉移,發送方就再也無法使用這些二進制數據。
我們只需要在 postMessage
的時候指定一下可轉移對象:
tasks.postMessage({ type: ReadEventTblFinish, data: { mockData, size: i, start } }, [mockData.buffer]);
Chrome 瀏覽器輸出的結果如下:
可以看到通過這種方法,數據傳輸的耗時大大減少了。再打印一下 postMessage
之後的 mockData
:
數據爲空,說明控制權確實被轉移了,Worker 裏再也無法使用這份數據了。
然而,使用 transferableList
有兩個需要注意的地方:
-
目前,實現了
Transferable
接口的只有:ArrayBuffer
、MessagePort
、ImageBitmap
。也就是說,如果我們傳輸的是 JS 對象,需先將其轉換爲 ArrayBuffer,否則會報錯。而如果對象本身很龐大,數據格式轉換的時間也會隨之增大,是否有必要爲了減少 Worker 通信時間而增加數據格式轉換時間還需要權衡。 -
當我們使用
TransferableList
傳輸對象時,瀏覽器會幫我們完成Transferable
對象到對應的數據成員(postMessage 的第一個參數中)之間的映射。因此,如果我們的數據集中於少數變量中,那麼可以放心地使用Transferable
來傳輸。但如果transferable
數據分散於成百上千個元素中,這個解析映射的時間就會比較久,使用Transferable
對象傳輸反而會有比較明顯的性能問題。
Shared Array Buffers
默認情況下,Worker 之間、主線程與 Worker 都不會共享內存,但使用 SharedArrayBuffer,兩個線程都可以在同一塊內存中讀寫數據。共享內存,也就意味着沒有傳輸延遲和開銷。
然而,這也會帶來衝突和競爭的問題,而且當前瀏覽器對這個特性的支持情況也比較差,因此建議不要使用這種方式。
使用 Promise 封裝 Worker 通信
目前,使用 postMessage
和 onmessage
這兩個 API,我們確實能實現通信的目的。但看看代碼結構:
主線程向 Worker 發送消息:
-
消息一旦發送,我們沒有辦法追蹤,只能通過監聽 Worker 對應的 message。
-
主線程和 Worker 每發送一種消息,就要新增一個 type 類型,且兩者沒有對應關係。
-
事件處理的入口和結果是分離的,不利於代碼的閱讀。比如說:A 同學要理解從 indexedDB 讀取數據,處理後發送回主線程這個流程,他需要經歷以下幾個步驟:
-
首先找到主線程的入口,主線程
postMessage
發送了ReadEventTblStart
的信號; -
到 Worker 對應的代碼中找到
onmessage
時對應的處理方法; -
Worker 處理完後給主線程發了一個
ReadEventTblFinish
的信號; -
回到主線程對應的代碼,找到
onmessage
時對應事件的處理;
class PromiseWorker {
private worker: Worker;
constructor(worker: Worker) {
this.worker = worker;
}
}
由於我們只能通過 postMessage
和 onmessage
發送和接收信息,所以我們需要一個 map 將發送消息和收到消息後回調映射起來:
// 這裏我用number類型的type變量作爲key值,實際上這個key值只要唯一即可
private handlerMap: Map<number, Function> = newMap();
封裝 postMessage
,每次發送消息時,在 map 中添加一條映射,以供返回時轉換 Promise 的狀態:
postMessage(message: WorkerMessage) {
const { type } = message;
returnnewPromise((resolve) => {
this.worker.postMessage(message);
this.handlerMap.set(type, resolve);
});
}
接收消息時,根據和發送消息對應的 type 值,取出 resolve
函數:
this.worker.onmessage = (event: MessageEvent) => {
const { type, data } = event.data;
const resolve = this.handlerMap.get(type);
if (!resolve) {
return;
}
resolve(data);
this.handlerMap.delete(type);
};
一個完整的 PromiseWorker 類:
exportdefaultclass PromiseWorker {
private handlerMap: Map<number, Function> = newMap();
private worker: Worker;
constructor(worker: Worker) {
this.worker = worker;
this.worker.onmessage = (event: MessageEvent) => {
const { type, data } = event.data;
const resolve = this.handlerMap.get(type);
if (!resolve) {
return;
}
resolve(data);
this.handlerMap.delete(type);
};
}
postMessage(message: WorkerMessage) {
const { type } = message;
returnnewPromise((resolve) => {
this.worker.postMessage(message);
this.handlerMap.set(type, resolve);
});
}
}
使用方式:
/** 主線程 */
// 實例化一個PromiseWorker
const reportWorker = new PromiseWorker(new ReportWorker());
// 調用封裝好的postMessage
reportWorker.postMessage({ type: WorkerReportType.ReadEventTbl, data: count }).then((data) => {
console.log('read event table finish', data);
});
/** worker線程 */
// 收到消息,計算處理完畢後,發送同一個type即可
self.onmessage = async (event: MessageEvent) => {
const { type, data } = event.data;
// 對不同類型的消息做不同處理
switch (type) {
case WorkerReportType.ReadEventTbl:
// 讀取、處理日誌數據
const result = await ...
// 回覆(發送同樣的type)
self.postMessage({ type, data: result });
break;
case ...
}
}
這樣簡單的實現一個 Promise 化的 Worker,在主線程上,我們就能專注於業務實現,而不必關心發送消息和接收消息的對應關係。
Web Worker 的侷限性
-
DOM 操作限制 Worker 線程和主線程的
window
是不在一個全局上下文中運行的,因此我們無法在 Worker 中訪問到document、window、parent
這些對象,也不能訪問 DOM 元素。但是,可以獲取navigator、location
對象。這跟 JavaScript 被設計成單線程也是有關係的,試想多個線程同時對同一個 DOM 操作,就會出現衝突。 -
數據通信限制 Worker 和主線程的通信可以傳遞對象和數組,他們是通過拷貝的形式傳遞的,這意味着,我們不能傳遞不能被序列化的數據,比如說函數,否則會報錯。
-
無法訪問 localStorage。
-
同源限制 分配給 Worker 線程運行的腳本文件,需要和主線程的腳本文件同源。
-
腳本限制 Worker 線程不能執行
alert、confirm
,但是可以獲取setTimeout、XMLHttpRequest
等瀏覽器 API。 -
文件限制 爲了安全,Worker 線程無法讀取本地文件,即不能打開本機的文件系統(
file://
),它所加載的腳本必須來自網絡,且需要與主線程的腳本同源。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/IJHI9JB3nMQPi46b6yGVWw