使用 Emscripten 调试 WebAssembly 中的内存泄漏

虽然 JavaScript 在自我清理方面相当宽容,但静态语言绝对不是...

Ingvar Stepanyan
Ingvar Stepanyan

Squoosh.app 是一种 PWA,说明了多少不同的图像编解码器和设置可以在不显著影响质量的情况下减小图像文件大小。不过,它也是一个技术演示,展示了如何将使用 C++ 或 Rust 编写的库移植到 Web 上。

能够从现有生态系统中移植代码非常有价值,但这些静态语言与 JavaScript 之间存在一些关键区别。其中之一就是它们对内存管理采用了不同的方法。

虽然 JavaScript 在清理自身方面相当宽容,但此类静态语言绝对不是。您需要显式请求分配新的内存,并且您真的需要确保之后将其归还,并且永远不会再次使用。如果没有发生这种情况,就会出现泄漏,而这种情况实际上经常发生。下面我们来了解如何调试这些内存泄漏问题,更棒的是,如何设计代码以避免下次发生此类问题。

可疑模式

最近,在开始开发 Squoosh 时,我情不自禁地注意到 C++ 编解码器封装容器中的一个有趣的模式。我们以 ImageQuant 封装容器为例(已经过简化,只显示对象创建和取消分配部分):

liq_attr* attr;
liq_image* image;
liq_result* res;
uint8_t* result;

RawImage quantize(std::string rawimage,
                  int image_width,
                  int image_height,
                  int num_colors,
                  float dithering) {
  const uint8_t* image_buffer = (uint8_t*)rawimage.c_str();
  int size = image_width * image_height;

  attr = liq_attr_create();
  image = liq_image_create_rgba(attr, image_buffer, image_width, image_height, 0);
  liq_set_max_colors(attr, num_colors);
  liq_image_quantize(image, attr, &res);
  liq_set_dithering_level(res, dithering);
  uint8_t* image8bit = (uint8_t*)malloc(size);
  result = (uint8_t*)malloc(size * 4);

  // …

  free(image8bit);
  liq_result_destroy(res);
  liq_image_destroy(image);
  liq_attr_destroy(attr);

  return {
    val(typed_memory_view(image_width * image_height * 4, result)),
    image_width,
    image_height
  };
}

void free_result() {
  free(result);
}

JavaScript(也包括 TypeScript):

export async function process(data: ImageData, opts: QuantizeOptions) {
  if (!emscriptenModule) {
    emscriptenModule = initEmscriptenModule(imagequant, wasmUrl);
  }
  const module = await emscriptenModule;

  const result = module.quantize(/* … */);

  module.free_result();

  return new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );
}

您是否发现了问题?提示:这是 use-after-free,但在 JavaScript 中!

在 Emscripten 中,typed_memory_view 会返回由 WebAssembly (Wasm) 内存缓冲区支持的 JavaScript Uint8Array,并将 byteOffsetbyteLength 设置为给定的指针和长度。要点在于,这是一个指向 WebAssembly 内存缓冲区的 TypedArray 视图,而非 JavaScript 拥有的数据副本。

当我们从 JavaScript 调用 free_result 时,它会反过来调用标准 C 函数 free 来将此内存标记为可供任何未来分配使用,这意味着,我们的 Uint8Array 视图指向的数据可以被任何未来对 Wasm 的调用用任意数据覆盖。

或者,free 的某些实现甚至可能决定立即对已释放的内存执行零填充。Emscripten 使用的 free 不能做到这一点,但我们在这里依赖于无法保证的实现细节。

或者,即使保留指针后面的内存,新的分配可能也需要增加 WebAssembly 内存。通过 JavaScript API 或相应的 memory.grow 指令扩展 WebAssembly.Memory 时,它会使现有的 ArrayBuffer 失效,并会传递性地使由其支持的所有视图失效。

我使用开发者工具(或 Node.js)控制台来演示此行为:

> memory = new WebAssembly.Memory({ initial: 1 })
Memory {}

> view = new Uint8Array(memory.buffer, 42, 10)
Uint8Array(10) [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
// ^ all good, we got a 10 bytes long view at address 42

> view.buffer
ArrayBuffer(65536) {}
// ^ its buffer is the same as the one used for WebAssembly memory
//   (the size of the buffer is 1 WebAssembly "page" == 64KB)

> memory.grow(1)
1
// ^ let's say we grow Wasm memory by +1 page to fit some new data

> view
Uint8Array []
// ^ our original view is no longer valid and looks empty!

> view.buffer
ArrayBuffer(0) {}
// ^ its buffer got invalidated as well and turned into an empty one

最后,即使我们不会在 free_resultnew Uint8ClampedArray 之间再次明确调用 Wasm,但在某个时候,我们可能会为编解码器添加多线程支持。在这种情况下,可能是在我们成功克隆数据之前,完全不同的线程覆盖了数据。

查找内存 bug

为以防万一,我决定进一步检查此代码在实际操作中是否存在任何问题。 这似乎是一个绝佳的机会,可以试用去年添加的 Emscripten sanitize 支持,我们在 Chrome 开发者峰会上的 WebAssembly 演讲中就曾介绍过:

在本例中,我们关注的是 AddressSanitizer,它可以检测各种与指针和内存相关的问题。如需使用它,我们需要使用 -fsanitize=address 重新编译编解码器:

emcc \
  --bind \
  ${OPTIMIZE} \
  --closure 1 \
  -s ALLOW_MEMORY_GROWTH=1 \
  -s MODULARIZE=1 \
  -s 'EXPORT_NAME="imagequant"' \
  -I node_modules/libimagequant \
  -o ./imagequant.js \
  --std=c++11 \
  imagequant.cpp \
  -fsanitize=address \
  node_modules/libimagequant/libimagequant.a

这样会自动启用指针安全检查,但我们还希望发现潜在的内存泄漏问题。由于我们将 ImageQuant 用作库而非程序,因此没有 Emscripten 可以自动验证所有内存是否已释放的“退出点”。

相反,对于此类情况,LeakSanitizer(包含在 AddressSanitizer 中)提供 __lsan_do_leak_check__lsan_do_recoverable_leak_check 函数,每当我们预计所有内存都被释放并希望验证该假设时,就可以手动调用这两个函数。__lsan_do_leak_check 适用于运行应用结束时,即当您希望在检测到任何泄漏时终止进程时;而 __lsan_do_recoverable_leak_check 更适用于库用例(例如我们的用例),即当您希望将泄漏信息输出到控制台,但无论如何都让应用保持运行时。

我们通过 Embind 公开第二个帮助程序,以便随时从 JavaScript 调用它:

#include <sanitizer/lsan_interface.h>

// …

void free_result() {
  free(result);
}

EMSCRIPTEN_BINDINGS(my_module) {
  function("zx_quantize", &zx_quantize);
  function("version", &version);
  function("free_result", &free_result);
  function("doLeakCheck", &__lsan_do_recoverable_leak_check);
}

处理完图片后,从 JavaScript 端调用它。从 JavaScript 端(而非 C++ 端)执行此操作有助于确保在运行这些检查时,所有作用域均已退出,并且所有临时 C++ 对象均已释放:

  // 

  const result = opts.zx
    ? module.zx_quantize(data.data, data.width, data.height, opts.dither)
    : module.quantize(data.data, data.width, data.height, opts.maxNumColors, opts.dither);

  module.free_result();
  module.doLeakCheck();

  return new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );
}

这会在控制台中生成如下所示的报告:

消息的屏幕截图

糟糕,存在一些小泄露,但堆栈轨迹不是很有用,因为所有函数名称都被破坏。我们使用基本调试信息重新编译,以保留这些信息:

emcc \
  --bind \
  ${OPTIMIZE} \
  --closure 1 \
  -s ALLOW_MEMORY_GROWTH=1 \
  -s MODULARIZE=1 \
  -s 'EXPORT_NAME="imagequant"' \
  -I node_modules/libimagequant \
  -o ./imagequant.js \
  --std=c++11 \
  imagequant.cpp \
  -fsanitize=address \
  -g2 \
  node_modules/libimagequant/libimagequant.a

这样看起来要好多了:

来自 GenericBindingType RawImage ::toWireType 函数的消息“12 个字节的直接泄漏”消息的屏幕截图

堆栈轨迹的某些部分仍然看起来很模糊,因为它们指向 Emscripten 内部,但我们可以确定,该内存泄露来自 Embind 对 RawImage 的转换(转换为“线型”[JavaScript 值])。事实上,查看代码后,我们可以看到,我们会将 RawImage C++ 实例返回给 JavaScript,但从未在任一端释放它们。

请注意,目前 JavaScript 和 WebAssembly 之间没有垃圾回收集成,但正在开发中。相反,您需要在使用完对象后手动释放所有内存,并从 JavaScript 端调用析构函数。具体而言,官方文档建议对公开的 C++ 类调用 .delete() 方法:

JavaScript 代码必须明确删除它收到的任何 C++ 对象句柄,否则 Emscripten 堆将无限增长。

var x = new Module.MyClass;
x.method();
x.delete();

事实上,当我们在 JavaScript 中为类执行此操作时:

  // 

  const result = opts.zx
    ? module.zx_quantize(data.data, data.width, data.height, opts.dither)
    : module.quantize(data.data, data.width, data.height, opts.maxNumColors, opts.dither);

  module.free_result();
  result.delete();
  module.doLeakCheck();

  return new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );
}

泄漏按预期消失。

发现更多与清洁剂相关的问题

使用清理程序构建其他 Squoosh 编解码器时,会发现类似的问题以及一些新问题。例如,MozJPEG 绑定中遇到了以下错误:

消息的屏幕截图

这里不是内存泄露,而是我们写入了分配边界之外的内存 😱

深入研究 MozJPEG 的代码后,我们发现问题在于 jpeg_mem_dest(用于为 JPEG 分配内存目的地的函数)会在 outbufferoutsize 不为零时重复使用其现有值

if (*outbuffer == NULL || *outsize == 0) {
  /* Allocate initial buffer */
  dest->newbuffer = *outbuffer = (unsigned char *) malloc(OUTPUT_BUF_SIZE);
  if (dest->newbuffer == NULL)
    ERREXIT1(cinfo, JERR_OUT_OF_MEMORY, 10);
  *outsize = OUTPUT_BUF_SIZE;
}

不过,我们在调用时不会初始化其中任何一个变量,这意味着 MozJPEG 会将结果写入一个可能随机的内存地址,该地址在调用时恰好存储在这些变量中!

uint8_t* output;
unsigned long size;
// …
jpeg_mem_dest(&cinfo, &output, &size);

在调用之前对这两个变量进行零初始化可解决此问题,现在,代码会执行内存泄露检查。幸运的是,检查已成功通过,这表明此编解码器中没有任何泄露。

共享状态存在问题

…还是不行?

我们知道,我们的编解码器绑定会将一些状态以及结果存储在全局静态变量中,而 MozJPEG 有一些特别复杂的结构。

uint8_t* last_result;
struct jpeg_compress_struct cinfo;

val encode(std::string image_in, int image_width, int image_height, MozJpegOptions opts) {
  // …
}

如果其中一些在首次运行时被延迟初始化,然后在日后的运行中被不当重复使用,该怎么办?这样一来,使用过一次清理程序后,系统就不会将其报告为有问题。

我们来试着在界面中随机点击不同的画质级别,对图片进行几次处理。事实上,现在我们会收到以下报告:

消息的屏幕截图

262,144 字节 - 看起来整个示例图片都从 jpeg_finish_compress 泄露了!

查看文档和官方示例后,我发现 jpeg_finish_compress 不会释放之前 jpeg_mem_dest 调用分配的内存,只会释放压缩结构,即使该压缩结构已经知道我们的内存目的地也是如此… 叹息。

我们可以通过在 free_result 函数中手动释放数据来解决此问题:

void free_result() {
  /* This is an important step since it will release a good deal of memory. */
  free(last_result);
  jpeg_destroy_compress(&cinfo);
}

我可以继续逐个研究这些内存 bug,但我认为目前的内存管理方法已经导致一些可恶的系统性问题。

其中一些可以立即被清理工具捕获。有些则需要使用复杂的手段才能抓到。最后,正如我们从日志中看到的那样,有些问题(如本文开头所述)根本不会被清理程序捕获。原因在于,实际的滥用行为发生在 JavaScript 端,而清理工具无法看到该端。这些问题只会出现在生产环境中,或者将来对代码进行看似无关的更改后才会显现。

构建安全的封装容器

我们来回过头来,通过更安全的方式重构代码来解决所有这些问题。我将再次使用 ImageQuant 封装容器作为示例,但类似的重构规则适用于所有编解码器以及其他类似的代码库。

首先,我们来解决文章开头提到的“释放后使用”问题。为此,我们需要先从 WebAssembly 支持的视图克隆数据,然后再在 JavaScript 端将其标记为可用:

  // 

  const result = /*  */;

  const imgData = new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );

  module.free_result();
  result.delete();
  module.doLeakCheck();

  return new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );
  return imgData;
}

现在,我们需要确保在调用之间不会在全局变量中共享任何状态。这不仅会修复我们之前遇到的一些问题,还会让我们日后更轻松地在多线程环境中使用编解码器。

为此,我们重构了 C++ 封装容器,以确保对函数的每次调用都使用局部变量来管理自己的数据。然后,我们可以更改 free_result 函数的签名,以接受返回的指针:

liq_attr* attr;
liq_image* image;
liq_result* res;
uint8_t* result;

RawImage quantize(std::string rawimage,
                  int image_width,
                  int image_height,
                  int num_colors,
                  float dithering) {
  const uint8_t* image_buffer = (uint8_t*)rawimage.c_str();
  int size = image_width * image_height;

  attr = liq_attr_create();
  image = liq_image_create_rgba(attr, image_buffer, image_width, image_height, 0);
  liq_attr* attr = liq_attr_create();
  liq_image* image = liq_image_create_rgba(attr, image_buffer, image_width, image_height, 0);
  liq_set_max_colors(attr, num_colors);
  liq_result* res = nullptr;
  liq_image_quantize(image, attr, &res);
  liq_set_dithering_level(res, dithering);
  uint8_t* image8bit = (uint8_t*)malloc(size);
  result = (uint8_t*)malloc(size * 4);
  uint8_t* result = (uint8_t*)malloc(size * 4);

  // 
}

void free_result() {
void free_result(uint8_t *result) {
  free(result);
}

不过,由于我们已经在 Emscripten 中使用 Embind 与 JavaScript 进行交互,不妨完全隐藏 C++ 内存管理详细信息,从而使 API 更加安全!

为此,我们将使用 Embind 将 new Uint8ClampedArray(…) 部分从 JavaScript 移至 C++ 端。然后,我们可以使用它将数据克隆到 JavaScript 内存中,甚至在从函数返回之前也可以:

class RawImage {
 public:
  val buffer;
  int width;
  int height;

  RawImage(val b, int w, int h) : buffer(b), width(w), height(h) {}
};
thread_local const val Uint8ClampedArray = val::global("Uint8ClampedArray");

RawImage quantize(/*  */) {
val quantize(/*  */) {
  // 
  return {
    val(typed_memory_view(image_width * image_height * 4, result)),
    image_width,
    image_height
  };
  val js_result = Uint8ClampedArray.new_(typed_memory_view(
    image_width * image_height * 4,
    result
  ));
  free(result);
  return js_result;
}

请注意,只需进行一项更改,我们就可以确保生成的字节数组归 JavaScript 所有,而不是由 WebAssembly 内存支持,同时也消除了之前泄露的 RawImage 封装容器。

现在,JavaScript 完全无需担心释放数据,并且可以像使用任何其他垃圾回收对象一样使用结果:

  // 

  const result = /*  */;

  const imgData = new ImageData(
    new Uint8ClampedArray(result.view),
    result.width,
    result.height
  );

  module.free_result();
  result.delete();
  // module.doLeakCheck();

  return imgData;
  return new ImageData(result, result.width, result.height);
}

这也意味着,我们不再需要在 C++ 端使用自定义 free_result 绑定:

void free_result(uint8_t* result) {
  free(result);
}

EMSCRIPTEN_BINDINGS(my_module) {
  class_<RawImage>("RawImage")
      .property("buffer", &RawImage::buffer)
      .property("width", &RawImage::width)
      .property("height", &RawImage::height);

  function("quantize", &quantize);
  function("zx_quantize", &zx_quantize);
  function("version", &version);
  function("free_result", &free_result, allow_raw_pointers());
}

总而言之,我们的封装容器代码变得更加简洁和安全。

之后,我对 ImageQuant 封装容器的代码进行了一些进一步的微小改进,并针对其他编解码器复制了类似的内存管理修复。如果您想了解更多详情,可以点击此处查看生成的 PR:修复了 C++ 编解码器的内存问题

要点总结

我们可以从这次重构中学习和分享哪些可应用于其他代码库的经验教训?

  • 除了单次调用之外,请勿使用由 WebAssembly 支持的内存视图(无论其是使用哪种语言构建的)。您不能指望它们能保留更长时间,也无法通过常规方式捕获这些 bug,因此,如果您需要存储数据以供日后使用,请将其复制到 JavaScript 端并存储在该端。
  • 尽可能使用安全的内存管理语言,或者至少使用安全的类型封装容器,而不是直接对原始指针进行操作。这并不能避免 JavaScript ↔ WebAssembly 边界上的 bug,但至少可以减少静态语言代码自身包含的 bug 的出现几率。
  • 无论您使用哪种语言,在开发过程中都应使用清理程序运行代码,因为清理程序不仅有助于发现静态语言代码中的问题,还能发现 JavaScript ↔ WebAssembly 边界上的一些问题,例如忘记调用 .delete() 或从 JavaScript 端传入无效的指针。
  • 请尽可能避免将 WebAssembly 中的非受管理数据和对象完全公开给 JavaScript。JavaScript 是一种垃圾回收型语言,手动内存管理在其中并不常见。 这可能会被视为构建 WebAssembly 时所用语言的内存模型的抽象泄露,在 JavaScript 代码库中很容易忽略错误的管理。
  • 这可能很明显,但与任何其他代码库一样,请避免在全局变量中存储可变状态。您不希望调试在各种调用或线程中重复使用时出现的问题,因此最好使其尽可能自包含。