[譯] postMessage 很慢嗎?

postMessage 很慢嗎?

不,不必定(視狀況而定)html

這裏的「慢」是什麼意思呢?我以前在這裏說起過,在這裏再說一遍:若是你不度量它,它並不慢,即便你度量它,可是沒有上下文,數字也是沒有意義的。前端

話雖如此,人們甚至不會考慮採用 Web Workers,由於他們擔憂 postMessage() 的性能,這意味着這是值得研究的。個人上一篇博客文章也獲得了相似的回覆。讓咱們將實際的數字放在 postMessage() 的性能上,看看你會在何時冒着超出承受能力的風險。若是連普通的 postMessage() 在你的使用場景下都太慢,那麼你還能夠作什麼呢?android

準備好了嗎?繼續往下閱讀吧。ios

postMessage 是怎麼工做的?

在開始度量以前,咱們須要瞭解什麼是 postMessage(),以及咱們想度量它的哪一部分。不然,咱們最終將收集無心義的數據並得出無心義的結論。git

postMessage()HTML規範 的一部分(而不是 ECMA-262!)正如我在 deep-copy 一文中提到的,postMessage() 依賴於結構化克隆數據,將消息從一個 JavaScript 空間複製到另外一個 JavaScript 空間。仔細研究一下 postMessage() 的規範,就會發現結構化克隆是一個分兩步的過程:github

結構化克隆算法

  1. 在消息上執行 StructuredSerialize()
  2. 在接收方中任務隊列中加入一個任務,該任務將執行如下步驟:
    1. 在序列化的消息上執行 StructuredDeserialize()
    2. 建立一個 MessageEvent 並派發一個帶有該反序列化消息的 MessageEvent 事件到接收端口上

這是算法的一個簡化版本,所以咱們能夠關注這篇博客文章中重要的部分。雖然這在技術上是不正確的,但它卻抓住了精髓。例如,StructuredSerialize()StructuredDeserialize() 在實際場景中並非真正的函數,由於它們不是經過 JavaScript(不過有一個 HTML 提案打算將它們暴露出去)暴露出去的。那這兩個函數其實是作什麼的呢?如今,你能夠將 StructuredSerialize()StructuredDeserialize() 視爲 JSON.stringify()JSON.parse() 的智能版本。從處理循環數據結構、內置數據類型(如 MapSetArrayBuffer)等方面來講,它們更聰明。可是,這些聰明是有代價的嗎?咱們稍後再討論這個問題。web

上面的算法沒有明確說明的是,序列化會阻塞發送方,而反序列化會阻塞接收方。 另外還有:Chrome 和 Safari 都推遲了運行 StructuredDeserialize(),直到你實際訪問了 MessageEvent 上的 .data 屬性。另外一方面,Firefox 在派發事件以前會反序列化。算法

注意: 這兩個行爲都是兼容規範的,而且徹底有效。我在 Mozilla 上提了一個bug,詢問他們是否願意調整他們的實現,由於這可讓開發人員去控制何時應該受到反序列化大負載的「性能衝擊」。typescript

考慮到這一點,咱們必須選擇對什麼來進行基準測試:咱們能夠端到端進行度量,因此能夠度量一個 worker 發送消息到主線程所花費的時間。然而,這個數字將捕獲序列化和反序列化的時間總和,可是它們卻分別發生在不一樣的空間下。記住:與 worker 的整個通訊的都是主動的,這是爲了保持主線程自由和響應性。 或者,咱們能夠將基準測試限制在 Chrome 和 Safari 上,並單獨測量從 StructuredDeserialize() 到訪問 .data 屬性的時間,這個須要把 Firefox 排除在基準測試以外。我尚未找到一種方法來單獨測量 StructuredSerialize(),除非運行的時候調試跟蹤代碼。這兩種選擇都不理想,但本着構建彈性 web 應用程序的精神,我決定運行端到端基準測試,爲 postMessage() 提供一個上限。編程

有了對 postMessage() 的概念理解和評測的決心,我將使用 ☠️ 微基準 ☠️。請注意這些數字與現實之間的差距。

基準測試 1:發送一條消息須要花費多少時間?

Two JSON objects showing depth and breadth

深度和寬度在 1 到 6 之間變化。對於每一個置換,將生成 1000 個對象。

基準將生成具備特定「寬度」和「深度」的對象。寬度和深度的值介於 1 和 6 之間。對於寬度和深度的每一個組合,1000 個惟一的對象將從一個 worker postMessage() 到主線程。這些對象的屬性名都是隨機的 16 位十六進制數字符串,這些值要麼是一個隨機布爾值,要麼是一個隨機浮點數,或者是一個來自 16 位十六進制數的隨機字符串。基準測試將測量傳輸時間並計算第 95 個百分位數。

測量結果

這一基準測試是在 2018 款的 MacBook Pro上的 Firefox、 Safari、和 Chrome 上運行,在 Pixel 3XL 上的 Chrome 上運行,在 諾基亞 2 上的 Chrome 上運行。

注意: 你能夠在 gist 中找到基準數據、生成基準數據的代碼和可視化代碼。並且,這是我人生中第一次編寫 Python。別對我太苛刻。

Pixel 3 的基準測試數據,尤爲是 Safari 的數據,對你來講可能有點可疑。當 Spectre & Meltdown 被發現的時候,全部的瀏覽器會禁用 SharedArrayBuffer 並將我要測量使用的 performance.now() 函數實行計時器的精度減小。只有 Chrome 可以還原這些更改,由於它們將站點隔離發佈到 Chrome 桌面版。更具體地說,這意味着瀏覽器將 performance.now() 的精度限制在如下值上:

  • Chrome(桌面版):5µs
  • Chrome(安卓系統):100µs
  • Firefox(桌面版):1ms(該限制能夠禁用掉,我就是禁用掉的)
  • Safari(桌面版):1ms

數據顯示,對象的複雜性是決定對象序列化和反序列化所需時間的重要因素。這並不奇怪:序列化和反序列化過程都必須以某種方式遍歷整個對象。數據還代表,對象 JSON 化後的大小能夠很好地預測傳輸該對象所需的時間。

基準測試 2:什麼致使 postMessage 變慢了?

爲了驗證這個,我修改了基準測試:我生成了寬度和深度在 1 到 6 之間的全部排列,但除此以外,全部葉子屬性都有一個長度在 16 字節到 2 KiB 之間的字符串值。

測試結果

A graph showing the correlation between payload size and transfer time for postMessage

傳輸時間與 JSON.stringify() 返回的字符串長度有很強的相關性。

我認爲這種相關性足夠強,能夠給出一個經驗法則:對象的 JSON 字符串化後的大小大體與它的傳輸時間成正比。 然而,更須要注意的事實是,這種相關性只與大對象相關,我說的大是指超過 100 KiB 的任何對象。雖然這種相關性在數學上是成立的,但在較小的有效載荷下,這種差別更爲明顯(譯者注:懷疑這句話做者應該是寫錯了,應該表述爲差別不明顯)。

評估:發送一條信息

咱們有數據,但若是咱們不把它上下文化,它就沒有意義。若是咱們想得出有意義的結論,咱們須要定義「慢」。預算在這裏是一個有用的工具,我將再次回到 RAIL 指南來肯定咱們的預期。

根據個人經驗,一個 web worker 的核心職責至少是管理應用程序的狀態對象。狀態一般只在用戶與你的應用程序交互時纔會發生變化。根據 RAIL 的說法,咱們有 100 ms 來響應用戶交互,這意味着即便在最慢的設備上,你也能夠 postMessage() 高達 100 KiB 的對象,並保持在你的預期以內。

當運行 JS 驅動的動畫時,這種狀況會發生變化。動畫的 RAIL 預算是 16 ms,由於每一幀的視覺效果都須要更新。若是咱們從 worker 那裏發送一條消息,該消息會阻塞主線程的時間超過這個時間,那麼咱們就有麻煩了。從咱們的基準數據來看,任何超過 10 KiB 的動畫都不會對你的動畫預算構成風險。也就是說,這就是咱們更喜歡用 CSS animation 和 transition 而不是 JS 驅動主線程繪製動畫的一個重要緣由。 CSS animation 和 transition 運行在一個單獨的線程 - 合成線程 - 不受阻塞的主線程的影響。

必須發送更多的數據

以個人經驗,對於大多數採用非主線程架構的應用程序來講,postMessage() 並非瓶頸。不過,我認可,在某些設置中,你的消息可能很是大,或者須要以很高的頻率發送大量消息。若是普通 postMessage() 對你來講太慢的話,你還能夠作什麼?

打補丁

在狀態對象的狀況下,對象自己可能很是大,但一般只有少數幾個嵌套很深的屬性會發生變化。咱們在 PROXX 中遇到了這個問題,咱們的 PWA 版本掃雷:遊戲狀態由遊戲網格的二維數組組成。每一個單元格存儲這些字段:是否有雷,以及是被發現的仍是被標記的。

interface Cell {
  hasMine: boolean;
  flagged: boolean;
  revealed: boolean;
  touchingMines: number;
  touchingFlags: number;
}
複製代碼

這意味着最大的網格( 40 × 40 個單元格)加起來的 JSON 大小約等於 134 KiB。發送整個狀態對象是不可能的。咱們選擇記錄更改併發送一個補丁集,而不是在更改時發送整個新的狀態對象。 雖然咱們沒有使用 ImmerJS,這是一個處理不可變對象的庫,但它提供了一種快速生成和應用補丁集的方法:

// worker.js
immer.produce(stateObject, draftState => {
  // 在這裏操做 `draftState`
}, patches => {
  postMessage(patches);
});

// main.js
worker.addEventListener("message", ({data}) => {
  state = immer.applyPatches(state, data);
  // 對新狀態的反應
}
複製代碼

ImmerJS 生成的補丁以下所示:

[
  {
    "op": "remove",
    "path": [ "socials", "gplus" ]
  },
  {
    "op": "add",
    "path": [ "socials", "twitter" ],
    "value": "@DasSurma"
  },
  {
    "op": "replace",
    "path": [ "name" ],
    "value": "Surma"
  }
]
複製代碼

這意味着須要傳輸的數據量與更改的大小成比例,而不是與對象的大小成比例。

分塊

正如我所說,對於狀態對象,一般只有少數幾個屬性會改變。但並不是老是如此。事實上,PROXX 有這樣一個場景,補丁集可能會變得很是大:第一個展現可能會影響多達 80% 的遊戲字段,這意味着補丁集有大約 70 KiB 的大小。當目標定位於功能手機時,這就太多了,特別是當咱們可能運行 JS 驅動的 WebGL 動畫時。

咱們問本身一個架構上的問題:咱們的應用程序能支持部分更新嗎?Patchsets 是補丁的集合。你能夠將補丁集「分塊」到更小的分區中,並按順序應用補丁,而不是一次性發送補丁集中的全部補丁。 在第一個消息中發送補丁 1 - 10,在下一個消息中發送補丁 11 - 20,以此類推。若是你將這一點發揮到極致,那麼你就能夠有效地讓你的補丁流式化,從而容許你使用你可能知道的設計模式以及喜好的響應式編程。

固然,若是你不注意,這可能會致使不完整甚至破碎的視覺效果。然而,你能夠控制分塊如何進行,並能夠從新排列補丁以免任何不但願的效果。例如,你能夠確保第一個塊包含全部影響屏幕元素的補丁,並將其他的補丁放在幾個補丁集中,以給主線程留出喘息的空間。

咱們在 PROXX 上作分塊。當用戶點擊一個字段時,worker 遍歷整個網格,肯定須要更新哪些字段,並將它們收集到一個列表中。若是列表增加超過某個閾值,咱們就將目前擁有的內容發送到主線程,清空列表並繼續迭代遊戲字段。這些補丁集足夠小,即便在功能手機上, postMessage() 的成本也能夠忽略不計,咱們仍然有足夠的主線程預算時間來更新咱們的遊戲 UI。迭代算法從第一個瓦片向外工做,這意味着咱們的補丁以相同的方式排列。若是主線程只能在幀預算中容納一條消息(就像 Nokia 8110),那麼部分更新就會假裝成一個顯示動畫。若是咱們在一臺功能強大的機器上,主線程將繼續處理消息事件,直到超出預算爲止,這是 JavaScript 的事件循環的天然結果。

視頻連接:dassur.ma/things/is-p…

經典手法:在 [PROXX] 中,補丁集的分塊看起來像一個動畫。這在支持 6x CPU 節流的臺式機或低端手機上尤爲明顯。

也許應該 JSON?

JSON.parse()JSON.stringify() 很是快。JSON 是 JavaScript 的一個小子集,因此解析器須要處理的案例更少。因爲它們的頻繁使用,它們也獲得了極大的優化。Mathias 最近指出,有時能夠經過將大對象封裝到 JSON.parse() 中來縮短 JavaScript 的解析時間。也許咱們也可使用 JSON 來加速 postMessage() ?遺憾的是,答案彷佛是否認的:

將發送對象的持續時間與序列化、發送和反序列化對象進行比較的圖

將手工 JSON 序列化的性能與普通的 postMessage() 進行比較,沒有獲得明確的結果。

雖然沒有明顯的贏家,可是普通的 postMessage() 在最好的狀況下表現得更好,在最壞的狀況下表現得一樣糟糕。

二進制格式

處理結構化克隆對性能影響的另外一種方法是徹底不使用它。除告終構化克隆對象外,postMessage() 還能夠傳輸某些類型。ArrayBuffer 是這些可轉換類型之一。顧名思義,傳輸 ArrayBuffer 不涉及複製。發送方實際上失去了對緩衝區的訪問,如今是屬於接收方的。傳輸一個 ArrayBuffer 很是快,而且獨立於 ArrayBuffer的大小。 缺點是 ArrayBuffer 只是一個連續的內存塊。咱們就不能再處理對象和屬性。爲了讓 ArrayBuffer 發揮做用,咱們必須本身決定如何對數據進行編組。這自己是有代價的,可是經過了解構建時數據的形狀或結構,咱們能夠潛在地進行許多優化,而這些優化是通常克隆算法沒法實現的。

一種容許你使用這些優化的格式是 FlatBuffers。Flatbuffers 有 JavaScript (和其餘語言)對應的編譯器,能夠將模式描述轉換爲代碼。該代碼包含用於序列化和反序列化數據的函數。更有趣的是:Flatbuffers 不須要解析(或「解包」)整個 ArrayBuffer 來返回它包含的值。

WebAssembly

那麼使用每一個人都喜歡的 WebAssembly 呢?一種方法是使用 WebAssembly 查看其餘語言生態系統中的序列化庫。CBOR 是一種受 json 啓發的二進制對象格式,已經在許多語言中實現。ProtoBuffers 和前面提到的 FlatBuffers 也有普遍的語言支持。

然而,咱們能夠在這裏更厚顏無恥:咱們能夠依賴該語言的內存佈局做爲序列化格式。我用 Rust 編寫了一個小例子:它用一些 getter 和 setter 方法定義了一個 State 結構體(不管你的應用程序的狀態如何,它都是符號),這樣我就能夠經過 JavaScript 檢查和操做狀態。要「序列化」狀態對象,只需複製結構所佔用的內存塊。爲了反序列化,我分配一個新的 State 對象,並用傳遞給反序列化函數的數據覆蓋它。因爲我在這兩種狀況下使用相同的 WebAssembly 模塊,內存佈局將是相同的。

這只是一個概念的證實。若是你的結構包含指針(如 VecString),那麼你就很容易陷入未定義的行爲錯誤中。同時還有一些沒必要要的複製。因此請對代碼負責任!

pub struct State {
    counters: [u8; NUM_COUNTERS]
}

#[wasm_bindgen]
impl State {
    // 構造器, getters and setter...

    pub fn serialize(&self) -> Vec<u8> {
        let size = size_of::<State>();
        let mut r = Vec::with_capacity(size);
        r.resize(size, 0);
        unsafe {
            std::ptr::copy_nonoverlapping(
                self as *const State as *const u8,
                r.as_mut_ptr(),
                size
            );
        };
        r
    }
}

#[wasm_bindgen]
pub fn deserialize(vec: Vec<u8>) -> Option<State> {
    let size = size_of::<State>();
    if vec.len() != size {
        return None;
    }

    let mut s = State::new();
    unsafe {
        std::ptr::copy_nonoverlapping(
            vec.as_ptr(),
            &mut s as *mut State as *mut u8,
            size
        );
    }
    Some(s)
}
複製代碼

注意: Ingvar 向我指出了 Abomonation,是一個嚴重有問題的序列化庫,雖然可使用指針的概念。他的建議:「不要使用這個庫!」。

WebAssembly 模塊最終 gzip 格式大小約爲 3 KiB,其中大部分來自內存管理和一些核心庫函數。當某些東西發生變化時,就會發送整個狀態對象,可是因爲 ArrayBuffers 的可移植性,其成本很是低。換句話說:該技術應該具備幾乎恆定的傳輸時間,而無論狀態大小。 然而,訪問狀態數據的成本會更高。老是要權衡的!

這種技術還要求狀態結構不使用指針之類的間接方法,由於當將這些值複製到新的 WebAssembly 模塊實例時,這些值是無效。所以,你可能很難在高級語言中使用這種方法。個人建議是 C、 Rust 和 AssemblyScript,由於你能夠徹底控制內存並對內存佈局有足夠的瞭解。

SAB 和 WebAssembly

提示: 本節適用於 SharedArrayBuffer,它在除桌面端的 Chrome 外的全部瀏覽器中都已禁用。這正在進行中,可是不能給出 ETA。

特別是從遊戲開發人員那裏,我聽到了多個請求,要求 JavaScript 可以跨多個線程共享對象。我認爲這不太可能添加到 JavaScript 自己,由於它打破了 JavaScript 引擎的一個基本假設。可是,有一個例外叫作 SharedArrayBuffer ("SABs")。SABs 的行爲徹底相似於 ArrayBuffers,可是在傳輸時,不像 ArrayBuffers 那樣會致使其中一方失去訪問權, SAB 能夠克隆它們,而且雙方均可以訪問到相同的底層內存塊。SABs 容許 JavaScript 空間採用共享內存模型。 對於多個空間之間的同步,有 Atomics 提供互斥和原子操做。

使用 SABs,你只需在應用程序啓動時傳輸一塊內存。然而,除了二進制表示問題以外,你還必須使用 Atomics 來防止其中一方在另外一方還在寫入的時候讀取狀態對象,反之亦然。這可能會對性能產生至關大的影響。

除了使用 SABs 和手動序列化/反序列化數據以外,你還可使用線程化的 WebAssembly。WebAssembly 已經標準化了對線程的支持,可是依賴於 SABs 的可用性。使用線程化的 WebAssembly,你可使用與使用線程編程語言相同的模式編寫代碼。固然,這是以開發複雜性、編排以及可能須要交付的更大、更完整的模塊爲代價的。

結論

個人結論是:即便在最慢的設備上,你也可使用 postMessage() 最大 100 KiB 的對象,並保持在 100 ms 響應預算以內。若是你有 JS 驅動的動畫,有效載荷高達 10 KiB 是無風險的。對於大多數應用程序來講,這應該足夠了。postMessage() 確實有必定的代價,但還不到讓非主線程架構變得不可行的程度。

若是你的有效負載大於此值,你能夠嘗試發送補丁或切換到二進制格式。從一開始就將狀態佈局、可移植性和可補丁性做爲架構決策,能夠幫助你的應用程序在更普遍的設備上運行。 若是你以爲共享內存模型是你最好的選擇,WebAssembly 將在不久的未來爲你鋪平道路。

我已經在一篇舊的博文上暗示 Actor Model,我堅信咱們能夠在現在的 web 上實現高性能的非主線程架構,但這須要咱們離開線程化語言的溫馨區以及 web 中那種默認在全部主線程工做的模式。咱們須要探索另外一種架構和模型,擁抱 Web 和 JavaScript 的約束。這些好處是值得的。

若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索