Web單線程的終結者:Web Workers

Web單線程的終結者:Web Workers


image.png



作者 | Ada Rose Cannon譯者 | 王強編輯 | Yonie

Comlink 簡化了 Web Worker 的應用,使它們用起來更加安全,但是也要注意它背後的成本。

我寫這篇文章的同時還建了一個 演示網站,網站使用了複雜的物理效果和 SVG 濾鏡。它在移動設備上的手感很好,所以需要很流暢地運行才能出效果。

在同一個線程中運行物理效果和 SVG 濾鏡開銷太大了,所以我把物理效果部分移動到了 Web Worker 中來充分利用資源。

如果你不熟悉並行編程的話,Web Worker 用起來也會很困難。Comlink 這個庫可以幫助開發者簡化 Worker 的應用過程。本文將討論與使用 Web Worker 的好處和缺陷,以及優化它們來提升性能的策略。

JavaScript 中異步腳本的歷史回顧

傳統的 Web 是單線程的。一條條命令會按順序執行,完成一條再開始下一條。早年間,就連 XMLHttpRequest 這樣長時間運行的命令也可能阻塞主線程,完成後主線程才能解放出來:

var request = new XMLHttpRequest();
request.open('GET', '/bar/foo.txt', false);
request.send(null); // Can take several seconds


由於用戶體驗不佳,同步的 XMLHttpRequest 已被棄用;但一些較新的 API,比如說訪問磁盤存儲的 localstorage 也是同步的。它在傳統機械硬盤上的延遲可能達到 10 毫秒之多,耗盡我們大部分的幀預算。

同步 API 簡化了我們的腳本編寫工作,因爲程序的狀態會隨命令編寫的順序改變,在上一條命令完成之前不會發生任何事情。

Web 中的異步 API 是用來訪問某些速度較慢的計算機資源的,比如說從磁盤讀取、訪問網絡或周邊設備(如網絡攝像頭或麥克風等)。這些 API 經常依賴事件或回調來處理這些資源。

// The deprecated way of using getUserMedia with callbacks:
function successCallback () {}
navigator.getUserMedia(constraints, successCallback, errorCallback);
// Using events for XMLHttpRequest
// via MDN WebDocs
function reqListener () {}
var oReq = new XMLHttpRequest();
oReq.addEventListener("load", reqListener);
oReq.open("GET", "http://www.example.org/example.txt");
oReq.send();









Node.js 是服務端 JavaScript 環境,使用了大量異步代碼,因爲 Node 需要在服務器上高效運行;它不會浪費數百萬個 CPU 週期專門等待 IO 操作同步完成。Node 通常使用回調模式進行異步操作。

fs.readFile('/etc/passwd', (error, data) => {
 if (error) throw error;
 console.log(data);
});



雖然回調非常有用,但遺憾的是它們會依賴於先前異步函數的結果,從而散發一些嵌套異步函數的代碼味道,導致代碼大幅縮進;這被稱爲“回調金字塔的噩夢”。

爲了解決這個問題,比較新的 API 往往既不使用回調也不使用事件,而是使用 Promise。Promise 使用.then 語法使回調看起來更具可讀性:

fetch('/data.json')
.then(response => response.json())
.then(data => {
console.log(data);
});




Promise 的功能和回調是一樣的,但前者更具可讀性。特別是與 ES2015 的箭頭函數結合使用時,我們可以清楚地表達 Promise 中的每一步是怎樣轉換上一步的輸出的。

Promise 的真正優勢在於,它們是 EcmaScript 2017 中引入的新 JavaScript 語法——async/await 語法的基礎之一。

在 async 函數中,await 語句將暫停函數的執行,直到它們等待的 promise 完成或拒絕。結果代碼看起來還是同步的,還可以使用 try/catch 和 for 循環之類的同步構造,但行爲卻是異步的,不會阻塞主線程!

async function getData() {
const response = await fetch('data.json');
const data = await response.json();
console.log(data);
};
getData();





async 函數將返回一個 promise,它本身可以在其他 async 函數中與 await 並用,我覺得這種設計非常優雅。

來談談 Web Workers

目前爲止我們談的都是單線程編程。雖然異步代碼看起來像是同步運行的,它也實際上在阻止網站其他部分的運行。

通常來說每個網站都運行在一個 CPU 線程上,這個線程負責運行 JavaScript 代碼、解析 CSS、處理用戶看到的網站佈局和繪圖。需要運行很長時間的 JavaScript 將阻止線程中的其他所有內容繼續工作。如果你的網站過了好久還沒開始繪製,這將給用戶帶來非常糟糕的體驗。在過去這甚至可能導致瀏覽器崩潰,但現代瀏覽器在這方面的表現要好得多。

爲了繞過在單個線程中運行內容的限制,Web 可以通過 Web Worker 來利用多個線程。有幾種 Worker 是針對特定應用的(如服務 Worker 和 Worklet),但我們只討論通用的 Web Worker。

運行下面的代碼可以啓動一個新的 Web Worker:

const worker = new Worker('/my-worker.js');

它將下載 JavaScript 文件並運行在不同的線程中,使你在不阻塞主線程的前提下運行復雜的 JavaScript 程序。在下面的例子中,我們可以對比分別在主線程和 Worker 中計算 3 萬位圓周率的結果。

當它在主線程中計算時,頁面的其餘部分會停止工作;在 Worker 中計算時頁面可以在後臺繼續運行,直到計算完成。

示例:https://a-slice-of-pi.glitch.me/

要顯示 Worker 的計算結果,必須把結果用一條消息發送給主線程。然後主線程負責顯示數字。Worker 本身是無法顯示數字的,因爲它無法訪問主腳本的變量或文檔本身,它所能做的只有傳回計算的最終結果。

這是線程的性質決定的。你只能訪問同一線程內存中的內容。Document 是位於主線程中的,因此 Worker 線程無法對其執行任何操作。

究竟線程是什麼東西?

當初人們發明了計算機。很多人對此十分不滿,認爲這是人類邁出的錯誤一步。

—— Douglas Adams(《銀河系漫遊指南》作者)

下面來簡單介紹一下計算機是如何管理線程和內存的。

早年間的計算機可以一次運行一個進程。每個程序都可以訪問用來執行計算的 CPU 資源和用來存儲信息的內存資源。

在現代計算模型中,雖然很多程序可以同時並行運行,程序的行爲依舊是原來這個樣子。每個進程仍然可以使用一個 CPU 並可以訪問內存。這也可以防止進程寫入其他進程的內存。

計算機的線程數量等於其計算內核的數量,一些英特爾處理器可以在每個內核中運行兩個線程。

可以同時存在的線程數與 CPU 和內存的物理現實是分離的,因爲計算機可以在內存中存儲多個線程,然後在它們之間切換。這稱爲上下文切換,是一項昂貴的操作;因爲它需要清除 CPU 的 L1 到 L3 高速緩存並從內存重新填充它們。這可能需要花費 100ns 左右!看起來好像很快,但這已經相當於 100 個 CPU 時鐘週期了,因此應儘可能避免。

此外,程序可以使用的內存數量並不等同於機器中物理存在的內存容量,因爲操作系統可以使用硬盤交換空間來假裝有幾乎無限的內存,只是交換內存的部分速度很慢。

對現代硬件來說程序儘可能使用多個線程是很有意義的,因爲單個 CPU 核心的速度很難繼續增長了,取而代之的是單個芯片上的 CPU 內核數量不斷增加。

雖然在傳統的臺式 / 服務器計算機中各個處理核心幾乎沒有區別,但現代移動芯片通常包含功率有高有低的多個處理器核心以增加電池壽命並加強散熱能力。即使你的手機上有一顆非常強大的 CPU 核心,但它持續全速工作的時間可能會很短,以避免芯片過熱。

我手機中的 Exynos 9820 芯片的架構如下圖所示,其 CPU 部分有兩個大核心、兩個中核心和四個小核心。

https://www.samsung.com/semiconductor/minisite/exynos/products/mobileprocessor/exynos-9-series-9820/

image.png解決線程的侷限性

雖然不同的線程不能共享內存,但它們仍然可以相互通信以交換信息。這個 API 是基於事件的,每個線程都會偵聽 message 事件,並可以使用 postMessage API 發送消息。

除了字符串之外,還可以使用 postMessage 共享許多類型的數據結構,例如數組和對象等。發送這些數據時,瀏覽器以特殊的序列化格式製作數據結構的副本,然後在另一個線程中重建:

// In the worker:
self.postMessage(someObject);
// In the main thread:
worker.addEventListener('message', msg => console.log(msg.data));



在上面的示例中,對象 someObject 被克隆並變成可傳遞的形式,這個過程稱爲序列化。然後主線程會接收它並轉換成原始對象的副本。這可能是一項開銷巨大的操作,但沒有它就沒法維持複雜的數據結構了。

需要傳輸大量數據時你可以傳輸一塊內存,可以通過這種方式傳輸的對象稱爲 可傳遞對象。最常見的爲共享數據而傳遞的對象類型是 ArrayBuffer。

ArrayBuffer 是類型化數組API 的一部分。你不能直接寫入 ArrayBuffer,而需要使用類型化的數組來讀取和寫入。類型化數組將 JavaScript 數字轉換爲存儲在數組緩衝區中的原始數據。

你還可以創建具有已定義大小的新類型化數組,它將分配一塊新內存以適應這個大小值。這塊內存由底層的 ArrayBuffer 表示,並暴露爲.buffer,這個 ArrayBuffer 實例可以在線程之間傳輸以共享內容。

// In the worker:
const buffer = new ArrayBuffer(32); // 32 Bytes
>> ArrayBuffer { byteLength: 32 }
const array = new Float32Array(buffer);
>> Float32Array [ 0, 0, 0, 0, 0, 0, 0, 0 ]; // 4 Bytes per element, so 8 elements long.
array[0] = 1;
array[1] = 2;
array[2] = 3;
self.postMessage(array.buffer, [array.buffer]);








使用 postMessage 傳輸 ArrayBuffer 時要小心。一旦它被傳輸後,它在原始線程中就不能再讀取或寫入了,並且如果你嘗試使用它將拋出錯誤。

ArrayBuffer 與數據無關,它們只是內存塊。他們不關心自己存儲的是什麼樣的數據。因此你可以使用單個 ArrayBuffer 來存儲大量不同類型的較小數據塊。

所以如果你需要 ArrayBuffer 的效率,同時也需要處理複雜的數據結構,那麼你就可以小心地使用單個 ArrayBuffer。我寫了一篇 在 ArrayBuffer 中存儲稍複雜結構的文章,詳細介紹瞭如何在單個 ArrayBuffer 中存儲不同類型的數字: https://medium.com/samsung-internet-dev/being-fast-and-light-using-binary-data-to-optimise-libraries-on-the-client-and-the-server-5709f06ef105

你可以使用 postMessage 來回發送消息並使用事件來響應。不幸的是,在現實世界中這種方法用起來很麻煩,因爲想要跟蹤哪個響應對應於哪些消息,對於不常見的用例是很難做到的。

使用 Worker 在理想情況下可以給我們帶來很大的性能提升,所謂理想情況是指在不同處理器上運行的線程之間可以高效通信。

我們無法控制操作系統選擇在哪個物理處理器上運行進程,也無法控制用戶可能正在運行的其他應用程序。因此可能存在這樣的情況:Worker 和主線程都在同一物理處理器上運行,這就意味着 Worker 需要上下文切換才能開始執行。可能還存在這樣的情況:Worker 不是該 CPU 核心上的最高優先級進程,因此 Worker 線程可能會在內存中等待,而其他任務繼續工作。

讓開發人員更容易地使用多線程技術

所幸谷歌的 Surma 開發了一個令人讚歎的 JS 庫,將這種消息來往轉換成了基於 Promise 的異步 API!這個庫名爲 Comlink,體積非常小,但大大簡化了 Worker 的消息循環處理工作,

在下面的示例中,我們把從 Worker 中暴露的類實例化爲新對象,然後從中調用一些方法。在原始類中這些方法完全是同步的,但因爲向 Worker 發送並接收消息需要時間,所以 Comlink 返回一個 Promise 取而代之。

還好我們可以用 async/await 語法編寫看起來像是同步的異步代碼,因此代碼看起來仍然非常整潔和同步。

import {wrap} from '/comlink/comlink.js';
// This web worker uses Comlink's expose to expose a function
const MyMathLibrary = wrap(new Worker('/mymath.js'));
async function main() {
const myMath = await new MyMathLibrary();
const result1 = await myMath.add(2,2);
const result2 = await myMath.add(3,7);
return await myMath.multiply(result1, result2);
}








注意!Comlink 簡化了使用 Worker 的過程,但它也隱藏了來回發送數據的成本!在 main 中的這幾行代碼包括了 Worker 之間前後發送的 6 條消息,每條消息都要等上一條完成後纔會發送。每次發送消息時都必須對數據進行序列化和重構,並且可能需要進行上下文切換才能完成響應。

在理想情況下,另一個線程會運行在另一個 CPU 內核上等待一些輸入,一切都有條不紊地推進。但如果線程沒有主動工作,那麼 CPU 可能必須從內存中恢復它,速度可能會很慢。我們無法控制操作系統何時切換線程,但如果阻止代碼執行,直到另一個線程中的代碼執行完畢後才繼續,那麼就可能要等待 100 納秒的時間。

寫出清晰易讀和代碼總歸是好事情,但我們必須警惕性能的負面影響。我們能做的一項改進是並行計算 result1 和 result2 來提升性能,但代碼就不會那麼簡潔了。

// This web worker uses Comlink's expose to expose a function
const MyMathLibrary = proxy(new Worker('/mymath.js'));
async function main() {
const myMath = await new MyMathLibrary();
const [result1, result2] = await Promise.all(
[myMath.add(2,2), myMath.add(3,7)]
);
return await myMath.multiply(result1, result2);
}








使用 Comlink 可以帶來的另一大性能提升是利用 ArrayBuffer 之類的可傳遞對象,不用再複製它們。這會顯著提升性能,但用的時候也要小心,因爲一旦它們被傳遞後就不能在原始線程中使用了。

如果你正在程序中使用可傳遞對象,那麼傳遞後就把它們移出範圍,以免不小心再去讀取它們的數據。

const data = [1,2,3,4];
await (function () {
const toSend = Int16Array.from(data);
return myMath.addArray(
Comlink.transfer(toSend.buffer, [toSend.buffer])
);
}());






傳遞函數是用來包裝你發送的內容的,同時標記在第二個參數數組中可傳輸的數據。上面的示例中我發送 toSend.buffer 並告訴 Comlink 它可以傳遞而非複製。

記得在你的 Worker 中處理緩衝區的問題:

addArray(array) {
array = array.constructor === ArrayBuffer ?
new Int16Array(array) :
array;



優化 Comlink 代碼時,請注意平衡性能和代碼易讀性。這些優化可以爲你提供 10 納秒或 100 納秒的性能改進,對用戶來說沒那麼明顯,除非很多優化同時使用。優化太多的代碼也更難閱讀,可能會讓你更難診斷錯誤。

轉換現有代碼庫以利用 Worker

Comlink 的一大好處是它讓開發人員可以方便地把一部分應用放到 Worker 中,而無需對代碼庫做大幅度改動。

你要做的工作主要是把同步函數轉換爲異步函數,後者 await 從 Worker 暴露的 api。

但是簡單地把代碼都移到 Worker 裏並不是什麼銀彈。

你的幀速率可能會略有提高,因爲主線程的負擔減輕了不少;但如果有大量的消息來回傳遞,你可能會發現實際工作消耗的時間反而更久了。

   例子  

我寫了一個演示,結合了 Verlet 集成與 SVG 創建出晃來晃去的界面。相關鏈接: https://mind-map.glitch.me/。

Verlet 集成是一個基於一些點和約束條件的簡單物理模型。每一幀都需要爲運動部件做一次新的物理計算。

我的演示還使用了一個複雜的 SVG 濾鏡來爲 DOM 元素生成一個好看的特效。這個濾鏡在主線程上消耗了很多 CPU 計算資源。

它一開始運行得很順利,但後來應用程序的 Verlet 集成需要計算很多點,此時執行 Verlet 集成物理運算和渲染 SVG 所花費的時間就要比每幀的顯示時間(16ms)更長了。

我以爲把 Verlet 集成的代碼移動到 Web Worker 中就行,這部分代碼會 await 每個 API 調用。

然後我測試應用程序時發現卡頓消失也不跳幀了,但是每次物理計算花費的時間變長了很多,顯示出來的效果也不對勁了。

我使用 Chrome 的性能選項卡來測量 CPU 的佔用率,令我驚訝的是 CPU 大部分時間處於空閒狀態!發生了什麼事?!

問題在於必須在 for 循環中多次切換線程。在線程之間切換時,計算機可能需要從內存中獲取信息以填充緩存,這一過程就比較慢了。

// slow
for (let i=0;i<100;i++) await point.doPhysics(i);

我沒那麼多時間來優化代碼,而且優化過的代碼往往沒那麼容易看懂。我得把重點放在運行最頻繁且對用戶體驗影響最大的代碼上。

下面是我優化的順序:
  1. PointerMove 事件中的循環(運行速度超過 60fps)。
  2. 請求動畫幀中的循環(60fps)。
  3. 阻止應用啓動的循環,優化它來改善用戶體驗。
  4. PointerMove 事件。
  5. 請求動畫幀。
優化使用 Comlink 的 API

最重要的是要測量每次改動的結果,否則你沒法知道是不是修復了問題,修復了多少,甚至可能在不知不覺中做出更糟糕的事

可以用性能 API 來提供準確的計時數據,從而查看某些代碼運行所需的時間。

performance.clearMarks('start-doing-thing');
performance.clearMarks('end-doing-thing');
performance.clearMeasures("Time to do things");
performance.mark('start-doing-thing');
for (let i=0;i<100;i++) await myMath.doThing(i);
performance.mark('end-doing-thing');
performance.measure("Time to do things", 'start-doing-thing', 'end-doing-thing');






一旦你測出了要優化的代碼並確認它確實是性能問題的根源,就可以開始優化它了。優化一段代碼可以有這樣幾種方法:
  • 刪除它,你真的需要它嗎?

  • 緩存它,舊數據有什麼用? (可能會引入意外錯誤。)

  • 使計算並行運行(這沒關係):

arrayOfPromises = [];
for (let i=0;i<100;i++) arrayOfPromises[i] = myMath.doThing(i);
const results = await Promise.all(arrayOfPromises);


  • 更改你的 API 以批量接收輸入(這樣更好):

arrayOfArguments = [];
for (let i=0;i<100;i++) arrayOfArguments[i] = i;
const results = await myMath.doManyThings(arrayOfArguments);


如果你真的更改了 API 以處理批量輸入,那麼發送和返回 ArrayBuffer 就能進一步提升效率,因爲結果或參數本身可能是一個非常大的數值數組。

謹記!在線程之間傳輸可傳遞對象時要非常小心,因爲當它們跑到另一個線程中後就不再可用了。

最重要的事情

編寫跨兩個線程異步運行的代碼是一個難題。很容易出現一些意外錯誤,因爲你的代碼是並行運行的,可事情不會按照你期望的順序發生。

在開發人員的開發體驗和應用程序的性能之間做好取捨是非常重要的。不要過度優化對最終用戶沒什麼影響的代碼。

在下面的示例中,第一個版本更加簡潔,開發人員更容易理解,也沒比第二個版本慢很多。

const handle = await teapot.handle();
const spout = await teapot.spout();
const lid = await teapot.lid();
vs
const [handle, spout, lid] = await Promise.all([
teapot.handle(), teapot.spout(), teapot.lid()
]);






應該只在性能表現很重要時做優化,例如循環、快速觸發事件(如滾動或鼠標移動)或請求動畫幀這些情況。

在優化之前先做測量,確保你沒有浪費時間優化用戶根本注意不到的事情。應用程序啓動時多 200 毫秒可能沒人會注意,但多出來 2000 毫秒就是另一回事了。

過早優化可能會引入意外的錯誤,因爲代碼很難閱讀也很難查錯。

儘管異步代碼很難編寫,但它也有自己的價值,有時可以用來爲你的應用程序提供令人難以置信的流暢體驗。它不是必不可少的功能,而是 Web 性能工具箱中的一項工具。

PostScript:關於性能的有趣註釋

在考慮性能問題時,最重要的一步是找出瓶頸所在並測量性能。下表是一個方便的指南,幫助你找出性能問題的根源所在。

這個不同類型的 IO 操作時間指南比較粗略,但也足夠用了;我們的目標是提供每秒 60 幀的流暢體驗,在這樣的預算限制下做優化。

+---------------------------------------+-----------+
| Type | Time/ns |
+---------------------------------------+-----------+
| One frame at 60fps (16ms) | 16000000 |
| Accessing the disk (spinning platter) | 10000000 |
| Accessing the disk (solid state) | 150000 |
| Accessing memory | 100 |
| Accessing L3 cpu cache | 28 |
| Accessing L2 cpu cache | 3 |
| Accessing L1 cpu cache | 1 |
| 1 CPU cycle | 0.5 |
+---------------------------------------+-----------+











https://www.prowesscorp.com/computer-latency-at-a-human-scale/

有趣的是,光在 1 納秒時間內可以移動 30 釐米左右,因此在大約 0.5 個 CPU 週期內信號只能行進大約 15 釐米,和常見的手機一樣長。

英文原文: https://medium.com/samsung-internet-dev/web-workers-in-the-real-world-d61387958a40

 活動推薦

用更低的成本帶來用戶更好的體驗,是大前端的技術的演進主流思路之一:動態化、跨平臺技術爲降低研發成本,提高迭代效率帶來可觀的收益;前端中臺、業務抽象複用爲前端工程化指明瞭方向······更多大前端趨勢解讀盡在 ArchSummit 全球架構師峯會(北京站)2019,目前 7 折限時直降 2640 元!瞭解詳情請聯繫票務經理灰灰:15600537884 ,微信同號。



發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章