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

您發現問題了嗎?提示:是使用後使用,不過在 JavaScript 中!

在 Emscripten 中,typed_memory_view 會傳回 WebAssembly (Wasm) 記憶體緩衝區支援的 JavaScript Uint8Array,並將 byteOffsetbyteLength 設為指定的指標和長度。重點在於這是進入 WebAssembly 記憶體緩衝區的 TypedArray 檢視畫面,而非 JavaScript 擁有的資料副本。

從 JavaScript 呼叫 free_result 時,系統會接著呼叫標準 C 函式 free,將此記憶體標示為可用於日後配置的配置。也就是說,未來對 Wasm 的呼叫,都可以用任意資料覆寫 Uint8Array 檢視點所指向的資料。

或者,某些 free 實作甚至可能會決定立即為釋放的記憶體填入資料。Emscripten 使用的 free 無法達到此效果,但我們採用的是,我們仰賴的實作詳細資料不可保證。

或者,即使指標背後的記憶體會保留下來,新的配置可能需要加大 WebAssembly 記憶體。無論是透過 JavaScript API 或對應的 memory.grow 指令擴充 WebAssembly.Memory,都會讓現有的 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 全新 Emscripten Sanitizers

在這個範例中,我們著重使用 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 做為程式庫,而不是程式,因此不會有「結束點」自動驗證所有記憶體是否已釋放。

相反地,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

這樣看起來更好:

訊息螢幕擷取畫面,顯示來自 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 端。
  • 盡可能使用安全的記憶體管理語言,或至少使用安全的類型包裝函式,而不要直接操作原始指標。這不會導致您避免 JavaScript SoAssembly 邊界上的錯誤,但至少會降低靜態語言代碼本身含有的錯誤出現率。
  • 無論使用哪種語言,請在開發期間使用掃毒程式執行程式碼,不僅能協助找出靜態語言代碼的問題,還能解決 JavaScript noAssembly 邊界的問題,例如忘記呼叫 .delete() 或從 JavaScript 端傳遞無效指標。
  • 如果可以,請避免將 WebAssembly 中的非受管資料和物件全部提供給 JavaScript。JavaScript 是一種垃圾收集語言,並不常手動管理記憶體。這可視為 WebAssembly 建構語言的記憶體模型的抽象流失問題,且 JavaScript 程式碼集很容易忽視錯誤的管理方式。
  • 雖然這可能顯而易見,但就像任何其他程式碼集一樣,請避免在全域變數中儲存可變動狀態。您不想對在不同叫用 (甚至執行緒) 中重複使用問題進行偵錯,因此最好盡可能保持獨立狀態。