编译 | 核子可乐、褚杏娟
Josh Urbane 是一位从业多年的软件架构师,很喜欢在社交媒体分享技术观点。近日,他写了一篇文章,记录了自己凭借经验赢了与新人开发者打赌的故事,而“我的 JavaScript 比你的 Rust 更快”的结论也是来自这个打赌。他的故事或许可以说明运行策略在研发实践中的重要性。
对我来说,软件架构师这活儿最让人开心的一点就是能指导开发者理解最新的概念、影响他们的技术判断。有些开发者不是很嚣张吗,那就用理论加现实啪啪打他们的脸;架构师还得负责营造出寓教于乐的学习氛围,帮助年轻气盛的开发者逐渐长大成熟。
最会让我在心里暗爽的事儿就是一个愣头青开发者突然跳出来、想要挑战我的技术建议(从开发者的视角看,架构师就是一帮总在提「错误」建议的傻瓜),而且赌上全部身家坚持认为自己的办法更好。
问题是,我已经干这行很久了,不用验证我就知道问题的正确答案是什么。所以那就来呗,咱们手底下见真章,我把这段故事记录了下来、在几年后整理成了今天的这篇文章。
梭哈是一种“智慧”
老实讲,下面要讲的这个事已经过去好几年了,所以很多细节我已经记不清楚。大体情况就是结合当时团队的知识储备、可用工具库和原有技术债务,我给出的建议是让大家使用 Node.js。
一个新任初级开发者对自己刚拿到的计算机科学学士证书很有信心,想要用“炫技”的方式挫挫我的锐气。他们听说我是辅修的计算机科学,所以觉得我压根不了解计算机底层原理。其实刚毕业那会我也认为自己很懂,但这行干久了,我越来越觉得计算机系统像是魔法……
他的信心并非毫无来由,这个结论如同“C 比 JavaScript 速度快”,基本属于业界共识。但作为典型的架构师,我仍然坚持认为“要视情况而定”。
更具体地讲,“经过充分优化的 C ,确实比具有同等优化水平的 JavaScript 跑得更快”,毕竟 JavaScript 有着无法避免的执行开销(即便如此,我们也可以把代码编译成静态程序来获得高度接近 C 的性能)。反正话已至此,那就梭了呗。
意外的是,JavaScript 代码确实要比 C 版本更快一点,而且从架构设计的角度来看,JS 版本可以由当前团队一力维护、不需要借助其他部门的技术能力。
还好还好,其实我也不敢百分之百确定自己是对的,但考虑到这个用例中的内存对象大小可能是动态的、再加上那位年轻开发者确实经验不足,所以我愿意赌上一把。
JS 比 C 还快,怎么实现的?
我猜大多数开发者都理解不了这样的结果。这明显跟“编译”语言快于“解释”语言、“静态”程序快于“VM”程序的基本原则背道而驰啊。但请注意,这些只是经验、而非真理。
我之前也提到,“优化”才是决定速度的关键。毕竟即使 C 语言自身的性能优势再强,糟糕的编写质量也会让程序身陷泥潭。另一方面,Node.js(使用基于 C /C 的 V8 与 libuv 库)则更具优化空间,所以实际运行速度并不差。甚至可以说,质量同样差劲的 JS 和 C 程序,JS 的性能可能还更好一点。但这只是宏观论述,下面咱们来看点细节。
内存是关键
大多数开发者应该很熟悉栈和堆的概念,但这种理解基本只停留在了表面——例如只知道栈是线性的,而堆就是带有指针的“坨”(并非严格术语,大家能理解就行)。
更重要的是,栈和堆的概念对应着多种实现和方法。底层硬件并不知道“堆”是个什么东西,因为内存的管理方式是由软件来定义的,而内存管理方面的选择必然会对程序的最终性能产生巨大影响。
大家也可以就这个问题深挖下去,很有意义也很有价值。现代硬件和内核都相当复杂,其中往往包含大量具有特殊用途的优化机制,例如更高效地利用高级内存布局。这意味着软件可以(或者必须)借用由硬件提供的内存管理功能。此外还有虚拟化的影响……这里就不多做展开了。
魔法的核心:垃圾回收
没错,Node.js 解决方案的启动时间肯定更长,因为它需要通过 JIT 编译器来实现脚本的加载和运行。不过一旦加载完成,Node.js 代码其实反而拥有一项神秘的优势——垃圾回收机制。
而在 C 程序中,应用程序往往会在堆中创建动态大小的对象,之后再将其删除。这意味着程序的分配器必须一遍又一遍地在堆中分配和释放内存。这项操作本身速度较慢,而且实际性能基本由分配器中的算法决定。在多数情况下,dealloc 的速度会特别慢,即使是精简后的 alloc 也没好太多。
对于 Node.js 程序,这项绝技就是程序只运行一次就会退出。Node.js 同样运行脚本并分配必要的内存,但后面的删除操作会由垃圾回收器挑选空闲时间再推迟执行。
诚然,垃圾回收机制在本质上并不比其他内存管理策略更好或者更差(一切都是权衡),但在我们打赌的这个特定程序中,垃圾回收确实能显著提升性能,因为这个程序压根就没真正运行过。我们只是把一大堆对象塞进内存,再在退出时一次性丢弃。
垃圾回收肯定是有代价的,Node.js 进程占用的内存容量明显大于 C 程序。这就是“省 cpu= 费内存”和“省内存 = 费 cpu”的经典难题,但我的目标就是打那小子的脸,所以费点内存也无所谓。
而我之所以能赢,是因为对方选择了一个幼稚的策略。其实他要想赢,最好的办法就是添加内存泄漏,故意把所有分配都保留在内存当中。这样 C 程序的内存占用量还是更小,但速度却比原先快得多。或者,他也可以用给栈分配缓冲区之类的设计来进一步提高性能,这种办法在实际生产中其实经常用到。
另外还有如何选择性能基准的问题。一般来说,大家比较的就是每秒操作数量。这里的 JS 对 C 就是个很好的例子,证明了“先理解总体性能成本,再做选择”往往更加靠谱。在软件架构中,我们必须得时刻关注资源层面的“总体拥有成本”。
步入现代:有请 Rust 上场
Rust 是我目前最喜欢的语言之一。它提供了很多现代特性、速度很快,而且具备良好的内存模型,生成的代码也相当安全。
Rust 当然不是完美的,它的编译时间比较长、涉及不少奇奇怪怪的语义,但总体来说还是值得推荐。大家可以对 Rust 中的内存管理方式进行灵活控制,但其“栈”内存始终遵循所有者模型(ownership model),这也是其实现引以为傲的高安全性能的基础。
我目前参与的一个项目就是用 Rust 编写的 FaaS(函数即服务)主机,负责执行 WASM(WebAssembly)函数。它能快速安全地执行各项隔离函数,最大限度降低 FaaS 的运行开销。它的速度也很快,每核心每秒能够处理 90000 个简单请求。更重要的是,它的总内存占用量只有 20 MB 上下,可以说相当夸张了。
但这跟 Node.js 与 C 的赌局有什么关系?
简单来说,我是把 Node.js 视为“合理”的性能基准(Go 属于「梦幻」级基准,它的性能绝对不是那些专为 Web 服务设计的语言能比肩的,这里就别降维打击了),毕竟我们那款程序的早期 C 版本性能实在不咋的,唯一的好处就是内存占用量只有 Node.js 版本的不到十分之一。
虽然先让代码跑起来、再对代码做优化确实没啥毛病,但在 C 这种“快”语言上输给了 JavaScript 肯定让人非常沮丧。而我之所以敢当场梭哈,靠的就是对明显瓶颈的基本判断。这个瓶颈就是内存管理。
每个 guest 函数都被分配到一个内存数组,但在函数之内分配内存,以及在函数内存与主机内存间复制数据肯定会带来大量性能开销。由于动态数据被四处乱扔,分配器相当于是饱受四面八方的重拳打击。至于解决办法嘛,作弊喽!
加堆,两个堆、三个堆......
从本质上讲,堆代表的是分配器用来管理映射的一部分内存。程序会请求 N 个内存单元,分配器在可用的内存池里搜寻这些单元(或者向主机请求更多内存)及存储哪些单元已被占用,之后再返回该内存的位置指针。当程序用尽内存时,就会告知分配器,再由分配器更新映射以明确现在哪些单元已经再次可用。挺简单的,对吧?
但如果我们需要分配一大堆生命周期有别、大小各异的内存单元时,麻烦就来了。这一定会产生大量碎片,进而放大了新内存的分配成本。于是性能损失开始产生,毕竟分配器的功能太过简单,只是在寻找可用的存储位置。
这个问题显然没有太好的解决方案,虽然目前可选的分配算法很多,但它们还是各有权衡、要求我们结合用例特点选择最适方法(也可以像大多数开发者一样,直接用默认选项)。
再来说作弊。作弊的办法可不只一种:对于 FaaS,我们可以释放每次运行的 dealloc,并在每次运行完成后清除整个堆;我们也可以在函数生命周期的不同阶段使用不同的分配器,例如明确区分初始化阶段和运行阶段。这样无论是干净的函数(每次运行,都会被重置为相同的初始内存状态)还是有状态函数(在每次运行之间保留状态),都能获得与之对应且经过优化的内存策略。
在我们的 FaaS 项目里,大家最终构建了一个动态分配器,它会根据使用情况选择分配算法、且实际选择会在每次运行之间持续留存。
对于“使用率较低”的函数(也就是大多数函数),只使用简单的栈分配器用指针指向下一个空闲槽即可。当调用 dealloc 时,如果该单元为栈上的最后一个单元,则回滚指针;如果不是最后一个单元,则无操作。当函数完成时,指针将被设置为 0(相当于 Node.js 在垃圾回收前退出)。如果函数的 dealloc 失败数和用量达到一定阈值,则在其余调用中使用其他分配算法。结果就是,这套方案在大多数情况下都能显著加快内存分配。
运行时中还用到了另一个“堆”——主机(或者说是函数共享内存)。它使用同样的动态分配策略,并允许绕过早期 C 版本中的复制步骤、直接写入函数内存。如此一来,I/O 就能直接从内核中复制 guest 函数,并绕过主机运行时,从而显著提高吞吐量。
Node.js 对阵 Rust
经过优化,Rust FaaS 运行时最终比我们的 Node.js 参考实现快了 70% 以上,而内存占用量更是不到后者的十分之一。
但这里的关键在于“经过优化”,它的初始实现其实速度反而更慢。我们的优化还要求对 WASM 函数做出一些限制,具体限制在编译过程中完全公开透明,而且极少出现不兼容的情况。
Rust 版本的最大优势就是内存占用小,省下来的 RAM 可以用作缓存或者分布式内存存储等其他用途。这意味着 I/O 开销进一步降低,生产运行的效率更高,其效果甚至比拉高 CPU 配置还更明显些。
后续我们还有更多优化计划,但主要是为了解决主机层中一些具有重大安全影响的问题。虽然跟内存管理或者性能没啥关系,但毕竟也算支持了 “Rust 比 Node 更快”党们的观点。
总 结
其实全文写下来,我也得不出特别明确的结论。下面只给出几个粗浅的观点:
- 内存管理很有趣,每种方法都是在做取舍。只要策略运用得当,任何一种语言都能获得巨大的性能提升。
- 我仍然推荐大家根据实际目标灵活使用 Node.js 和 Rust,所以这里不做优劣判断。JavaScript 的可移植性确实更好,而且特别适合云原生开发场景;但如果大家特别看重性能,那 Rust 可能是个更好的选择。
- 从头到尾我都在说 JavaScript,但这里实际指的是 TypeScript。
归根结底,大家得根据实际情况选择最适合的技术方案。我们越是了解不同栈的不同特征,在选择的时候就越是从容有数。
原文链接:
https://medium.com/@jbyj/my-javascript-is-faster-than-your-rust-5f98fe5db1bf