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

你是否發現問題?提示:這是「使用後釋放」,但在 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 失效,並以傳遞方式影響由 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。如果您想將漏洞列印到主控台,但無論如何都讓應用程式繼續執行,則 __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 函式的訊息,內容為「直接洩漏 12 個位元組」

堆疊追蹤的部分內容仍看起來很模糊,因為它們會指向 Emscripten 內部,但我們可以得知,這項漏洞是由於 Embind 將 RawImage 轉換為「線路類型」(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
  );
}

這時,漏洞會如預期消失。

利用消毒液探索更多問題

使用消毒劑建立其他 Squoosh 編碼器時,也會發現類似問題,以及一些新問題。舉例來說,我曾在 MozJPEG 繫結中遇到這個錯誤:

訊息的螢幕截圖

這並非記憶體外洩,而是我們寫入分配邊界以外的記憶體 😱

深入瞭解 MozJPEG 的程式碼後,我們發現問題出在 jpeg_mem_dest 上,這是我們用來為 JPEG 分配記憶體目的地的函式。jpeg_mem_destoutbufferoutsize 的值不為零時重複使用

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) {
  // …
}

如果其中部分在首次執行時以延遲方式初始化,然後在日後的執行作業中不當重複使用,該怎麼辦?因此,每次呼叫經由消毒器進行呼叫時,並不會回報為問題。

請在 UI 中隨機點選不同的品質等級,嘗試處理圖片幾次。事實上,我們現在取得的報告如下:

訊息的螢幕截圖

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

為此,讓我們使用 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 支援的記憶體檢視畫面 (無論其建構語言為何)。然而,若這些錯誤持續存在,您便無法仰賴它們,您也無法透過傳統方式找出這些錯誤。因此,如果需要儲存資料供日後使用,請複製到 JavaScript 端並儲存。
  • 盡可能使用安全的記憶體管理語言,或至少使用安全型別包裝函式,而非直接對原始指標運作。這不會讓您免於 JavaScript ↔ WebAssembly 邊界上的錯誤,但至少會減少靜態語言程式碼自包含的錯誤面。
  • 無論您使用哪種語言,在開發期間都請使用掃毒程式執行程式碼,除了能找出靜態語言代碼中的問題,還能協助解決 JavaScript 重混 WebAssembly 邊界的相關問題,例如忘記呼叫 .delete(),或從 JavaScript 端傳入無效指標。
  • 盡可能避免將未管理的資料和物件從 WebAssembly 公開至 JavaScript。JavaScript 是一種垃圾收集語言,因此不常使用手動記憶體管理。這可以視為 WebAssembly 所建構語言的記憶體模型抽象洩漏,而錯誤的管理很容易在 JavaScript 程式碼庫中被忽略。
  • 這可能很明顯,但就像在任何其他程式碼集一樣,請避免將可變動狀態儲存在全域變數中。您不想對在不同叫用或執行緒中的重複使用問題進行偵錯,因此最好盡可能讓問題保持獨立。