跳到主要内容

Electron 和 V8 内存笼

·阅读 8 分钟

Electron 21 及更高版本将启用 V8 内存笼,这会对某些原生模块产生影响。


更新 (2022/11/01)

要跟踪关于 Electron 21+ 中原生模块使用情况的持续讨论,请参阅 electron/electron#35801

在 Electron 21 中,我们将启用 V8 沙盒指针,以遵循 Chrome 在 Chrome 103 中做出相同决定的做法。 这对原生模块有一些影响。 此外,我们之前在 Electron 14 中启用了相关技术 指针压缩。 当时我们对此谈论不多,但指针压缩对 V8 堆的最大大小有影响。

启用这两种技术后,它们对安全性、性能和内存使用都有显著益处。 然而,启用它们也存在一些缺点。

启用沙盒指针的主要缺点是**不再允许指向外部(“堆外”)内存的 ArrayBuffer**。 这意味着依赖此 V8 功能的原生模块需要进行重构才能在 Electron 20 及更高版本中继续工作。

启用指针压缩的主要缺点是**V8 堆被限制为最大 4GB**。 具体细节有些复杂——例如,ArrayBuffer 与 V8 堆的其余部分分开计算,但它们有自己的限制

Electron 升级工作组认为指针压缩和 V8 内存笼的优点大于缺点。 这样做的主要原因有三个

  1. 它使 Electron 更接近 Chromium。 Electron 在 V8 配置等复杂内部细节上与 Chromium 的差异越小,我们意外引入错误或安全漏洞的可能性就越小。 Chromium 的安全团队非常强大,我们希望确保充分利用他们的工作。 此外,如果一个错误只影响 Chromium 中未使用的配置,那么修复它不太可能是 Chromium 团队的优先事项。
  2. 它性能更好。指针压缩可将 V8 堆大小减少多达 40%,并将 CPU 和 GC 性能提高 5%–10%。 对于绝大多数不会遇到 4GB 堆大小限制且不使用需要外部缓冲区的原生模块的 Electron 应用程序来说,这些都是显著的性能提升。
  3. 它更安全。 一些 Electron 应用程序运行不受信任的 JavaScript(希望它们遵循我们的安全建议!),对于这些应用程序,启用 V8 内存笼可以保护它们免受一大类恶意 V8 漏洞的侵害。

最后,对于确实需要更大堆内存的应用程序,也有一些变通方法。 例如,您可以在应用程序中包含一个禁用了指针压缩的 Node.js 副本,并将内存密集型工作转移到子进程。 尽管有些复杂,但如果您决定为您的特定用例选择不同的折衷方案,也可以构建一个禁用指针压缩的自定义 Electron 版本。 最后,在不远的将来,wasm64 将允许在 Web 和 Electron 中使用 WebAssembly 构建的应用程序使用远超 4GB 的内存。


常见问题

我如何知道我的应用程序是否受此更改影响?

在 Electron 20+ 中,尝试使用 ArrayBuffer 封装外部内存将在运行时崩溃。

如果您的应用程序中不使用任何原生 Node 模块,那么您是安全的——无法从纯 JS 中触发此崩溃。 此更改仅影响那些在 V8 堆外部分配内存(例如使用 `malloc` 或 `new`)然后使用 ArrayBuffer 封装外部内存的原生 Node 模块。 这是一个相当罕见的用例,但有些模块确实使用了这种技术,并且此类模块需要进行重构才能与 Electron 20+ 兼容。

我如何衡量我的应用程序使用了多少 V8 堆内存,以了解我是否接近 4GB 限制?

在渲染器进程中,您可以使用 `performance.memory.usedJSHeapSize`,它将以字节为单位返回 V8 堆的使用情况。 在主进程中,您可以使用 `process.memoryUsage().heapUsed`,两者是可比较的。

什么是 V8 内存笼?

有些文档将其称为“V8 沙盒”,但该术语容易与 Chromium 中发生的其他类型的沙盒混淆,因此我将坚持使用“内存笼”这个术语。

有一种相当常见的 V8 漏洞利用方式,大致如下:

  1. 在 V8 的 JIT 引擎中找到一个错误。 JIT 引擎分析代码是为了能够省略缓慢的运行时类型检查并生成快速机器代码。 有时逻辑错误意味着它会错误地进行这种分析,并遗漏了它实际需要的类型检查——例如,它认为 x 是一个字符串,但实际上它是一个对象。
  2. 滥用这种混淆来覆盖 V8 堆中的某些内存位,例如,ArrayBuffer 开头的指针。
  3. 现在您拥有了一个可以指向任何您想要的 ArrayBuffer,因此您可以读写**任何**内存中的进程,甚至包括 V8 通常无法访问的内存。

V8 内存笼是一种旨在从根本上防止此类攻击的技术。 实现这一目标的方法是*不在 V8 堆中存储任何指针*。 相反,V8 堆内对其他内存的所有引用都存储为从某个保留区域起始处的偏移量。 这样,即使攻击者设法损坏 ArrayBuffer 的基地址(例如通过利用 V8 中的类型混淆错误),他们最坏也只能在内存笼内读写内存,而他们本来可能就已经可以做到这一点了。 关于 V8 内存笼的工作原理还有很多可供阅读,所以我在这里不再详细介绍——最好的入门读物可能是 Chromium 团队的高级设计文档

我想重构一个 Node 原生模块以支持 Electron 21+。我该怎么做?

重构原生模块以使其与 V8 内存笼兼容有两种方法。 第一种方法是**复制**外部创建的缓冲区到 V8 内存笼中,然后将其传递给 JavaScript。 这通常是一个简单的重构,但当缓冲区很大时可能会很慢。 另一种方法是**使用 V8 的内存分配器**来分配您最终打算传递给 JavaScript 的内存。 这会稍微复杂一些,但可以避免复制,这意味着大型缓冲区的性能会更好。

为了更具体地说明,这里有一个使用外部 ArrayBuffer 的 N-API 模块示例

// Create some externally-allocated buffer.
// |create_external_resource| allocates memory via malloc().
size_t length = 0;
void* data = create_external_resource(&length);
// Wrap it in a Buffer--will fail if the memory cage is enabled!
napi_value result;
napi_create_external_buffer(
env, length, data,
finalize_external_resource, NULL, &result);

当内存笼启用时,这将导致崩溃,因为数据是在笼外分配的。 重构为将数据复制到笼内,我们得到

size_t length = 0;
void* data = create_external_resource(&length);
// Create a new Buffer by copying the data into V8-allocated memory
napi_value result;
void* copied_data = NULL;
napi_create_buffer_copy(env, length, data, &copied_data, &result);
// If you need to access the new copy, |copied_data| is a pointer
// to it!

这将把数据复制到一个新分配的、位于 V8 内存笼内的内存区域。 此外,N-API 还可以提供指向新复制数据的指针,以防您之后需要修改或引用它。

重构以使用 V8 的内存分配器会稍微复杂一些,因为它需要修改 `create_external_resource` 函数以使用 V8 分配的内存,而不是使用 `malloc`。 这可能可行或不可行,取决于您是否控制 `create_external_resource` 的定义。 思路是首先使用 V8 创建缓冲区,例如使用 `napi_create_buffer`,然后将资源初始化到 V8 分配的内存中。 重要的是要保留一个 `napi_ref` 到 Buffer 对象,以保证资源的生命周期,否则 V8 可能会对 Buffer 进行垃圾回收,并可能导致使用后释放错误。