原文链接: Is postMessage slow?
不,事实上,这得视情况而定。
“慢” 意味着什么?我之前说过,在这里我要再次强调:如果你没有衡量过它,那么就不能说它慢。而且即使你去衡量了,失去了具体的场景,这些性能数字也没有什么意义。
实际上,现在的事实是大家因为担心 postMessage() 的性能,甚至连 Web Workers 都不敢考虑。当然这也意味着它的确适合调研一番。我上一篇关于此话题的一些研究也得到了这样的反馈。我们用数字来实质性地研究一下 postMessage() 的性能,然后再来看看到底是哪里可能会造成崩溃。也可以看看 postMessage() 这个小可爱如果真的造成了性能问题该怎么办。
准备好了吗?我们开始吧。
postMessage是如何工作的?
在我们打分之前,我们得先了解一下 postMessage() 究竟是什么东西和它其中哪部分值得我们去打分。另外,我们也必须停止去收集没有意义的数据并且得出没有意义的结论。
postMessage() 实际上是 HTML spec 的一个特性(不是 ECMA-262! 的), 在我上一篇关于深拷贝的博客中有提到过,postMessage() 依靠结构化克隆将消息从一个 JavaScript 作用域拷贝到另外一个。如果我们仔细研究 postMessage() 的规范,会发现其实结构化克隆是一个分两步的过程:
结构化克隆算法
-
在消息上运行 StructuredDeserialize() 算法
-
创建一个消息事件并且派发一个带着从接收端口返回的反序列化消息的消息事件。
这是一个简化版的算法讲解,在这其中我们只需要关注我们这篇博客关心的部分。这个描述在技术上讲其实并不准确,但其实已经抓住了核心。例如,StructuredSerialize() 和 StructuredDeserialize() 事实上不是真正意义的函数,而且它们至今为止也没有由 JavaScript 来暴露出来。那么这两个函数究竟做了什么呢?现在,你可以把 StructuredSerialize() 和 StructuredDeserialize() 想象成某种程度上智能版的 JSON.stringify() 和 JSON.parse()。它们智能化在可以更好地处理周期性的数据结构,比如用 Map, Set 和 ArrayBuffer 这些结构构建的数据。但是这些智能性会不会带来代价? 我们等会儿回来讨论。
关于上面的两个算法中没有明确地说出来的是,serialization 算法决定消息发送方的作用域,deserialization 算法决定接收方的作用域。多说一点:事实上 Chrome 和 Safari 在实际接触到消息事件的 data 属性之前都不运行 StructuredDeserialize(),Firefox 在派发事件之前都不处理 deserializes。
注意:这两种执行方式都是符合规范并且准确有效的。我用 Mozilla 打开的时候有一个 bug, 所以我询问了Mozilla的开发者他们是不是愿意调整他们的实现方式,因为这样开发人员就可以控制 deserializing 带来的巨大负担从而造成的 “性能打击” 。
有了这些东西在脑子里,我们现在得选择一个基准,我们可以从端到端去衡量,而这实质上就是去衡量一个任务发送到主线程的时间。然而,这个数字是用来捕捉到执行在两个不同作用域中的 serialization 和 deserialization 的时间总和,记住:这其中所有的工作的原动力都是想要保持主线程的空闲和及时响应。当然,我们也可以通过把标准限定在 Chrome 和 Safari 中需要多长时间可以获取到 data 属性来间接单独衡量StructuredDeserialize(),这样我们也可以同时得到 Firefox 的数据。当然我也没有找到一种可以直接单独衡量 StructuredSerialize() 的方法,因为没有办法跟踪执行。
这两种方法都不是最理想的,但是秉承着构建一个灵活的 web app 的思想,我决定使用这种端到端的基准为 postMessage() 设置一个上限。
我们只是从概念上理解 postMessage() 并且以测量性能为目标,因此我将会使用 ”微基准“,所以请注意这些数字和现实之间的差距。
基准一:发送消息需要多长时间
深度和广度的范围是从 1 到 6,对于每个排列,将生成 1000 个对象。
这个基准会生成带有特定的 “深度” 和 “广度” 的对象,深度和广度的值分布在从 1 到 6 这个区间上。每一层次深度和广度的组合,都会通过 postMessage 发送 1000 个对象从任务到主线程。这些对象的属性名是以字符串形式展示的随机生成的十六进制数字,属性值则是十六进制形式的随机布尔值、随机浮点数或者也是一个随机的十六进制字符串。这个基准将会测量出转换时间并计算出第95个百分位数
结果
这是标准分别运行在 Firefox、Safari 和 2018 MacBook Pro、Pixel 3XL、Nokia 2 上的 Chrome 的结果。
注意:你可以找出基准数据,通过自己编写程序来生成它并且也可以直观地感受到数据中的要点。这也是我人生中第一次写 Python,求轻喷。
Pixel 3 以及特别是 Safari 上的数字会让你觉得有一点困惑,所有浏览器发现崩溃的时候会禁用 SharedArrayBuffer,并且会降低计时器的精度。我用来测量的函数 performance.now() 就受到了这个限制。然而由于 Chrome 在桌面版上做了站点隔离,所以只有它能够恢复这些改变。更准确地说就是浏览器是使用以下的值来掌握 performance.now() 的精度的:
-
Chrome(桌面版): 5µs
-
Chrome(Android 版): 100µs
-
Firefox(桌面版): 1ms(我将 clamping 当作一个禁用标志)
-
Safari(桌面版): 1ms
数据中显示出一个对象的复杂度直接决定了这个对象序列化及反序列化的难度。不过这并不出乎意料:序列化和反序列化的过程都是将整个对象从一种转化为另外一种。这份数据也进一步暗示出了 JSON 形式的对象能够让我们更好地预料到转换这个对象需要多久。
基准2:是什么使得 postMessage 变慢?
为了验证这一点,我修改了基准: 我生成了横纵坐标中从1到6所有的排列,但是除此之外所有的叶子属性都会拥有一个长度从16 byte 到 2KB之间的字符串值。
结果
转换时间和 JSON.stringify 返回的字符串长度有很强的关联性。我认为这种关联性已经足够去总结出一条规则:字符串化的 JSON 对象和它的转换时间大致成比例。然而更重要的是事实上这种关联性只对非常大的对象有明显的作用,这里我对于 “大” 的标准是指超过 100KB 的。在对象比较小的时候, 方差就很大了, 相关性也更弱。
评估过程:关于发送一条消息
我们现在有了数据,但是如果我们不将它放在一定的情境下它就没有意义。如果我们想要得出一个有意义的结论,我们需要去给 ”慢“ 下一个定义。预算在这里是一个非常有用的工具,所以我将再一次回到 RAIL 指南去建立我们的预案。
在我的经验中,一个 web 任务的关键职责是,至少要保证的是,管理你的 app 的状态对象。当用户与你的 app 进行交互的时候,状态经常会进行改变。根据 RAIL 中的原则,我们有 100ms 对用户的交互行为做出反应。这也意味着,即使在最慢的设备上,你也可以用 postMessage() 在你的预案内发送 100KB 大小的对象。
但是如果有 JS 动画, 情况会发生改变。因为在视觉上的每一次改变都会更新每一帧,所以 RAIL 预案在动画上是 16ms。如果我们的任务阻塞主线程超过这个时间的话,就会出问题。把注意力放在我们基准中的数字上,所有只有 10KiB 的东西都不会对你的动画的流畅度发起挑战。之前已经说过,这就是为什么,比起需要占用主线程的 JS 驱动的动画,我们更喜欢 CSS 的动画。CSS 的动画和转换运行在另外一个线程上,并且不会被主线程的阻塞所打扰。
一定要发送更多的数据
在我的经验中,对于离主线程架构的众多 app 而言,postMessage() 都不是一个瓶颈。然而我会承认,当你的信息不是很大或者你需要在一个高流畅度的情况下发送消息,你需要额外的准备。如果原生 postMessage() 对你来说太慢应该怎么办呢?
补丁
例如状态对象,对象本身可能就非常的庞大,但是通常只有少量深层嵌套的属性会发生变化。我们在 PROXX 中会遇到这种情况。我们 ”扫雷“ 游戏的克隆,这个游戏的状态由一个二维数组构成游戏栅格。每一个格子都存储着这里是否有雷的状态,以及这里是否被触发过或者是否被标旗。
interface Cell {
hasMine: boolean;
flagged: boolean;
revealed: boolean;
touchingMines: number;
touchingFlags: number;
}
复制代码
这意味着最大的 40×40 的栅格可以构成 134KiB 的 JSON 对象。毫无疑问我们要发送整个的状态对象。
我们不再对任意一个改变发送整个对象,而是记录改变并发送补丁取而代之。我们过去没有使用 ImmerJS,这是一个和不可变的对象一起使用的库。它确实提供了一个快捷的方式去生成和继承这样的补丁。
// worker.js
immer.produce(stateObject, draftState => {
// Manipulate `draftState` here
}, patches => {
postMessage(patches);
});
// main.js
worker.addEventListener("message", ({data}) => {
state = immer.applyPatches(state, data);
// React to new state
}
复制代码
这个代码块补丁由 ImmerJS 生成像下面这样:
[
{
"op": "remove",
"path": [ "socials", "gplus" ]
},
{
"op": "add",
"path": [ "socials", "twitter" ],
"value": "@DasSurma"
},
{
"op": "replace",
"path": [ "name" ],
"value": "Surma"
}
]
复制代码
这意味着需要转化的地方实际上和你做改变的大小是一样的,而不是你对象的大小。
块
就像我之前说的,状态对象的改变实际上是很小一部分属性的改变。但也不总是这样。事实上,PROXX 有一个对于补丁设置非常大的设想。扫雷中第一次挖雷会对 80% 的游戏区域有影响,这会将补丁设置增加到差不多 70KiB。对于功能手机而言,这负担很重,尤其是我们有 JS 的 WebGL 动画运行的时候。
我们问了我们自己一个架构问题,我们的 app 能支持局部更新吗?补丁设置就是补丁的集合。除了一次性将补丁集合发送出去外,你也可以将补丁集合切割成更小的块,然后依序继承。在第一条信息中发送补丁 1-10, 在下一条中发送 10-20,依次进行。如果你运用的很好,你可以高效地运输你的补丁,这允许你在响应式编程中运用任何你喜欢或者你知道的形式。
当然,如果你不注意的话这也有可能会破坏或者影响你的视觉效果。然而,如果你能够掌握如何切割并且记录补丁,你就可以避免你不想要的影响。例如,你可以保证第一个块内包含所有补丁中屏幕可见的元素。并且将剩下的补丁放入补丁集来给主线程喘息的空间。
我们会在 PROXX 中分块。当用户点击一个区域,当前任务会遍历整个栅格并且将所有需要改变的区域变成一个列表。如果这个列表的大小超过一个临界点,我们会将我们现在已有的列表发送给主线程,将列表置空并继续遍历游戏区域。这些补丁集都足够小并且甚至一个功能性手机的 postMessage() 的消耗也是可控的。我们在主线程也有足够的时间预算来控制我们游戏的 UI。这个遍历算法由第一个图块开始向外延伸,这意味着我们的补丁也是以同样的方式排序的。如果说一个主线程在帧上的预算只能适应一条消息的话(像 Nokia 8110),部分更新会伪装成显示动画。如果我们在一个功能更强大的机器上,主线程会持续更新消息事件直到它的预算被 JS 事件循环消耗完。
经典手法:在 [PROXX] 中,补丁集中的块看起来像是一个动画,这在低端手机中尤其明显,或者在6x CPU的桌面电脑上。
或者是JSON ?
JSON.parse() 和 JSON.stringify() 快得令人难以置信。JSON 是 JS 的一个小子集。所以解析器只需要控制很少的意外。因为被频繁地使用,所以他们也被极大的优化了。 Mathias 最近指出,如果一个很大的对象被 JSON.parse转换过之后,就可以降低你转化它的时间。我们也许可以使用 JSON 来提升 postMessage() 的时间?可惜的是,并不能。
对比手动 JSON 序列化的性能和 原生的 postMessage() 没有明确的结果。
这两者之间没有明确的胜者,原生 postMessage() 在最好的用例中表现地更好,在最差的用例中也表现地最差。
二进制格式
另一种处理结构化克隆的影响的方式就是根本不用它。除了结构化克隆对象,postMessage() 也可以对特定的一种类型进行转化。ArrayBuffer 就是一种可转化类型。顾名思义,转换 ArrayBuffer 并不涉及到拷贝。发送方实际上已经失去了对缓冲区的访问权限,它现在被接收方所拥有。转换 ArrayBuffer 的速度非常快并且与它的大小没有关系。缺点就是 ArrayBuffer 是缓存的连续集合块。我们不再以对象或者属性的方式进行工作。如果我们想要 ArrayBuffer 起作用那么我们必须决定我们的数据如何整理自己。如果这在数据中做是非常浪费的,但是如果在数据构建的时候我们就知道数据的形式,我们也许可以运用许多我们在克隆算法中无法使用的优化方法。
一种可以进行优化的操作就是 FlatBuffers 形式的。FlatBuffers 拥有能够编译 JS 和其他语言的能力,并且能够把他们从模型描述转化为代码。代码中会包含能够序列化和反序列化你的数据的方法。更有趣的是:FlatBuffers 不需要去解析整个 ArrayBuffer 并且返回它包含的形式。
WebAssembly
每个人都喜欢的 WebAssembly。一种方法是使用WebAssembly查看其他语言生态系统中的序列化库。CBOR,一种由JSON 启发的二进制形式,已经被许多语言继承了。ProtoBuffers和前面提到的FlatBuffers也具有广泛的语言支持。
然而我们可以更厚脸皮一点,我们可以依靠语言的内存布局作为序列化格式。我写了一个 Rust 的小例子:它定义了一个状态结构,(无论您的应用状态如何,都具有象征意义),这里面有 setter 和 getter 函数,所以我可以追踪和操纵 JS 的状态。为了序列化状态对象,我只是复制了该结构占用的内存块。为了反序列化,我收集了一个新的状态对象,并且用反序列化函数重写了其中的数据。因为我在这两个例子中使用了相同的 WebAssembly 对象,所以内存分布是完全相同的。
这只是一个概念证明,如果你的结构中包含指针,则可以轻松地利用未定义的行为。现在仍然存在一些不必要的编码行为,请认真编码!
pub struct State {
counters: [u8; NUM_COUNTERS]
}
#[wasm_bindgen]
impl State {
// Constructors, 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 模块在压缩之后大约有 3KiB 大。这其中很多的空间占用都来源于内存管理和关键的库函数。在发生改变时,整体的状态对象会被发送出去。但是由于 ArrayBuffer 的转换能力,这代价其实是很低的。换句话说:这项技术可以忽略掉状态对象的大小,几乎让转换时间保持一定。 但它同时也将在获取数据这里消耗更多的时间。总是有无法规避的缺点 !!!
这项技术也要求状态对象不要使用类似于指针这种数据结构。因为这些值在被拷贝给一个新的 WebAssembly 模块实例的时候会不可用。因此,你可能需要一个高阶语言来实现这个方法。我的推荐是 C语言、Rust 和 AssemblyScript,当然你得能够对内存的管理有绝对掌控能力。
SABs & WebAssembly
小心:这一部分是以 SharedArrayBuffer 作为基础的,它除了桌面 Chrome 以外都不支持。这项工作尚在进行中,但无法提供任何预计完成时间(ETA)。
特别是对于游戏开发者来说,我已经听到了很多想要给 JS 增加跨线程分享对象能力的诉求。我认为这不应该添加给语言本身,因为它打破了 JS 引擎的基本预设之一。然而,SharedArrayBuffer (“SABs”)就是其中的一个意外。SABs 的行为和 ArrayBuffers 很像,不同的是它在被转换时,一个域不会丢失访问权限。他们会被克隆并且这两个域对于相同的内存块都有访问权限。SABs 允许 JS 去接收一个可分享的内存模块。为了域的同步, Atomics 会提供互斥的自动化操作。
使用 SABs,你只要在你的 app 启动的时候去转换内存块。然而,除了二进制展示的问题,你可以用 Atomics 去避免一个域在读取状态对象的同时,另外一个域仍然在写入,反之亦然。这样可以照顾到性能的影响。
既然用 SABs 手动序列化和反序列化数据是一个选择,你也可以包含线程化的 WebAssembly。WebAssembly 对于线程有标准支持,但是这受控于 SABs 的可用性。使用了线程化的 WebAssembly,您可以使用与线程编程语言完全相同的模式来编写代码。这当然取决于开发复杂度的花费,业务流程、和可能需要交付的更大的整体的模块。
总结:
这是我的判断:即使在最慢的设备上,你也可以在使用最大 100KiB 的对象和 100ms 的返回预算的基础上使用 postMessage(),如果你有 JS 动画,10KiB 以下的负载是安全的,这对大多数的 app 都足够了。postMessage() 确实有花销,但是在一定程度上它不会使非主线程体系结构崩溃。
如果你的负载大于上述,你可以试着用补丁或者将你的数据转化为二进制形式。考虑到状态布局和补丁能力是可以帮助你的 app 能够在更多的设备型号上运行的基础架构能力。 如果你觉得共享内存模块是你最好的选择,WebAssembly 将会在未来为你开启这个道路。
就像我在之前的博客中暗示关于 Actor Model 的那样,我强烈相信我们今天已经可以高效地在 web 上使用脱离主线程的工具。但是这要求我们走出线程语言的舒适区,并且网络默认为全部。我们也需要去继续探索那些包含统一 Web 和 JS 的可选择的结构和模型,这些好处是很值得的。