使用 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 设置为指定的指针和长度。要点在于,这是一个将 TypedArray 视图放入 WebAssembly 内存缓冲区,而不是由 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 排错程序支持的绝佳机会,该功能是去年添加,并在 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 函数的邮件读取“Direct leak of 12 bytes”消息的屏幕截图

由于堆栈轨迹的某些部分指向 Emscripten 内部构件,因此仍看起来模糊不清,但我们可以判断,泄露是由于 Embind 的 RawImage 转换为“wire type”(到 JavaScript 值)所致。实际上,当我们查看代码时,我们可以看到向 JavaScript 返回了 RawImage C++ 实例,但我们从未释放它们。

请注意,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
  );
}

泄漏会按预期消失。

发现排错程序方面的更多问题

使用排错程序构建其他 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) {
  // …
}

如果其中一些对象在首次运行时进行延迟初始化,然后在将来运行时错误地重复使用,该怎么办?然后,使用排错程序的单个通话不会报告它们有问题。

让我们尝试对图片进行几次处理,具体方法是在界面中随机点击不同的质量级别。实际上,我们现在得到以下报告:

消息的屏幕截图

262144 字节 - 整个示例图片似乎从 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 代码库中很容易被忽略。
  • 这可能很明显,但与在任何其他代码库中一样,请避免将可变状态存储在全局变量中。您不想调试在各种调用甚至线程中重复使用它的问题,因此最好让它尽可能保持独立。