- 原文地址:Is postMessage slow?
- 原文做者:Surma
- 译文出自:掘金翻译计划
- 本文永久连接:github.com/xitu/gold-m…
- 译者:linxiaowu66
- 校对者:MarchYuanx, TiaossuP
不,不必定(视状况而定)html
这里的“慢”是什么意思呢?我以前在这里说起过,在这里再说一遍:若是你不度量它,它并不慢,即便你度量它,可是没有上下文,数字也是没有意义的。前端
话虽如此,人们甚至不会考虑采用 Web Workers,由于他们担忧 postMessage()
的性能,这意味着这是值得研究的。个人上一篇博客文章也获得了相似的回复。让咱们将实际的数字放在 postMessage()
的性能上,看看你会在何时冒着超出承受能力的风险。若是连普通的 postMessage()
在你的使用场景下都太慢,那么你还能够作什么呢?android
准备好了吗?继续往下阅读吧。ios
在开始度量以前,咱们须要了解什么是 postMessage()
,以及咱们想度量它的哪一部分。不然,咱们最终将收集无心义的数据并得出无心义的结论。git
postMessage()
是 HTML规范 的一部分(而不是 ECMA-262!)正如我在 deep-copy 一文中提到的,postMessage()
依赖于结构化克隆数据,将消息从一个 JavaScript 空间复制到另外一个 JavaScript 空间。仔细研究一下 postMessage()
的规范,就会发现结构化克隆是一个分两步的过程:github
StructuredSerialize()
StructuredDeserialize()
MessageEvent
并派发一个带有该反序列化消息的 MessageEvent
事件到接收端口上这是算法的一个简化版本,所以咱们能够关注这篇博客文章中重要的部分。虽然这在技术上是不正确的,但它却抓住了精髓。例如,StructuredSerialize()
和 StructuredDeserialize()
在实际场景中并非真正的函数,由于它们不是经过 JavaScript(不过有一个 HTML 提案打算将它们暴露出去)暴露出去的。那这两个函数其实是作什么的呢?如今,你能够将 StructuredSerialize()
和 StructuredDeserialize()
视为 JSON.stringify()
和 JSON.parse()
的智能版本。从处理循环数据结构、内置数据类型(如 Map
、Set
和ArrayBuffer
)等方面来讲,它们更聪明。可是,这些聪明是有代价的吗?咱们稍后再讨论这个问题。web
上面的算法没有明确说明的是,序列化会阻塞发送方,而反序列化会阻塞接收方。 另外还有:Chrome 和 Safari 都推迟了运行 StructuredDeserialize()
,直到你实际访问了 MessageEvent
上的 .data
属性。另外一方面,Firefox 在派发事件以前会反序列化。算法
注意: 这两个行为都是兼容规范的,而且彻底有效。我在 Mozilla 上提了一个bug,询问他们是否愿意调整他们的实现,由于这可让开发人员去控制何时应该受到反序列化大负载的“性能冲击”。typescript
考虑到这一点,咱们必须选择对什么来进行基准测试:咱们能够端到端进行度量,因此能够度量一个 worker 发送消息到主线程所花费的时间。然而,这个数字将捕获序列化和反序列化的时间总和,可是它们却分别发生在不一样的空间下。记住:与 worker 的整个通讯的都是主动的,这是为了保持主线程自由和响应性。 或者,咱们能够将基准测试限制在 Chrome 和 Safari 上,并单独测量从 StructuredDeserialize()
到访问 .data
属性的时间,这个须要把 Firefox 排除在基准测试以外。我尚未找到一种方法来单独测量 StructuredSerialize()
,除非运行的时候调试跟踪代码。这两种选择都不理想,但本着构建弹性 web 应用程序的精神,我决定运行端到端基准测试,为 postMessage()
提供一个上限。编程
有了对 postMessage()
的概念理解和评测的决心,我将使用 ☠️ 微基准 ☠️。请注意这些数字与现实之间的差距。
深度和宽度在 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()
的精度限制在如下值上:
数据显示,对象的复杂性是决定对象序列化和反序列化所需时间的重要因素。这并不奇怪:序列化和反序列化过程都必须以某种方式遍历整个对象。数据还代表,对象 JSON 化后的大小能够很好地预测传输该对象所需的时间。
为了验证这个,我修改了基准测试:我生成了宽度和深度在 1 到 6 之间的全部排列,但除此以外,全部叶子属性都有一个长度在 16 字节到 2 KiB 之间的字符串值。
传输时间与 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 的事件循环的天然结果。
经典手法:在 [PROXX] 中,补丁集的分块看起来像一个动画。这在支持 6x CPU 节流的台式机或低端手机上尤为明显。
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 查看其余语言生态系统中的序列化库。CBOR 是一种受 json 启发的二进制对象格式,已经在许多语言中实现。ProtoBuffers 和前面提到的 FlatBuffers 也有普遍的语言支持。
然而,咱们能够在这里更厚颜无耻:咱们能够依赖该语言的内存布局做为序列化格式。我用 Rust 编写了一个小例子:它用一些 getter 和 setter 方法定义了一个 State
结构体(不管你的应用程序的状态如何,它都是符号),这样我就能够经过 JavaScript 检查和操做状态。要“序列化”状态对象,只需复制结构所占用的内存块。为了反序列化,我分配一个新的 State
对象,并用传递给反序列化函数的数据覆盖它。因为我在这两种状况下使用相同的 WebAssembly 模块,内存布局将是相同的。
这只是一个概念的证实。若是你的结构包含指针(如
Vec
和String
),那么你就很容易陷入未定义的行为错误中。同时还有一些没必要要的复制。因此请对代码负责任!
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,由于你能够彻底控制内存并对内存布局有足够的了解。
提示: 本节适用于
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 连接。
掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 Android、iOS、前端、后端、区块链、产品、设计、人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划、官方微博、知乎专栏。