使用 Node.js 开发的一个好处是简直能够在 JavaScript 和 原生 C++ 代码之间无缝切换 - 这要得益于 V8 的扩展 API。从 JavaScript 进入 C++ 的能力有时由处理速度驱动,但更多的情况是我们已经有 C++ 代码,而我们想要直接用 JavaScript 调用。
我们可以用(至少)两轴对不同用例的扩展进行分类 - (1)C++ 代码的运行时间,(2)C++ 和 JavaScript 之间数据流量。
大多数文档讨论的 Node.js 的 C++ 扩展关注于左右象限的不同。如果你在左象限(短处理时间),你的扩展有可能是同步的 - 意思是当调用时 C++ 代码在 Node.js 的事件循环中直接运行。
"#nodejs 允许我们在#javascript 和原生 C++ 代码之间无缝切换" via @RisingStack
在这个场景中,扩展函数阻塞并等待返回值,意味着其他操作不能同时进行。在右侧象限中,几乎可以确定要用异步模式来设计附加组件。在一个异步扩展函数中,JavaScript 调用函数立即返回。调用代码向扩展函数传入一个回调,扩展函数工作于一个独立工作线程中。由于扩展函数没有阻塞,则避免了 Node.js 事件循环的死锁。
顶部和底部象限的不同时常容易被忽视,但是他们也同样重要。
V8 vs. C++ 内存和数据
如果你不了解如何写一个原生附件,那么你首先要掌握的是属于 V8 的数据(可以 通过 C++ 附件获取的)和普通 C++ 内存分配的区别。
当我们提到 “属于 V8 的”,指的是持有 JavaScript 数据的存储单元。
这些存储单元是可通过 V8 的 C++ API 访问的,但它们不是普通的 C++ 变量,因为他们只能够通过受限的方式访问。当你的扩展 可以 限制为只使用 V8 数据,它就更有可能同样会在普通 C++ 代码中创建自身的变量。这些变量可以是栈或堆变量,且完全独立于 V8。
在 JavaScript 中,基本类型(数字,字符串,布尔值等)是 不可变的,一个 C++ 扩展不能够改变与基本类型相连的存储单元。这些基本类型的 JavaScript 变量可以被重新分配到 C++ 创建的 新存储单元 中 - 但是这意味着改变数据将会导致 新 内存的分配。
在上层象限(少量数据传递),这没什么大不了。如果你正在设计一个无需频繁数据交换的附加组件,那么所有新内存分配的开销可能没有那么大。当扩展更靠近下层象限时,分配/拷贝的开销会开始令人震惊。
一方面,这会增大最高的内存使用量,另一方面,也会 损耗性能。
在 JavaScript(V8 存储单元) 和 C++(返回)之间复制所有数据花费的时间通常会牺牲首先运行 C++ 赚来的性能红利!对于在左下象限(低处理,高数据利用场景)的扩展应用,数据拷贝的延迟会把你的扩展引用往右侧象限引导 - 迫使你考虑异步设计。
V8 内存与异步附件
在异步扩展中,我们在一个工作线程中执行大块的 C++ 处理代码。如果你对异步回调并不熟悉,看看这些教程(这里 和 这里)。
异步扩展的中心思想是 你不能在事件循环线程外访问 V8 (JavaScript)内存。这导致了新的问题。大量数据必须在工作线程启动前 从事件循环中 复制到 V8 内存之外,即扩展的原生地址空间中去。同样地,工作线程产生或修改的任何数据都必须通过执行事件循环(回调)中的代码拷贝回 V8 引擎。如果你致力于创建高吞吐量的 Node.js 应用,你应该避免花费过多的时间在事件循环的数据拷贝上。
理想情况下,我们更倾向于这么做:
Node.js Buffer 来救命
这里有两个相关的问题。
- 当使用同步扩展时,除非我们不改变/产生数据,那么可能会需要花费大量时间在 V8 存储单元和老的简单 C++ 变量之间移动数据 - 十分费时。
- 当使用异步扩展时,理想情况下我们应该尽可能减少事件轮询的时间。这就是问题所在 - 由于 V8 的多线程限制,我们 必须 在事件轮询线程中进行数据拷贝。
Node.js 里有一个经常会被忽视的特性可以帮助我们进行扩展开发 - Buffer
。Nodes.js 官方文档 在此。
Buffer 类的实例与整型数组类似,但对应的是 V8 堆外大小固定,原始内存分配空间。
这不就是我们一直想要的吗 - Buffer 里的数据 并不存储在 V8 存储单元内,不受限于 V8 的多线程规则。这意味着可以通过异步扩展启动的 C++ 工作线程与 Buffer 进行交互。