Web Worker 現狀
導讀:Web 是單線程的。這讓編寫流暢又靈敏的應用程序變得越來越困難。Web Worker 的名聲很臭,但對 Web 開發者來說,它是解決流暢度問題的 一個非常重要的工具。讓我們來了解一下 Web Worker 吧。
我們總是把 Web 和 所謂的 “Native” 平臺(比如 Android 和 iOS)放在一起比較。Web 是流式的,當你第一次打開一個應用程序時,本地是不存在任何可用資源的。這是一個根本的區別,這使得很多在 Native 上可用的架構 無法簡單應用到 Web 上。
不過,不管你在關注在什麼領域,都一定使用過或瞭解過 多線程技術。iOS 允許開發者 使用 Grand Central Dispatch 簡單的並行化代碼,而 Android 通過 新的統一任務調度器 WorkManager 實現同樣的事情,遊戲引擎 Unity 則會使用 job systems 。我上面列舉的這些平臺不僅支持了多線程,還讓多線程編程變得儘可能簡單。
在這篇文章,我將概述爲什麼我認爲多線程在 Web 領域很重要,然後介紹作爲開發者的我們能夠使用的多線程原語。除此之外,我還會談論一些有關架構的話題,以此幫助你更輕鬆的實現多線程編程(甚至可以漸進實現)。
無法預測的性能問題
我們的目標是保持應用程序的 流暢(smooth) 和 靈敏(responsive)。流暢 意味着 穩定 且 足夠高 的 幀率。靈敏 意味着 UI 以最低的延遲 響應 用戶交互。兩者是保持應用程序 優雅 和 高質量 的 關鍵因素。
按照 RAIL 模型,靈敏 意味着響應用戶行爲的時間控制在 100ms 內,而 流暢 意味着屏幕上任何元素移動時 穩定在 60 fps。所以,我們作爲開發者 擁有 1000ms/60 = 16.6ms 的時間 來生成每一幀,這也被稱作 “幀預算”(frame budget)。
我剛剛提到了 “我們”,但實際上是 “瀏覽器” 需要 16.6ms 的時間 去完成 渲染一幀背後的所有工作。我們開發者僅僅直接負責 瀏覽器實際工作 的一部分。瀏覽器的工作包括(但不限於):
-
檢測 用戶操作的 元素(element)
-
發出 對應的事件
-
運行相關的 JavaScript 時間處理程序
-
計算 樣式
-
進行 佈局(layout)
-
繪製(paint)圖層
-
將這些圖層合併成一張 最終用戶在屏幕上看到的 圖片
-
(以及更多…)
好大的工作量啊。
另一方面,“性能差距” 在不斷擴大。旗艦手機的性能隨着 手機產品的更新換代 變得越來越高。而低端機型正在變得越來越便宜,這使得之前買不起手機的人能夠接觸到移動互聯網了。就性能而言,這些低端手機的性能相當於 2012 年的 iPhone。
爲 Web 構建的應用程序會廣泛運行在性能差異很大的不同設備上。JavaScript 執行完成的時間取決於 運行代碼設備有多快。不光是 JavaScript,瀏覽器執行的其他任務(如 layout 和 paint)也受制於設備的性能。在一臺現代的 iPhone 上運行只需要 0.5ms 的任務 可能 到了 Nokia 2 上需要 10ms。用戶設備的性能是完全無法預測的。
注:RAIL 作爲一個指導框架至今已經 6 年了。你需要注意一點,實際上 60fps 只是一個佔位值,它表示的是用戶的顯示設備原生刷新率。例如,新的 Pixel 手機 有 90Hz 的屏幕 而 iPad Pro 的屏幕是 120Hz 的,這會讓 幀預算 分別減少到 11.1ms 和 8.3ms。
更復雜的是,除了測算 requestAnimationFrame() 回調之間的時間,沒有更好的方法來確定運行 app 設備的刷新率 。
JavaScript
JavaScript 被設計成 與瀏覽器的主渲染循環同步運行。幾乎所有的 Web 應用程序都會遵循這種模式。這種設計的缺點是:執行緩慢的 JavaScript 代碼會阻塞瀏覽器渲染循環。
JavaScript 與瀏覽器的主渲染循環 同步運行可以理解爲:如果其中一個沒有完成,另一個就不能繼續。爲了讓長時間的任務能在 JavaScript 中 協調運行,一種基於 回調 以及 後來的 Promise 的 異步模型被建立起來。
爲了保持應用程序的 流暢,你需要保證你的 JavaScript 代碼運行 連同 瀏覽器做的其他任務(樣式、佈局、繪製…)的時間加起來不超出設備的幀預算。
爲了保持應用程序的 靈敏,你需要確保任何給定的事件處理程序不會花費超過 100ms 的時間,這樣才能及時在設備屏幕上展示變化。在開發中,即使用自己的設備實現上面這些已經很困難了,想要在所有的設備都上實現這些幾乎是不可能的。
通常的建議是 “做代碼分割(chunk your code)”,這種方式也可以被稱作 “出讓控制權(yield)給瀏覽器”。其根本的原理是一樣的:爲了給瀏覽器一個時機來進入下一幀,你需要將代碼分割成大小相似的塊(chunk),這樣一來,在代碼塊間切換時 就能將控制權交還給 瀏覽器 來做渲染。
有很多種 “出讓控制權(yield)給瀏覽器” 的方法,但是沒有那種特別優雅的。最近提出的 任務調度 API 旨在直接暴露這種能力。然而,就算我們能夠使用 await yieldToBrowser() (或者類似的其他東西) 這樣的 API 來 出讓控制權,這種技術本身還是會存在缺陷:爲了保證不超出幀預算,你需要在足夠小的塊(chunk)中完成業務,而且,你的代碼每一幀至少要 出讓一次控制權。
過於頻繁的出讓控制權 的 代碼 會導致 調度任務的開銷過重,以至於對應用程序整體性能產生負面影響。再綜合一下我之前提到的 “無法預測的設備性能”,我們就能得出結論 — 沒有適合所有設備的塊(chunk)大小。當嘗試對 UI 業務進行 “代碼分割” 時,你就會發現這種方式很成問題,因爲通過出讓控制權給瀏覽器來分步渲染完整的 UI 會增加 佈局 和 繪製 的總成本。
Web Workers
有一種方法可以打破 與瀏覽器渲染線程同步的 代碼執行。我們可以將一些代碼挪到另一個不同的線程。一旦進入不同的線程,我們就可以任由 持續運行的 JavaScript 代碼 阻塞,而不需要接受 代碼分割 和 出讓控制權 所帶來的 複雜度 和 成本。
使用這種方法,渲染進程甚至都不會注意到另一個線程在執行阻塞任務。在 Web 上實現這一點的 API 就是 Web Worker。通過傳入一個獨立的 JavaScript 文件路徑 就可以 創建一個 Web Worker,而這個文件將在新創建的線程里加載和運行。
const worker = new Worker("./worker.js");
在我們深入討論之前,有一點很重要,雖然 Web Workers, Service Worker 和 Worklet 很相似,但是它們完全不是一回事,它們的目的是不同的:
在這篇文章中,我只討論 Web Workers (經常簡稱爲 “Worker”)。Worker 就是一個運行在 獨立線程裏的 JavaScript 作用域。Worker 由一個頁面生成(並所有)。
ServiceWorker 是一個 短期的 ,運行在 獨立線程裏的 JavaScript 作用域,作爲一個 代理(proxy)處理 同源頁面中發出的所有網絡請求。最重要的一點,你能通過使用 Service Worker 來實現任意的複雜緩存邏輯。
除此之外,你也可以利用 Service Worker 進一步實現 後臺長請求,消息推送 和 其他那些無需關聯特定頁面的功能。它挺像 Web Worker 的,但是不同點在於 Service Worker 有一個特定的目的 和 額外的約束。
Worklet 是一個 API 收到嚴格限制的 獨立 JavaScript 作用域,它可以選擇是否運行在獨立的線程上。Worklet 的重點在於,瀏覽器可以在線程間移動 Worklet。AudioWorklet,CSS Painting API 和 Animation Worklet 都是 Worklet 應用的例子。
SharedWorker 是特殊的 Web Worker,同源的多個 Tab 和 窗口可以引用同一個 SharedWorker。這個 API 幾乎不可能通過 polyfill 的方式使用,而且目前只有 Blink 實現過。所以,我不會在本文中深入介紹。
JavaScript 被設計爲和瀏覽器同步運行,也就是說沒有併發需要處理,這導致很多暴露給 JavaScript 的 API 都不是 線程安全 的。對於一個數據結構來說,線程安全意味着它可以被多個線程並行訪問和操作,而它的 狀態(state)不會 被破壞(corrupted)。
這一般通過 互斥鎖(mutexes) 實現。當一個線程執行操作時,互斥鎖會鎖定其他線程。瀏覽器 和 JavaScript 引擎 因爲不處理鎖定相關的邏輯,所以能夠做更多優化來讓代碼執行更快。另一方面,沒有鎖機制 導致 Worker 需要運行在一個完全隔離的 JavaScript 作用域,因爲任何形式的數據共享都會 因缺乏線程安全 而產生問題。
雖然 Worker 是 Web 的 “線程” 原語 ,但這裏的 “線程” 和在 C++,Java 及其他語言中的非常不同。最大的區別在於,依賴於隔離環境 意味着 Worker 沒有權限 訪問其創建頁面中其他變量和代碼,反之,後者也無法訪問 Worker 中的變量。
數據通信的唯一方式就是調用 API postMessage,它會將傳遞信息複製一份,並在接收端 觸發 message 事件。隔離環境也意味着 Worker 無法訪問 DOM,在 Worker 中也就無法更新 UI — 至少在沒有付出巨大努力的情況下(比如 AMP 的 worker-dom)。
瀏覽器對 Web Worker 的支持可以說是普遍的,即使是 IE10 也支持。但是,Web Worker 的使用率依舊偏低,我認爲這很大程度上是由於 Worker API 特殊的設計。
JavaScript 的併發模型
想要應用 Worker ,那麼就需要對應用程序的架構進行調整。JavaScript 實際上支持兩種不同的併發模型,這兩種模型通常被歸類爲 “Off-Main-Thread 架構”(脫離主線程架構)。
這兩種模型都會使用 Worker,但是有非常不同的使用方式,每種方式都有自己的權衡策略。這兩種模型了代表解決問題的兩個方向,而任何應用程序都能在兩者之間找到一個更合適的。
併發模型 #1:Actor
我個人傾向於將 Worker 理解爲 Actor 模型 中的 Actor。編程語言 Erlang 中對於 Actor 模型 的實現可以說是最受歡迎的版本。每個 Actor 都可以選擇是否運行在獨立的線程上,而且完全保有自己操作的數據。沒有其他的線程可以訪問它,這使得像 互斥鎖 這樣的渲染同步機制就變得沒有必要了。Actor 只會將信息傳播給其他 Actor 並 響應它們接收到的信息。
例如,我會把 主線程 想象成 擁有並管理 DOM 或者說是 全部 UI 的 Actor。它負責更新 UI 和 捕獲外界輸入的事件。還會有一個 Actor 負責管理應用程序的狀態。DOM Actor 將低級的輸入事件 轉換成 應用級的語義化的事件,並將這些事件傳遞給 狀態 Actor 。
狀態 Actor 按照接收到的事件 修改 狀態對象,可能會使用一個狀態機 甚至涉及其他 Actor。一旦狀態對象被更新,狀態 Actor 就會發送一個 更新後狀態對象的拷貝 到 DOM Actor。DOM Actor 就會按照新的狀態對象更新 DOM 了。Paul Lewis 和 我 曾經在 2018 年的 Chrome 開發峯會上探索過以 Actor 爲中心的應用架構 。
當然,這種模式也不是沒有問題的。例如,你發送的每一條消息都需要被拷貝。拷貝所花的時間不僅取決於 消息的大小,還取決於當前應用程序的運行情況。根據我的經驗,postMessage 通常 “足夠快”,但在某些場景確實不太行。
另一個問題是,將代碼遷移到 Worker 中可以解放 主線程,但同時不得不支付通信的開銷,而且 Worker 可能會在響應你的消息之前忙於執行其他代碼,我們需要考慮這些問題來做一個平衡。一不小心,Worker 可能會給 UI 響應帶來負面影響。
通過 postMessage 可以傳遞非常複雜的消息。其底層算法(叫做 “結構化克隆”)可以處理 內部帶有循環的數據結構 甚至是 Map 和 Set 。然而,他不能處理 函數 或者 類,因爲這些代碼在 JavaScript 中無法跨作用域共享。
有點惱人的是,通過 postMessage 傳一個 函數 會拋出一個 錯誤,然而一個類被傳遞的話,只會被靜默的轉換爲一個普通的 JavaScript 對象,並在此過程中丟失所有方法(這背後的細節是有意義的,但是超出了本文討論的範圍)。
另外,postMessage 是一種 “Fire-and-Forget” 的消息傳遞機制,沒有請求 和 響應 的概念。如果你想使用 請求 / 響應 機制(根據我的經驗,大多數應用程序架構都會最終讓你不得不這麼做),你必須自己搞定。這就是我寫了 Comlink 的原因,這是一個底層使用 RPC 協議的庫,它能幫助實現 主線程 和 Worker 互相訪問彼此對象。
使用 Comlink 的時候,你完全不用管 postMessage。唯一需要注意的一點是,由於 postMessage 的異步性,函數並不會返回結果,而是會返回一個 promise。在我看來,Comlink 提煉了 Actor 模式 和 共享內存 兩種併發模型中優秀的部分 並 提供給用戶。
Comlink 並不是魔法,爲了使用 RPC 協議 還是需要使用 postMessage。如果你的應用程序最終罕見的由於 postMessage 而產生瓶頸,那麼你可以嘗試利用 ArrayBuffers 可 被轉移(transferred) 的特性。
轉移 ArrayBuffer 幾乎是即時的,並同時完成所有權的轉移:在這個過程中 發送方的 JavaScript 作用域會失去對數據的訪問權。當我實驗在主線程之外運行 WebVR 應用程序的物理模擬時,用到了這個小技巧。
併發模型 #2:共享內存
就像我之前提到的,傳統的線程處理方式是基於 共享內存 的。這種方式在 JavaScript 中是不可行的,因爲幾乎所有的 JavaScript API 都是假定沒有併發訪問對象 來設計的。
現在要改變這一點要麼會破壞 Web,要麼會由於目前同步的必要性導致重大的性能損耗。相反,共享內存 這個概念目前被限制在一個專有類型:SharedArrayBuffer (或簡稱 SAB)。
SAB 就像 ArrayBuffer,是線性的內存塊,可以通過 Typed Array 或 DataView 來操作。如果 SAB 通過 postMessage 發送,那麼另一端不會接收到數據的拷貝,而是收到完全相同的內存塊的句柄。在一個線程上的任何修改 在其他所有線程上都是可見的。爲了讓你創建自己的 互斥鎖 和 其他的併發數據結構,Atomics 提供了各種類型的工具 來實現 一些原子操作 和 線程安全的等待機制。
SAB 的 缺點是多方面的。首先,也是最重要的一點,SAB 只是一塊內存。SAB 是一個非常低級的原語,以增加 工程複雜度 和 維護複雜度 作爲成本,它提供了高靈活度 和 很多能力。而且,你無法按照你熟悉的方式去處理 JavaScript 對象 和 數組。它只是一串字節。
爲了提升這方面的工作效率,我實驗性的寫了一個庫 buffer-backed-object。它可以合成 JavaScript 對象,將對象的值持久化到一個底層緩衝區中。
另外,WebAssembly 利用 Worker 和 SharedArrayBuffer 來支持 C++ 或 其他語言 的線程模型。WebAssembly 目前提供了實現 共享內存併發 最好的方案,但也需要你放棄 JavaScript 的很多好處(和舒適度)轉而使用另一種語言,而且通常這都會產出更多的二進制數據。
案例研究: PROXX
在 2019 年,我和我的團隊發佈了 PROXX,這是一個基於 Web 的 掃雷遊戲,專門針對功能機。功能機的分辨率很低,通常沒有觸摸界面,CPU 性能差勁,也沒有湊乎的 GPU。儘管有這麼多限制,這些功能機還是很受歡迎,因爲他們的售價低的離譜 而且 有一個功能完備的 Web 瀏覽器。因爲功能機的流行,移動互聯網得以向那些之前負擔不起的人開放。
爲了確保這款遊戲在這些功能機上靈敏流暢運行,我們使用了一種 類 Actor 的架構。主線程負責渲染 DOM(通過 preact,如果可用的話,還會使用 WebGL)和 捕捉 UI 事件。整個應用程序的狀態 和 遊戲邏輯 運行在一個 Worker 中,它會確認你是否踩到雷上了,如果沒有踩上,在遊戲界面上應該如何顯示。遊戲邏輯甚至會發送中間結果到 UI 線程 來持續爲用戶提供視覺更新。
其他好處
我談論了 流暢度 和 靈敏度 的重要性,以及如何通過 Worker 來更輕鬆的實現這些目標。另外一個外在的好處就是 Web Worker 能幫助你的應用程序消耗更少的設備電量。通過並行使用更多的 CPU 核心,CPU 會更少的使用 “高性能” 模式,總體來說會讓功耗降低。來自微軟的 David Rousset 對 Web 應用程序的功耗進行了探索。
採用 Web Worker
如果你讀到了這裏,希望你已經更好的理解了 爲什麼 Worker 如此有用。那麼現在下一個顯而易見的問題就是:怎麼使用。
目前 Worker 還沒有被大規模使用,所以圍繞 Worker 也沒有太多的實踐和架構。提前判斷代碼的哪些部分值得被遷移到 Worker 中是很困難的。我並不提倡使用某種特定的架構 而拋棄其他的,但我想跟你分享我的做法,我通過這種方式漸進的使用 Worker,並獲得了不錯的體驗:
大多數人都使用過 模塊 構建應用程序,因爲大多數 打包器 都會依賴 模塊 執行 打包 和 代碼分割。使用 Web Worker 構建應用程序最主要的技巧就是將 UI 相關 和 純計算邏輯 的代碼 嚴格分離。這樣一來,必須存在於主線程的模塊(比如調用了 DOM API 的)數量就能減少,你可以轉而在 Worker 中完成這些任務。
此外,儘量少的依靠同步,以便後續採用諸如 回調 和 async/await 等異步模式。如果實現了這一點,你就可以嘗試使用 Comlink 來將模塊從主線程遷移到 Worker 中,並測算這麼做是否能夠提升性能。
現有的項目想要使用 Worker 的話,可能會有點棘手。花點時間仔細分析代碼中那些部分依賴 DOM 操作 或者 只能在主線程調用的 API。如果可能的話,通過重構刪除這些依賴關係,並漸近的使用上面我提出的模型。
無論是哪種情況,一個關鍵點是,確保 Off-Main-Thread 架構 帶來的影響是可測量的。不要假設(或者估算)使用 Worker 會更快還是更慢。瀏覽器有時會以一種莫名其妙的方式工作,以至於很多優化會導致反效果。測算出具體的數字很重要,這能幫你做出一個明智的決定!
Web Worker 和 打包器(Bundler)
大多數 Web 現代開發環境都會使用打包器來顯著的提升加載性能。打包器能夠將多個 JavaScript 模塊打包到一個文件中。然而,對於 Worker,由於它構造函數的要求,我們需要讓文件保持獨立。我發現很多人都會將 Worker 的代碼分離並編碼成 Data URL 或 Blob URL,而不是選擇在 打包器 上下功夫來實現需求。
Data URL 和 Blob URL 這兩種方式都會帶來大問題:Data URL 在 Safari 中完全無法工作,Blob URL 雖說可以,但是沒有 源(origin) 和 路徑 的概念,這意味路徑的解析和獲取無法正常使用。這是使用 Worker 的另一個障礙,但是最近主流的打包器在處理 Worker 方面都已經加強了不少:
-
Webpack :對於 Webpack v4,worker-loader 插件讓 Webpack 能夠理解 Worker。而從 Webpack v5 開始,Webpack 可以自動理解 Worker 的構造函數,甚至可以在 主線程 和 Worker 之間共享模塊 而 避免重複加載。
-
Rollup :對於 Rollup,我寫過 rollup-plugin-off-main-thread ,這個插件能讓 Worker 變得開箱即用
-
Parcel :Parcel 值得特別提一下,它的 v1 和 v2 都支持 Worker 的開箱即用,無需額外配置。
在使用這些打包器開發應用程序時,使用 ES Module 是很常見的。然而,這又會帶來新問題。
Web Worker 和 ES Module
所有的現代瀏覽器都支持通過 <script type="module" src="file.js">
來運行 JavaScript 模塊。Firefox 之外的所有現代瀏覽器現在也都支持對應 Worker 的一種寫法:new Worker("./worker.js", {type: "module"}) 。Safari 最近剛開始支持,所以考慮如何支持稍老一些的瀏覽器是很重要的。
幸運的是,所有的打包器(配合上面提到的插件)都會確保你模塊的代碼運行在 Worker 中,即使瀏覽器不支持 Module Worker。從這個意義上來說,使用打包器可以被看作是對 Module Worker 的 polyfill。
未來
我喜歡 Actor 模式。但在 JavaScript 中的併發 設計的並不是很好。我們構建了很多的 工具 和 庫 來彌補,但終究這是 JavaScript 應該在語言層面上去完成的。一些 TC39 的工程師對這個話題很感興趣,他們正嘗試找到讓 JavaScript 更好的支持這兩種模式的方式。
目前多個相關的提案都在評估中,比如 允許代碼被 postMessage 傳輸,比如 能夠使用 高階的,類似調度器的 API (這在 Native 上很常見) 來在線程間共享對象。
這些提案目前沒還有在 標準化流程中 取得非常重大的進展,所以我不會在這裏花時間深入討論。如果你很好奇,你可以關注 TC39 提案 ,看看下一代的 JavaScript 會包含哪些內容。
總結
Worker 是保證主線程 靈敏 和 流暢 的關鍵工具,它通過防止長時間運行代碼阻塞瀏覽器渲染來保證這一點。由於和 Worker 通信 存在 內在的異步性,所以採用 Worker 需要對應用程序的架構進行一些調整,但作爲回報,你能更輕鬆的支持各種性能差距巨大的設備來訪問。
你應該確保使用一種 方便遷移代碼的架構,這樣你就能 測算 非主線程架構 帶來的性能影響。Web Worker 的設計會導致一定的學習曲線,但是最複雜的部分可以被 Comlink 這樣的庫抽象出來。
FAQ
總會有人提出一些常見的問題和想法,所以我想先發制人,將我的答案記錄在這裏。
postMessage 不慢嗎?
我針對所有性能問題的核心建議是:先測算!在你測算之前,沒有快慢一說。但根據我的經驗,postMessage 通常已經 “足夠快” 了。這是我的一個經驗法則:如果 JSON.stringify(messagePayload) 的參數小於 10kb,即使在速度最慢的手機上,你也不用擔心會導致卡幀。如果 postMessage 真的成爲了你應用程序中的瓶頸,你可以考慮下面的技巧:
-
將你的任務拆分,這樣你就可以發送更小的信息
-
如果消息是一個狀態對象,其中只有很小一部分發生改變,那就只發送變更的部分而不是整個對象
-
如果你發送了很多消息,你可以嘗試將多條消息整合成一條
-
最終手段,你可以嘗試將你的信息轉化爲 數字表示,並轉移 ArrayBuffers 而不是 基於對象的消息
我想從 Worker 中訪問 DOM
我收到了很多類似這樣的反饋。然而,在大多數情況下,這只是把問題轉移了。你有也許能有效地創建第二個主線程,但你還會遇到相同的問題,區別在於這是在不同的線程中。爲了讓 DOM 在多線程中安全訪問,就需要增加鎖,這將導致 DOM 操作的速度降低,還可能會損害很多現有的 Web 應用。
另外,同步模型其實也是有優點的。它給了瀏覽器一個清晰的信號 — 什麼時候 DOM 處於可用狀態,能夠被渲染到屏幕上。在一個多線程的 DOM 世界,這個信號會丟失,我們就不得不手動處理 部分渲染的邏輯 或是 什麼其他的邏輯。
我真的不喜歡爲了使用 Worker 把我的代碼拆分成獨立的文件
我同意。TC39 中有一些提案正在被評議,爲了能夠將一個模塊內聯到另一個模塊中,而不會像 Data URL 和 Blob URL 一樣有那麼多小問題。雖然目前還沒有一個令人滿意的解決方案,但是未來 JavaScript 肯定會有一次迭代解決這個問題。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/p12atJZ79NDe1XmbcCPhig