0. 背景
我之前在"如何评价ry(Ryan Dahl)的新项目deno?"的回答中曾经写到:
我比较好奇的是 deno 使用了 Protobuf,而没有使用 Mojo。既然目标是要兼容浏览器,却不使用 Mojo......
但是从 issue 中可以看出,Ryan Dahl 之前是没有听说过 Mojo 的,但是他看完 Mojo 之后,依然觉得 Protobuf 是正确的的选择。
Ryan Dahl 最初选择了 golang,后来又将 golang 从 deno 中彻底删除。前几天 Protobuf 的作者 Kenton Varda(kentonv) 开了一个 issue:Protobuf seems like a lot of overhead for this use case? #269,在文中 kentonv 指出:
I was surprised by the choice of Protobuf for intra-process communications within Deno. Protobuf's backwards compatibility guarantees and compact wire representation offer no benefit here, while the serialize/parse round-trip on every I/O seems like it would be pretty expensive.
大概意思是:kentonv 对于 Deno 选择 Protobuf 感到很吃惊,因为 Protobuf 的兼容性优势并不是 Deno 需要的,相反,Protobuf 的序列化和反序列化非常消耗 I/O 性能。
1. Cap'n Proto 性能
kentonv 离开 Google 之后开发了 Cap'n Proto。
Cap'n Proto 相比 Protobuf 到底有多快呢?10 倍?100 倍?1000倍?官网给出了一张对比图:
Cap'n Proto 的编码解码速度是 Protobuf 的 ∞ 倍。2333
其实这张图是个标题党,图中对比了两者的编码解码,但是在 Cap'n Proto 中,是根本不需要编码和解码(encoding/decoding)的。Cap'n Proto 编码后的数据格式直接存放在内存,数据结构跟在内存里面的布局保持一致,所以可以直接将编码好的结构根据字节存放到硬盘,或者通过网络传输。
这是不是意味这 Cap'n Proto 编码是特定于平台的?
不!Cap'n Proto 采用的按字节编码方案是独立于任何平台的,但在如今主流的通用 CPU 上面会有更好的性能。数据的组织类似于编译器对 struct 的组织形式:固定宽度,固定偏移,以及内存对齐,对于可变的数组元素使用指针,而指针也是使用的偏移存放而不是绝对地址。整数使用的是小端序,因为大多数现代 CPU 都是小端序的,甚至大端序的 CPU 通常有读取小端序数据的指令。
注:大端序(big-endian)和小端序(little-endian)统称为字节顺序。对于多字节数据,例如 32 位整数占据 4 字节,在不同的处理器中存放方式也不同,以内存中 0x0A0B0C0D
的存放方式为例:
在大端序中,如果数据以 8bit 为单位进行存储,则最高位字节 0x0A
存储在最低的内存地址处。
地址增长方向 →
0x0A, 0x0B, 0x0C, 0x0D
如果数据以 16bit 为单位进行存储,则最高的 16bit 单元 0x0A0B
存储在低位:
地址增长方向 →
0x0A0B, 0x0C0D
而小端序则与此相反。目前大多数主流 CPU 都是小端序的,这也是 Cap'n Proto 采用小端序的原因。
如果熟悉 C 或者 C++ 的结构体,可以看到 Cap'n Proto 的编码方式跟 struct 的内存结构很相似。即使在 V8 引擎内部,也是使用了类似的结构来进行属性的快速读取。相比使用 Hash Map 有很高的性能提升。
- 扩展阅读:开启 V8 对象属性的“fast”模式
2. 序列化/反序列化
Protobuf 每次都会构建一个用于表示 message 的对象,然后将对象序列化为 ArrayBuffer,在消息的接收方需要从缓冲区读取 message,然后解析为一个对应的对象,在之后的编程中使用该对象。而在 Cap'n Proto 中消息的结构直接存放在 ArrayBuffer 上,当我们调用 message.setFoo(123)
时,实际上就类似于 uint32Array[offset] = 123
,在消息的接收方,我们可以直接从缓冲区读取这条消息。
Protobuf 可以使用变宽的编码,这样对于某些场景可以有更小的编码长度。而 Cap'n Proto 为了性能考虑会把整数编码为固定宽度,额外的字节使用 0 进行填充(这种存储方式很类似于 memcached)。一个是以空间换时间,一个是以时间换空间。在通过网络发送消息时,我们希望消息体越小越好,但是如果在同一地址空间内通信时,则我们有无限带宽。Cap'n Proto 的文档中还指出,当带宽真的很重要时,无论您使用何种编码格式,都应该对消息体进行通用压缩,如 zlib 或 LZ4。
3. FlatBuffers
deno 的作者 ry 也在 issue 中参与了讨论,对大家的热情关注 ry 感到十分感动,然后。。。。然后创建了一个 flatbuffers 分支 :P
FlatBuffers 同样是一个 created at Google 的库,具有更加完善的文档以及 Benchmarks。而 Cap'n Proto 除了那个“无限倍速”的不公平测试外,没有任何的基准测试数据。
而 kentonv 对基准测试的态度是:
关于基准测试 - 我花了很多时间对序列化系统进行基准测试,不幸的是,我的结论是基准测试结果几乎总是毫无意义。...
一个真正有意义的基准测试,需要使用两种不同的序列化来编写两个版本的实际应用程序,并对它们进行比较......但这是几乎没有人做过的大量工作。
这确实是个大工程。相比而言,V8 和 Chrome 每次发布都会进行 Real-world JavaScript performance
4. 安全
在当前 deno 的 protobuf 使用上,每个消息都会创建一个副本。deno 使用 protobuf 只是为了在 V8 和其他特权代码之间通讯,即使真的明确需要一个消息副本,那么也可以直接使用 memcpy()
来达到更高的性能。
如果在同一个缓冲区(ArrayBuffer),当不同的线程同时操作时,则需要一个副本来防止 TOCTOU 漏洞,或者谨慎的处理 JavaScript 代码,但这是不可控的,因为你不能防止第三方模块也做相同的假设(如果第三方扩展也使用相同的通讯机制的化)。
TOCTOU 的全程是“time of check to time of use”,TOCTOU 是竞争条件缺陷的一种。在多线程、多核系统中,这个漏洞很普遍。当我们访问某个共享资源时,系统首先会检测当前用户或代码是否有权限,而检查(check)和使用(use)是分离的,而且不是原子的。当系统检查资源被授予用户权限后,攻击者可以临时阻塞调用户线程,然后在时间差内替换调资源,以达到越权访问的目的。
举个简单的例子:
if (hasPermission("file")) {
// (1)
buffer = open("file");
// (2) dosth
write("file", buffer);
// (3)
}
而攻击者可以在 (1)
处构造如下代码:
// ...
// hasPermission 检查通过
symlink("/etc/passwd", "file");
// 文件打开之前
// ...
这样用户就越权拿到了 "/etc/passwd"
的控制权。
上面只是一个简单的例子,TOCTOU 有很多不同的形式。在类 Unix 系统上,/tmp
和 /var/tmp
目录经常会被错误地使用,从而导致竞争条件。
为了安全而暂时损失性能是一种不得已的妥协,之前 V8 也遇到过,对于逃逸分析的漏洞直接导致了安全问题,Chrome 团队不得不在下一个发行版中去除了逃逸分析。
- 扩展阅读:V8 团队的一个错误,使得整个互联网变慢
5. 综上
Deno 就像一个出生不久的孩子,Ryan Dahl 也在不停的探索,难免会走一些弯路。而作为普通开发者的我们,可以关注 deno 的源码以及 github 上的 commit。
对于一个非常成熟的项目,比如 Node.js,我们很难读懂他的全部源码,甚至我们都不知道从何读起。而 deno 则是一个机会,我们见证了 deno 的诞生,截至到我写这篇文章,deno 一共才有 249 次 commit。