雖然 JavaScript 可自行執行清除作業,但靜態語言當然不是...
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
,並將 byteOffset
和 byteLength
設為指定的指標和長度。重點在於這是進入 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_result
和 new
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
這樣看起來更好:
當堆疊追蹤指向 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 配置記憶體目的地的函式),當這些值不是零時,會重複使用 outbuffer
和 outsize
的現有值:
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 程式碼集很容易忽視錯誤的管理方式。
- 雖然這可能顯而易見,但就像任何其他程式碼集一樣,請避免在全域變數中儲存可變動狀態。您不想對在不同叫用 (甚至執行緒) 中重複使用問題進行偵錯,因此最好盡可能保持獨立狀態。