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

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

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 设置为给定的指针和长度。 关键点是,这是一个 TypedArray 视图,指向 WebAssembly 内存缓冲区,而不是 JavaScript 拥有的数据副本。

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

或者,某些 free 的实现甚至可能决定立即将释放的内存归零。free Emscripten 使用的并没有这样做,但我们在这里依赖的是无法保证的实现细节。

或者,即使指针后面的内存被保留,新的分配也可能需要增长 WebAssembly 内存。当 WebAssembly.Memory 通过 JavaScript API 或相应的 memory.grow 指令增长时,它会使现有的 ArrayBuffer 以及由其支持的任何视图无效。

让我使用 DevTools(或 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,在某些时候我们可能会为我们的编解码器添加多线程支持。 在这种情况下,在我们设法克隆它之前,完全有可能是一个不同的线程覆盖了数据。

寻找内存错误

以防万一,我决定更进一步,检查此代码在实践中是否会表现出任何问题。这似乎是一个尝试新的(较新的)Emscripten sanitizers 支持 的绝佳机会,该支持是去年添加的,并在我们在 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
  );
}

这为我们在控制台中提供了如下报告

Screenshot of a message

糟糕,有一些小的泄漏,但堆栈跟踪不是很有帮助,因为所有函数名称都被破坏了。 让我们使用基本的调试信息重新编译以保留它们

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

这看起来好多了

Screenshot of a message reading 'Direct leak of 12 bytes' coming from a GenericBindingType RawImage ::toWireType function

堆栈跟踪的某些部分仍然看起来很模糊,因为它们指向 Emscripten 内部,但我们可以看出泄漏来自 RawImage 转换为 Embind 的“wire type”(JavaScript 值)。 实际上,当我们查看代码时,我们可以看到我们将 RawImage C++ 实例返回给 JavaScript,但我们从不在任何一侧释放它们。

提醒一下,目前 JavaScript 和 WebAssembly 之间没有垃圾回收集成,尽管 正在开发一个。 相反,您必须在完成对象后从 JavaScript 端手动释放任何内存并调用析构函数。 对于 Embind 特别是,官方文档 建议在公开的 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
  );
}

泄漏如预期般消失了。

通过 sanitizers 发现更多问题

使用 sanitizers 构建其他 Squoosh 编解码器会揭示类似的问题以及一些新问题。 例如,我在 MozJPEG 绑定中遇到了这个错误

Screenshot of a message

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

深入研究 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) {
  // …
}

如果其中一些在第一次运行时被延迟初始化,然后在未来的运行中被不正确地重用怎么办? 那么使用 sanitizer 的单次调用不会将它们报告为有问题。

让我们尝试多次处理图像,方法是在 UI 中随机单击不同的质量级别。 实际上,现在我们得到以下报告

Screenshot of a message

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);
}

我可以继续逐个查找这些内存错误,但我认为到现在为止已经很清楚,当前的内存管理方法会导致一些令人讨厌的系统性问题。

其中一些可以立即被 sanitizer 捕获。 其他一些需要复杂的技巧才能被捕获。 最后,正如我们从日志中看到的那样,帖子开头的问题根本没有被 sanitizer 捕获。 原因是在 JavaScript 端发生了实际的误用,sanitizer 对此没有可见性。 这些问题只会出现在生产环境中,或者在未来看似不相关的代码更改之后才会暴露出来。

构建安全包装器

让我们退后几步,而是通过以更安全的方式重构代码来解决所有这些问题。 我将再次以 ImageQuant 包装器为例,但类似的重构规则适用于所有编解码器以及其他类似的代码库。

首先,让我们修复帖子开头的 use-after-free 问题。 为此,我们需要在 JavaScript 端将其标记为释放之前,从 WebAssembly 支持的视图中克隆数据

  // 

  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 更加安全!

为此,让我们将 new Uint8ClampedArray(…) 部分从 JavaScript 移动到带有 Embind 的 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 支持的内存视图——无论它是用哪种语言构建的——超出单次调用。您不能指望它们能存活更长时间,并且您将无法通过传统方式捕获这些错误,因此如果您需要存储数据以供以后使用,请将其复制到 JavaScript 端并存储在那里。
  • 如果可能,请使用安全的内存管理语言,或者至少使用安全的类型包装器,而不是直接操作原始指针。这不会使您免受 JavaScript ↔ WebAssembly 边界上的错误的影响,但至少会减少静态语言代码本身包含的错误表面。
  • 无论您使用哪种语言,在开发期间都使用 sanitizers 运行代码——它们可以帮助捕获不仅静态语言代码中的问题,还可以捕获 JavaScript ↔ WebAssembly 边界上的一些问题,例如忘记调用 .delete() 或从 JavaScript 端传入无效指针。
  • 如果可能,请避免完全将 WebAssembly 中的非托管数据和对象公开给 JavaScript。 JavaScript 是一种垃圾回收语言,手动内存管理在其中并不常见。这可以被认为是您的 WebAssembly 构建语言的内存模型的抽象泄漏,并且在 JavaScript 代码库中很容易忽略不正确的管理。
  • 这可能很明显,但与任何其他代码库一样,避免在全局变量中存储可变状态。您不希望调试其在各种调用甚至线程中重用的问题,因此最好使其尽可能自包含。