Emscripten を使用して WebAssembly でのメモリリークをデバッグする

JavaScript は、その後のクリーンアップにはかなりの猶予がありますが、静的言語は明らかにそうではありません...

Squoosh.app は、画像コーデックの多さを示す PWA です。 設定することで、画質を大幅に損なうことなく画像ファイルのサイズを改善できます。ただし、 C++ または Rust で記述されたライブラリを あります。

既存のエコシステムからコードを移植できることは非常に有益ですが、いくつか重要な点があります。 JavaScript の違いについて学びました。そのうちの 1 つは さまざまなアプローチがあります。

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 と推移的にすべてのビューを無効にします。 それに支えられています

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 サニタイザーをぜひお試しください。 昨年追加されたサポート Chrome Dev Summit の 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 は、私たちのような図書館のユースケースに適しています。たとえば、リークをコンソールに出力したい場合などです。 アプリケーションを実行し続けることができます。

2 つ目のヘルパーを 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 側から呼び出します。これを C++ 側ではなく JavaScript 側のほうが、すべてのスコープが チェックを実行するまでに、一時 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

外観が大幅に改善されました。

「12 バイトの直接リーク」というメッセージのスクリーンショットGenericBindingType RawImage ::toWireType 関数から

Emscripten の内部を示しているため、スタックトレースの一部があいまいに見えますが、 「ワイヤータイプ」への RawImage コンバージョンに原因があることを伝える(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 は メモリアドレスが 1、2、3、4 の変数にたまたま 示されます。

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

メモリのバグを 1 つずつ探し続けることはできますが、 メモリ管理に対する現在のアプローチでは 厄介な体系的な問題が起きています

中にはサニタイザーですぐに捕捉できるものもあります。また、キャッチするために複雑な技が必要なクリエイターもいます。 最後に、投稿の冒頭にある問題があります。ログからわかるように、 検出されることはありません。なぜなら、実際の不正使用は JavaScript 側。サニタイザーは参照できません。これらの問題は 本番環境で、または将来的に無関係と思われるコード変更を行った後にのみです。

安全なラッパーの作成

少し前のステップに戻り、コードを再構成して、これらの問題をすべて修正しましょう。 保護します。ここでも ImageQuant ラッパーを例として使用しますが、同様のリファクタリング ルールが適用されます。 すべてのコーデック、および他の類似のコードベースに対して適用できます。

まず、解放後の使用の問題を投稿の最初から修正しましょう。そのためには、 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);
}

しかし、JavaScript とのやり取りに Embind をすでに使用しているため、 C++ メモリ管理の詳細を完全に非表示にして、API の安全性をさらに高めます。

そのため、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;
}

1 つの変更で、結果のバイト配列が 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 と WebAssembly のバグによる影響が軽減されません。 少なくとも、静的な言語コードによって自己完結型のバグが発生する可能性は減ります。
  • 使用する言語にかかわらず、開発時にサニタイザーを使用してコードを実行すると、 静的な言語コードの問題だけでなく、JavaScript 全体の問題も捕捉します Џ .delete() の呼び出しを忘れた場合や、WebAssembly から無効なポインタが渡された場合など 見てみましょう。
  • 可能であれば、管理対象外データやオブジェクトを WebAssembly から JavaScript に公開しないでください。 JavaScript はガベージ コレクションの対象言語であり、手動でのメモリ管理は一般的ではありません。 これは、WebAssembly を使用する言語のメモリモデルの抽象化リークと考えられます。 JavaScript コードベースでは、誤った管理が見落とされがちです。
  • 言うまでもなく、他のコードベースと同様に、変更可能な状態をグローバル 使用します。さまざまな呼び出しでのコードの再利用に関する問題をデバッグしたり、 できるだけ自己完結型にすることをおすすめします。