開始之前,代碼在這里壁榕。歡迎各位大神指導(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∠弈可以安裝插件 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)試困難。所以 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ù)制的動作阐斜,可以認為是 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ò)惕澎、硬件等情況不同或者不完全可控會有不同莉测,定量分析不會那么準確,定性分析有一定的代表性唧喉。
同時捣卤,這個試驗和樣本的數(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ù)慕咏恕N覀儊戆褦?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)化的過程中消耗的节值。但是后面徙硅,筆者認為是瀏覽器做了優(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ù)号枕。這個接口就是一個標記的作用缰揪,表明實現(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é)合場景具體分析涩拙。