跳转到主内容

Electron 和 V8 内存隔离区

· 阅读时间:约 10 分钟

Electron 21及更高版本将启用 V8 内存隔离区,这将对一些原生模块产生影响。


在Electron 21中,我们将启用 V8沙盒指针, Electron中,Chrome 决定在Chrome 103中执行相同的操作。 这对原生模块有一定的影响。 此外,我们曾在 Electron 14 中启用了 指针压缩 相关技术。 我们当时没有对此进行过多地讨论,但指针压缩对V8最大堆大小有影响。

这两种技术一旦启用,将对安全、性能和内存使用大有裨益。 但是,启用它们也有一些缺点。

启用沙盒指针的主要缺点是,不再允许进行指向外部 ("off-heap") 内存 的数组缓冲区的操作。 这意味着在V8中依赖于此功能的原生模块将需要重构才能在Electron 20及更高版本中继续工作。

启用指针压缩的主要缺点是, V8堆的最大大小限制为4GB。 这方面的确切细节有点复杂 - 例如,ArrayBuffers与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的内存。


常见问题 (FAQ)

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

尝试使用ArrayBuffer包装外部存储器将在Electron 20 +的运行时崩溃。

如果你没有在应用中使用任何原生 Node 模块,那么你是安全的 - 目前没有办法从纯 JS 触发此崩溃。 此更改仅影响原生 Node 模块,这些模块在 V8 堆之外分配内存(例如,使用 mallocnew),然后使用 ArrayBuffer 包装外部内存。 这是一个相当罕见的用例,但有些模块确实使用这种技术,并且这些模块需要重构才能与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 20+。 我该怎么做?

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

为了更具体地说明这一点,下面是一个使用外部数组缓冲区的示例 N-API 模块:

// 创建一些外部分配的缓冲区。
// |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 分配的内存中。 在资源生存期内,必须保留对 Buffer 对象的 napi_ref ,否则 V8 可能会对 Buffer 进行垃圾回收,并可能导致释放后使用错误。