開始之前,代碼在這里。歡迎各位大神指導(dǎo)。
在 Web Worker 之前,解析 CSS,生成布局,繪制界面以及運行 javascript 腳本都運行在瀏覽器的一個線程里。如果一個 Web App 運行的 js 腳本一次運行時間過長,就會出現(xiàn)界面卡頓。這樣的用戶體驗是沒法用及格來評價的。
在多核普及的當(dāng)下,瀏覽器也大多支持了 Web Worker。這讓前端開發(fā)有了更多的選擇,使用 web worker 來實現(xiàn)真正的多線程。所有阻礙、延遲用戶反饋的操作都可以移入一個后臺運行的線程中。
如何發(fā)現(xiàn)性能瓶頸
筆者主要使用 React 開發(fā),所以首先聊一下 React 中如何發(fā)現(xiàn)性能出現(xiàn)問題的地方。在 React16.9 中新增了 Profiler API,使用起來也非常簡單,具體可以查看文檔((https://reactjs.org/docs/profiler.html))。
更通用一點的可以使用 chrome 的lighthouse??梢园惭b插件 lighthouse 插件,或者也可以直接打開開發(fā)面板的audits點擊run audits,就能看到報表了。console.time和console.timeEnd組合。
但是,以上只適用于開發(fā)模式下使用。在生產(chǎn)上使用多多少少會給產(chǎn)品本身帶來額外的資源消耗。一般來說,app 都會有埋點,在埋點點時候如何順道達成性能消耗點記錄就需要具體問題具體分析了。
Web Worker
使用 Web Worke 讓阻塞代碼在后臺運行,自然不會阻塞 UI 線程(main thread)。在例子中所用到的是typescript版本的代碼,所有后面如果有必要會給出在 typescript 實現(xiàn)的代碼和相應(yīng)的說明。配置一類的文件請直接移步到代碼目錄查看,這里就不多說了。
在 Worker 的部分使用了webpack + worker-loader的方式。worker-loader的具體內(nèi)容可以參考這里。
創(chuàng)建一個 Worker
創(chuàng)建一個 Worker 非常的簡單,只需要把一段命名腳本傳給Worker構(gòu)造函數(shù)就可以。比如 MDN 的一段:
這是 Worker 腳本:
// worker.js
self.onmessage = event => {
console.log("Message received", event.data);
self.postMessage("Worker done");
};
在 typescript 里,首先需要處理 Worker 的上下文的問題,否則tsc編譯不過。
const ctx: DedicatedWorkerGlobalScope = self as any;
ctx.onmessage = (event: MessageEvent) => {
//...
ctx.postMessage("done");
// Close the worker when jobs done
ctx.close();
};
ctx.onerror = (event: ErrorEvent): any => {
console.error("Error in worker", event.message);
ctx.close();
};
export default null as any;
注意:這里需要使用DedicatedWorkerGlobalScope不能直接食欲哦那個Worker,因為Worker的定義里面沒有close方法。這是因為close方法deprecated?
TS2339: Property 'close' does not exist on type 'Worker'.
還有在創(chuàng)建 Worker 的最后,需要一個export語句:
export default null as any;
創(chuàng)建 Worker:
// Main thread
var myWorker = new Worker("worker.js");
myWorker.postMessage([first.value, second.value]);
myWorker.onmessage = function(e) {
result.textContent = e.data;
console.log("Message received from worker");
};
Typescript:
import SimpleWorker from "./simple.worker";
const worker = new SimpleWorker();
兩個線程之間(上例是 UI thread 和一個 worker)可以通過postMessage和onmessage或者(addEventListener('message', () => {})的方式來傳遞消息。
線程之間的通信是基于事件的。那么錯誤的處理也是同樣道理,例如:
// UI thread
var myWorker = new Worker("worker.js");
myWorker.onerror = function() {
console.log("There is an error with your worker!");
};
// Inside worker
self.onerror = err => {
console.error("Error in worker", err);
};
引入外部腳本
importScripts(); /* imports nothing */
importScripts("foo.js"); /* imports just "foo.js" */
importScripts("foo.js", "bar.js"); /* imports two scripts */
importScripts(
"http://example.com/hello.js"
); /* You can import scripts from other origins */
注意:下載順序可以是任意順序,但是執(zhí)行的順序是按照腳本在importScripts方法里出現(xiàn)的順序。
因為使用了worker-loader,在引入外部代碼的時候,和一般的import差不多:
import { ab2str, str2ab } from "./lib/utils"; // 引入內(nèi)部依賴
import * as _ from "lodash"; // 引入外部依賴
ctx.onmessage = (event: MessageEvent) => {
// ...
const dataStr = ab2str(dataBuff); // 使用內(nèi)部依賴
// ...
const target = JSON.parse(dataStr || '[]');
const v = _.get(target, 'a.b', 'N/A'); // 使用外部依賴
// ...
關(guān)閉一個 Worker
Worker 也占用和消耗資源,所以在不用的時候就要關(guān)閉它。
關(guān)閉一個 Worker 有兩種方法:一種是直接在 UI thread 里面使用terminate方法,一種是在 Worker 的內(nèi)部調(diào)用close方法。
// In main thread
const worker = new Worker("myworker.js");
// If it's the time to terminate a worker
worker.terminate();
在調(diào)用了terminate方法之后,Worker 會被立刻終止,即使是還在運行中的也是一樣。但是一般情況下還是希望在 Worker 執(zhí)行完成之后才去關(guān)閉。這個時候就要用到 Worker 的close方法。
// In a worker
self.onmessage = event => {
self.close();
};
如上文所說,close方法就要被廢棄了,現(xiàn)在是在deprecated的狀態(tài)。具體看 MDN 的這里
要被廢棄是因為,在一個 worker 出了作用域之后就會被回收。所以有沒有close這個方法并沒有太大的必要。
Inline Worker
Worker 的創(chuàng)建需要得到腳本的 URL 地址。一般情況下,這段腳本是放在 server 上的。這就需要網(wǎng)絡(luò)的傳輸。如果只是一個簡單的需要放到后臺執(zhí)行的腳本,如果可以打包到一起直接發(fā)布到客戶瀏覽器會節(jié)省很多的時間。這個時候就需要 inline Worker。
它的創(chuàng)建也很簡單,并沒有什么特別的地方。只是在獲得 URL 的時候使用了Blob這個工具,如:
// URL.createObjectURL
window.URL = window.URL || window.webkitURL;
// "Server response", used in all examples
var response = "self.onmessage=function(e){postMessage('Worker: '+e.data);}";
var blob;
try {
blob = new Blob([response], { type: "application/javascript" });
} catch (e) {
// Backwards-compatibility
window.BlobBuilder =
window.BlobBuilder || window.WebKitBlobBuilder || window.MozBlobBuilder;
blob = new BlobBuilder();
blob.append(response);
blob = blob.getBlob();
}
var worker = new Worker(URL.createObjectURL(blob));
// Test, used in all examples:
worker.onmessage = function(e) {
alert("Response: " + e.data);
};
worker.postMessage("Test");
在 react hook 和 Worker 結(jié)合的一個 npm 包里就有過使用這種方法的代碼。簡單的把用戶的 task(一個方法)轉(zhuǎn)成字符串,之后通過Blob得到一個 URL 來創(chuàng)建出一個 Worker。其他使用 react hook 的工作機制通知 task 執(zhí)行的結(jié)果。非常的簡單有效。代碼在這里。
節(jié)選部分代碼,以饗讀者:
const createWorker = func => {
if (func instanceof Worker) return func;
if (typeof func === "string" && func.endsWith(".js")) return new Worker(func);
const code = [
`self.func = ${func.toString()};`,
"self.onmessage = async (e) => {",
" const r = self.func(e.data);",
" if (r[Symbol.asyncIterator]) {",
" for await (const i of r) self.postMessage(i)",
" } else if (r[Symbol.iterator]){",
" for (const i of r) self.postMessage(i)",
" } else {",
" self.postMessage(await r)",
" }",
"};"
];
const blob = new Blob(code, { type: "text/javascript" });
const url = URL.createObjectURL(blob);
return new Worker(url);
};
現(xiàn)在這部分代碼都交給 webpack 都插件來做了。
Web Worker 不能做什么
首先 Web Worker 不能訪問 UI thread 的 UI,也就是 DOM。
如果一個 Web Worker 可以訪問 DOM,那加上 UI thread 就是兩個或者兩個以上的 Worker 可以訪問 DOM 了,那就會出現(xiàn)非常麻煩的多線程特有的問題,而且調(diào)試?yán)щy。所以 DOM 肯定是不能訪問的。
其他的還有很多限制可以參考這里
但是,還是可以發(fā)出網(wǎng)絡(luò)請求,可以setTimeout, setInterval,還是可以使用Cache和IndexedDB等等一些功能等。
Worker 雖好,也不能開的太多。Worker 是真正系統(tǒng)級的線程,要運行起來就需要有支撐的資源。在 Worker 之間傳輸?shù)臄?shù)據(jù)不能太大。為了避免多個 Thread 共享內(nèi)存而導(dǎo)致的多線程問題,WeW Worker 傳輸數(shù)據(jù)的時候使用了兩個方式:
- 在多個 Worker 之間傳輸?shù)臄?shù)據(jù)是拷貝傳輸?shù)摹i_發(fā)者不需要考慮這段數(shù)據(jù)的鎖保護之類的事情。
- 以拷貝的方式傳輸數(shù)據(jù),數(shù)據(jù)量過大的時候拷貝消耗的資源也會很大。這個時候就要考慮使用
Transferable Object。這種類型的數(shù)據(jù)在傳輸?shù)臅r候基本不存在復(fù)制的動作,可以認(rèn)為是 c++里的引用傳遞。不同的是 Worker 的Transferable Object在傳遞出去之后就當(dāng)前上下文里即不可訪問。
// Create a 32MB "file" and fill it.
var uInt8Array = new Uint8Array(1024 * 1024 * 32); // 32MB
for (var i = 0; i < uInt8Array.length; ++i) {
uInt8Array[i] = i;
}
worker.postMessage(uInt8Array.buffer, [uInt8Array.buffer]);
舉個栗子
我們來把一個字符串反轉(zhuǎn)多次來模擬 CPU “繁重”的任務(wù)。這個栗子分為三部分一個是運行在 UI thread 上看看會有多卡,一個是運行在Promise里,看看會有什么不同的結(jié)果。數(shù)據(jù)全部都是基于我們的栗子來得到,對于讀者來說由于有些網(wǎng)絡(luò)、硬件等情況不同或者不完全可控會有不同,定量分析不會那么準(zhǔn)確,定性分析有一定的代表性。
同時,這個試驗和樣本的數(shù)量關(guān)系十分密切。在樣本足夠打的時候,試驗只會收到異常。
測試數(shù)據(jù)是怎么來的
const ITERATE_COUNT = 1000;
const STR_LEN = 3;
let queue: TaskQueue | null = null;
function prepareData(count: number = 1000, length: number = 10) {
const data: Array<DataType> = [];
for (let i = 0; i < count; i++) {
const item = RandomString.generate(length);
data.push({ key: `Key - ${i}`, val: item });
}
return data;
}
const rawData = prepareData(ITERATE_COUNT, STR_LEN);
(window as any).rawData = rawData;
上面的方法生成了 1000 個長度是 10 的字符串。在后面的例子里會把這些字符串全部反轉(zhuǎn)。以此來模擬某種業(yè)務(wù)場景下繁重的 CPU 任務(wù)。
例一、在主線程
代碼:
// Demo 1: execute reverse string in ui thread
function execTaskSync() {
console.time("sync task in ui thread");
const target = rawData;
for (let el of target) {
const { val } = el;
reverseString(val);
}
console.timeEnd("sync task in ui thread");
}
(window as any).execTaskSync = execTaskSync;
這個任務(wù)量其實不夠大,只會產(chǎn)生一個和后面例子對比的效果。先運行一下看看結(jié)果:
運行結(jié)果看起來很快,如果需要更慢一些只需要把字符串?dāng)?shù)量或者字符串的長度調(diào)大就可以。運行的結(jié)果基本都在 0.xx ms 的范圍內(nèi),只有一個是 2.27 ms。這也許只是一個現(xiàn)象,也許就很值得深究了。
在 Worker 運行
是時候讓這個功能在 worker 里面運行一次了:
ctx.onmessage = (event: MessageEvent) => {
console.time("worker timer");
const { target } = event.data as { target: DataType[] };
for (let el of target) {
const { val } = el;
reverseString(val);
}
console.timeEnd("worker timer");
ctx.postMessage("done");
// Close the worker when jobs done
self.close();
};
數(shù)據(jù)全部傳過來之后,在 worker 連運行。結(jié)果是這樣的:
在 Micro Queue 運行
看起來是一個 queue,不過是一個個 Promise 接連運行的。在本例中只有一個 Promise 運行。
Queue 是什么樣的 Queue:
class Queue {
private _startExec() {
const task = this._queue.shift();
if (task) task.run();
}
next() {
if (this._queue.length === 0) {
return;
}
this._startExec();
}
async addTask(
fun: (param: any) => any,
data: any,
resolve: (val: any) => void,
reject: (err: any) => void
) {
const run = async () => {
try {
const ret = await fun(data);
resolve(ret);
} catch (e) {
reject(e);
}
this.next();
};
this._queue.push({ run } as Task);
this._startExec();
}
}
這個是在 Queue 里添加 task 的方法,在添加的時候就會在 task 運行完成之后調(diào)用 Queue 的 next 方法來開始下一個 task。
在數(shù)據(jù)量同樣的情況下運行的結(jié)果:
看起來和在主線程的運行結(jié)果相當(dāng)?shù)慕咏?。我們來把?shù)據(jù)量加大看看會有什么結(jié)果。
const ITERATE_COUNT = 100000;
const STR_LEN = 300;
先把數(shù)量級提升到這個程度。
多次運行之后,主線程和放在 Promise 里的方式差別依然不大,只是在按鈕點擊之后明顯的增加了等待的時間。在 Worker 里運行的花費時間比之主線程依然更多,但是按鈕點擊之后的等待時間并沒有相應(yīng)的更多等待。
Run in Worker with Buffer
這就體現(xiàn)出 Worker 存在的意義了。相應(yīng)用戶點擊的速度一定會快很多。這個時候就需要Buffer出場了。我們來測試一下使用了 Buffer 的 Worker 會出現(xiàn)什么樣的驚喜。
明顯在第一次消耗了很多時間之后,每次的調(diào)用都消耗了比直接調(diào)用 Worker 的postMessage更少的時間。使用 Buffer 來實現(xiàn)不同 Worker 之間傳輸數(shù)據(jù)就像是 C/C++的引用傳遞一樣,這里不會涉及到數(shù)據(jù)的拷貝操作。所以節(jié)省了時間。
但是,在代碼里:
const dataStr = JSON.stringify(data);
const dataBuff = str2ab(dataStr);
const worker = new CachedWorker();
worker.postMessage(dataBuff, [dataBuff]);
其實包含了數(shù)據(jù)->字符串(json)->buffer 的轉(zhuǎn)化過程。第一次花費的時間很多是在這些轉(zhuǎn)化的過程中消耗的。但是后面,筆者認(rèn)為是瀏覽器做了優(yōu)化,還要繼續(xù)查一下資料,所以花費的時間只有直接傳輸 buffer 花費的時間,所以大量減少。
注意:使用 Buffer 傳輸數(shù)據(jù)可以很大,比如在 Google 的某個例子中是 30M 多。但是,上文的例子中,傳輸?shù)臄?shù)據(jù)的大小受到了很大的限制。主要是在把 Buffer 的數(shù)據(jù)轉(zhuǎn)化為 Object 的時候會出現(xiàn)異常。有興趣的各位可以把數(shù)據(jù)的大小繼續(xù)往大調(diào)這個異常就會出現(xiàn)。所以,如何使用需要看具體的場景,比如,上例可以改為在 Worker 里請求得到二進制數(shù)據(jù)再做處理。
Transferable Object
傳遞 Buffer 的時候是按照 Transferable Object 傳遞的。這種數(shù)據(jù)是實現(xiàn)了Transferable接口的數(shù)據(jù)。這個接口就是一個標(biāo)記的作用,表明實現(xiàn)了這個接口的數(shù)據(jù)可以如引用一般傳遞。
但是,此處的引用和 C/C++的引用是兩回事。Transferable object 在完成不同的執(zhí)行上下文(execution context)傳輸之后就不再可用了。H5 委員會為了 Worker 可以普及,默默的解決了多少使用多線程可能會出現(xiàn)的問題。
多次執(zhí)行就不用說了,只執(zhí)行一次的代碼緩存起來也存粹是浪費空間。緩沖的命中率是說緩存的結(jié)果會被用到。如果緩存不會再被多次執(zhí)行的某個功能用到,那么也是沒有意義的。
在本例中,緩存的作用基本上大打折扣。字符串是隨機生成的。用隨機字符串為 Key 緩存的結(jié)果,基本上備用到的概率很小,而且隨機字符串的數(shù)量比較大(這里是 1000)。那么在查找緩存字符串的時候也要便利 map 的大部分 Key。反而造成了不必要的多余計算。
所以,緩存需要根據(jù)代碼的執(zhí)行邏輯和緩存的命中率來判斷是否需要。
Worker 的使用離不開特定的場景
使用 Worker 或者不使用 Worker 都是要看具體的某個場景。新技術(shù)的產(chǎn)生一定是解決某個特定的問題的。在使用這項新技術(shù)之前至少要盡量真實的模擬需要解決的場景,來驗證這個新的技術(shù)是否可行。比如,在本文使用的例子就是為了模擬筆者想要解決的問題的場景設(shè)立的。遇到的最大的問題是如果數(shù)據(jù)量達到某個臨界值的時候,在 Worker 內(nèi)部反序列化并組成 Object 的時候就會出現(xiàn)異常。而混存,因為 Key 值極大的可能是重復(fù)的,所以混存的使用就非常的有必要。在以上各種場景的模擬之后可以使用的各種技術(shù)的結(jié)合必然是緩存和使用Buffer傳輸數(shù)據(jù)。但是,數(shù)據(jù)量需要控制,不能出現(xiàn)反序列化的問題。
或者,直接從 Worker 里請求得到 JSON 的二進制串,比如發(fā)送和接收二進制數(shù)據(jù)。
所以,各種技術(shù)都有在特定場合下使用的優(yōu)劣。這就需要我們具體結(jié)合場景具體分析。