使用 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 支援的任何檢視畫面。

我將使用 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 開發人員大會的 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 分配記憶體目的地的函式。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) {
  // …
}

如果其中部分在首次執行時以延遲方式初始化,然後在日後的執行作業中不當重複使用,該怎麼辦?這樣一來,單一呼叫的消毒程式就不會將其回報為有問題。

請在 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 程式碼庫中被忽略。
  • 這可能很明顯,但就像任何其他程式碼庫一樣,請避免在全域變數中儲存可變動的狀態。您不希望在各種呼叫或甚至執行緒中重複使用時發生偵錯問題,因此最好盡可能讓其保持自給自足。