Web單線程的終結者:Web Workers

Web單線程的終結者:Web Workers


image.png



做者 | Ada Rose Cannon譯者 | 王強編輯 | Yonie前端

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

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

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

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

JavaScript 中異步腳本的歷史回顧

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

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

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

同步 API 簡化了咱們的腳本編寫工做,由於程序的狀態會隨命令編寫的順序改變,在上一條命令完成以前不會發生任何事情。promise

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 ,微信同號。

相關文章
相關標籤/搜索