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

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

Ingvar Stepanyan
Ingvar Stepanyan

Squoosh.app 是一款 PWA,展示了 和设置可以缩减图片文件的大小,而不会明显影响图片质量。不过 一个技术演示,展示如何将使用 C++ 或 Rust 编写的库引入 。

能够从现有生态系统移植代码极具价值, 静态语言和 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 内存。当 WebAssembly.Memory 通过 JavaScript API 或相应的 memory.grow 指令,它会使现有 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,在某个时间点,我们也可能会为编解码器添加多线程支持。在这种情况下, 可能是一个完全不同的线程,在我们设法克隆数据之前覆盖数据。

正在查找内存错误

为保险起见,我决定更进一步,检查此代码在实践中是否出现了任何问题。 这似乎是试用 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

这看起来好多了:

显示“Direct leak of 12 bytes”消息的屏幕截图来自 GenericBindingType RawImage ::toWireType 函数

堆栈轨迹的某些部分虽然指向 Emscripten 内部结构,因此看起来仍然不清楚,但我们可以 泄漏来自 RawImage 到“线类型”的转换(映射到 JavaScript 值) Embind。事实上,当我们查看代码时,会看到 RawImage C++ 实例 JavaScript,但我们绝不会在任一端释放它们。

谨此提醒,目前 JavaScript 和 WebAssembly,但正在开发一个。相反, 在使用完 对象。对于 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) {
  // …
}

如果其中一些容器在首次运行时被延迟初始化,然后在日后不恰当地重复使用,该怎么办? 跑步?那么,使用排错程序进行的单个调用就不会将它们报告为有问题。

让我们随机点击不同的质量级别,尝试多次处理该图片 。现在,我们确实获得了以下报告:

消息的屏幕截图

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

我本可以不停地逐个寻找那些记忆虫子,但我觉得现在已经很清楚了, 目前的内存管理方法会导致一些令人厌恶的系统性问题。

其中有些可能会立即被排毒程序捕捉。而另一些则要运用复杂手段才能捉住。 最后,如博文开头部分所示,从日志中可以看出, 没有被清理程序发现原因在于实际的滥用发生在 JavaScript 端,Sanitizer 无法查看。这些问题 或是在将来对代码进行看似无关的更改后。

构建安全的封装容器

让我们往后退几个步骤,改为通过重构代码来解决所有这些问题 管理安全性。我再次使用 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 的安全性!

为此,让我们将 new Uint8ClampedArray(…) 部分从 JavaScript 移至 C++ 端, Embind。然后,我们可以使用它将数据克隆到 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++ 代码进行内存修复 编解码器

要点总结

从此次重构中,我们可以学习并分享哪些可应用于其他代码库的经验?

  • 除了 单个调用。你不能指望它们能活得更久, 通过常规方法捕获这些错误,因此如果您需要存储数据以备后用,请将其复制到 并将其存储在其中
  • 如果可能,请使用安全的内存管理语言,或者至少使用安全的类型封装容器, 直接对原始指针进行操作这不会避免 JavaScript SHORT WebAssembly 上出现错误 但至少可以减少静态语言代码自包含的 bug 的表面。
  • 无论您使用哪种语言,都可以在开发期间使用排错程序运行代码。排错程序可以帮助您 不仅能发现静态语言代码中的问题,还能发现 JavaScript 中的一些问题 WebAssembly 边界,例如忘记调用 .delete() 或从 JavaScript 方面
  • 如果可能,请避免将非受管数据和对象从 WebAssembly 中完全公开给 JavaScript。 JavaScript 是一种垃圾回收语言,手动内存管理在其中并不常见。 这可以视为 WebAssembly 语言的内存模型抽象泄露 而错误的管理很容易在 JavaScript 代码库中被忽略。
  • 这可能很明显,但是与其他任何代码库中一样,请避免将可变状态存储在全局变量中 变量。你可能不希望因各种调用中的重复使用而调试问题 所以最好尽可能保持其独立性。