Electron 和 V8 内存笼
Electron 21 及更高版本将启用 V8 内存笼,这对某些原生模块有影响。
要跟踪有关 Electron 21+ 中原生模块使用的持续讨论,请参阅 electron/electron#35801。
在 Electron 21 中,我们将启用 Electron 中的V8 沙盒指针,遵循 Chrome 在 Chrome 103 中做出同样决定的做法。这对原生模块有一些影响。此外,我们之前在 Electron 14 中启用了相关技术指针压缩。我们当时没有过多讨论它,但指针压缩对最大 V8 堆大小有影响。
启用这两项技术后,对安全性、性能和内存使用非常有益。但是,启用它们也有一些缺点。
启用沙盒指针的主要缺点是不再允许指向外部(“堆外”)内存的 ArrayBuffer。这意味着依赖 V8 中此功能的原生模块需要重构才能继续在 Electron 20 及更高版本中工作。
启用指针压缩的主要缺点是V8 堆的最大大小限制为 4GB。确切的细节有点复杂 - 例如,ArrayBuffer 与 V8 堆的其余部分分开计算,但有其自己的限制。
Electron 升级工作组认为,指针压缩和 V8 内存笼的优势大于缺点。这样做的主要原因有三个
- 它使 Electron 更接近 Chromium。Electron 在 V8 配置等复杂的内部细节上与 Chromium 的差异越小,我们就越不可能意外引入错误或安全漏洞。Chromium 的安全团队非常强大,我们希望确保充分利用他们的工作。此外,如果某个错误仅影响 Chromium 中未使用的配置,那么修复该错误不太可能是 Chromium 团队的首要任务。
- 它的性能更好。指针压缩将 V8 堆大小减少高达 40%,并将 CPU 和 GC 性能提高 5%–10%。对于绝大多数不会遇到 4GB 堆大小限制且不使用需要外部缓冲区的原生模块的 Electron 应用程序,这些都是显著的性能提升。
- 它更安全。一些 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 漏洞,大致如下:
- 在 V8 的 JIT 引擎中找到一个错误。JIT 引擎会分析代码,以便能够省略缓慢的运行时类型检查并生成快速的机器代码。有时,逻辑错误意味着它的分析是错误的,并省略了实际上需要的类型检查——例如,它认为 x 是一个字符串,但实际上它是一个对象。
- 利用这种混淆来覆盖 V8 堆中的某些内存位,例如指向 ArrayBuffer 开头的指针。
- 现在你拥有一个指向任何你想要的位置的 ArrayBuffer,因此你可以读取和写入进程中的任何内存,甚至是 V8 通常无法访问的内存。
V8 内存笼是一种旨在从根本上阻止此类攻击的技术。它的实现方式是不在 V8 堆中存储任何指针。相反,V8 堆中对其他内存的所有引用都存储为相对于某个保留区域起始位置的偏移量。然后,即使攻击者设法破坏了 ArrayBuffer 的基地址,例如通过利用 V8 中的类型混淆错误,他们所能做的最坏情况就是读取和写入内存笼中的内存,而他们可能无论如何都可以这样做。有很多关于 V8 内存笼工作原理的更多资料可供阅读,所以我在这里不再赘述——最好的阅读起点可能是 Chromium 团队的高层设计文档。
我想重构一个 Node 原生模块以支持 Electron 21+。我该怎么做?
有两种方法可以重构原生模块以使其与 V8 内存笼兼容。第一种是在将外部创建的缓冲区传递给 JavaScript 之前,将其复制到 V8 内存笼中。这通常是一个简单的重构,但当缓冲区很大时,它可能会很慢。另一种方法是使用 V8 的内存分配器来分配你打算最终传递给 JavaScript 的内存。这有点复杂,但可以避免复制,这意味着对于大型缓冲区具有更好的性能。
为了使这个问题更具体,这里有一个使用外部数组缓冲区的 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 分配的内存中。重要的是要保留对 Buffer 对象的 napi_ref
,以便在资源的生命周期内使用,否则 V8 可能会垃圾回收 Buffer,并可能导致使用后释放错误。