使用 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 (well、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,我們有時可能會在轉碼器中新增多執行緒支援。這樣的話 可能是完全不同的執行緒來覆寫資料,直到我們複製資料為止。

尋找記憶體錯誤

若是如此,我決定進一步檢查,看看這個程式碼是否確實存在任何實際問題。 這似乎是試試全新伊米蘭消毒液的好機會 支援 ,並在 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 端叫用此物件。方法是從 而非 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 程式碼

提醒您,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 端,而掃毒程式沒有所能看見的部分。這些問題會使自己 只有在實際工作環境中,或在日後看似無關的變更程式碼之後。

建立安全的包裝函式

讓我們先採取幾個步驟,即可透過重組程式碼來修正所有問題 以更安全的方式我再次使用 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++ 端 表情符號然後,我們可以使用它將資料複製到 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());
}

總而言之,Google 的包裝函式程式碼不僅變得更簡潔,也更安全。

在此之後,我對 ImageQuant 包裝函式的程式碼做了一些小小改善 為其他轉碼器複製類似的記憶體管理修正項目。如果需要更多資訊 您可以在此看到產生的 PR:C++ 的記憶體修正 轉碼器

重點整理

我們從這項重構作業中學到什麼,又能應用於其他程式碼集?

  • 無論使用何種語言建立,請勿使用 WebAssembly 支援的記憶體檢視,除了 單一叫用。您無法在這些時間繼續有效存活,而且您將無法 因此,如果您需要儲存資料供日後使用,請將資料複製到 然後儲存程式碼
  • 盡可能使用安全記憶體管理語言,或至少使用安全的型別包裝函式,不要 直接使用原始指標執行各項作業這麼做並不會讓您因 JavaScript 將使用者導向 WebAssembly 上的錯誤 但至少能減少靜態語言代碼中獨立的錯誤面。
  • 無論您使用哪種語言,在開發期間透過掃毒程式執行程式碼, 您不僅可找出靜態語言代碼中的問題,還能找出 JavaScript 🏡? 上的問題 WebAssembly 邊界,例如忘記呼叫 .delete(),或從 程式碼。
  • 請盡可能避免將非代管資料和物件從 WebAssembly 完全公開到 JavaScript。 JavaScript 是一種垃圾收集語言,而手動管理記憶體並不常見。 對於 WebAssembly 語言的記憶體模型,這可以視為抽象層流失 ,而且 JavaScript 程式碼集中的管理方式也很容易忽略。
  • 這可能很明顯,但就像任何其他程式碼集一樣,請避免將可變動狀態儲存在全域內 變數。您不想對在不同叫用時重複使用的問題進行偵錯, 所以最好盡量獨立成目錄