Meskipun JavaScript cukup mudah dibersihkan, bahasa statis jelas tidak...
Squoosh.app adalah PWA yang menggambarkan betapa banyaknya codec dan setelan gambar yang berbeda dapat meningkatkan ukuran file gambar tanpa memengaruhi kualitas secara signifikan. Namun, ini juga merupakan demo teknis yang menunjukkan cara membawa library yang ditulis dalam C++ atau Rust dan membawanya ke web.
Kemampuan mentransfer kode dari ekosistem yang ada sangatlah berharga, tetapi ada beberapa perbedaan utama antara bahasa statis dan JavaScript tersebut. Salah satunya adalah pendekatan mereka yang berbeda terhadap manajemen memori.
Meskipun JavaScript cukup mudah dibersihkan, bahasa statis seperti itu jelas tidak. Anda harus secara eksplisit meminta memori baru yang dialokasikan dan benar-benar harus memastikan Anda mengembalikan memori setelahnya, dan tidak pernah menggunakannya lagi. Jika itu tidak terjadi, Anda mengalami kebocoran... dan itu benar-benar terjadi secara teratur. Mari kita lihat bagaimana Anda dapat men-debug kebocoran memori tersebut, dan lebih baik lagi, bagaimana Anda dapat mendesain kode untuk menghindarinya pada lain waktu.
Pola mencurigakan
Baru-baru ini, saat mulai mengerjakan Squoosh, saya melihat pola yang menarik dalam wrapper codec C++. Mari kita lihat wrapper ImageQuant sebagai contoh (dikurangi sehingga hanya menampilkan pembuatan objek dan dealokasi bagian):
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 (baik, 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
);
}
Apakah Anda melihat masalah? Petunjuk: ini digunakan setelah gratis, tetapi dalam JavaScript.
Di Emscripten, typed_memory_view
menampilkan Uint8Array
JavaScript yang didukung oleh buffer memori WebAssembly (Wasm), dengan byteOffset
dan byteLength
ditetapkan ke pointer dan panjang yang diberikan. Poin utamanya adalah bahwa ini adalah tampilan TypedArray ke buffer memori WebAssembly, bukan salinan data yang dimiliki JavaScript.
Saat kita memanggil free_result
dari JavaScript, fungsi C standar akan memanggil free
guna menandai
memori ini sebagai tersedia untuk alokasi mendatang, yang berarti data yang dilihat oleh
Uint8Array
kita, dapat ditimpa dengan data arbitrer oleh panggilan mendatang ke Wasm.
Atau, beberapa implementasi free
bahkan mungkin memutuskan untuk langsung mengisi nol memori yang dibebaskan. free
yang digunakan Emscripten tidak melakukannya, tetapi di sini kami mengandalkan detail implementasi
yang tidak dapat dijamin.
Atau, meskipun memori di belakang pointer dipertahankan, alokasi baru mungkin perlu meningkatkan memori WebAssembly. Saat WebAssembly.Memory
dikembangkan melalui JavaScript API, atau petunjuk
memory.grow
yang sesuai, tindakan ini akan membatalkan ArrayBuffer
yang ada dan, secara transitif, semua tampilan
yang didukung olehnya.
Saya akan menggunakan konsol DevTools (atau Node.js) untuk mendemonstrasikan perilaku ini:
> 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
Terakhir, meskipun kita tidak secara eksplisit memanggil Wasm lagi antara free_result
dan new
Uint8ClampedArray
, pada waktu tertentu kita mungkin akan menambahkan dukungan multithreading ke codec kita. Dalam hal ini,
thread tersebut dapat berupa thread yang sama sekali berbeda, yang menimpa data tepat sebelum kita berhasil meng-clone data tersebut.
Mencari bug memori
Untuk berjaga-jaga, saya telah memutuskan untuk melangkah lebih jauh dan memeriksa apakah kode ini menunjukkan masalah dalam praktiknya. Sepertinya ini adalah kesempatan yang sempurna untuk mencoba dukungan sanitizer Empscripten yang baru(ish) yang telah ditambahkan tahun lalu dan dipresentasikan dalam diskusi WebAssembly di Chrome Dev Summit:
Dalam hal ini, kita ingin menggunakan
AddressSanitizer, yang
dapat mendeteksi berbagai masalah terkait pointer dan memori. Untuk menggunakannya, kita perlu mengompilasi ulang codec
dengan -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
Ini akan otomatis mengaktifkan pemeriksaan keamanan pointer, tetapi kita juga ingin menemukan potensi kebocoran memori. Karena kita menggunakan ImageQuant sebagai library, bukan program, tidak ada "titik keluar" tempat Emscripten dapat otomatis memvalidasi bahwa semua memori telah dibebaskan.
Sebagai gantinya, untuk kasus semacam ini, LeakSanitizer (disertakan dalam AddressSanitizer) menyediakan fungsi
__lsan_do_leak_check
dan
__lsan_do_recoverable_leak_check
,
yang dapat dipanggil secara manual setiap kali kita mengharapkan semua memori dibebaskan dan ingin memvalidasi asumsi
tersebut. __lsan_do_leak_check
dimaksudkan untuk digunakan di akhir aplikasi yang berjalan, saat Anda ingin
membatalkan proses jika ada kebocoran yang terdeteksi, sedangkan __lsan_do_recoverable_leak_check
lebih cocok untuk kasus penggunaan library seperti milik kita, saat Anda ingin mencetak kebocoran ke konsol, tetapi
tetap memastikan aplikasi tetap berjalan.
Mari kita ekspos helper kedua tersebut melalui Embind sehingga kita dapat memanggilnya dari JavaScript kapan saja:
#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);
}
Dan panggil dari sisi JavaScript setelah kita selesai dengan gambar. Melakukan hal ini dari sisi JavaScript, bukan dari C++, membantu memastikan bahwa semua cakupan telah dikeluarkan dan semua objek C++ sementara dibebaskan pada saat kami menjalankan pemeriksaan tersebut:
// …
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
);
}
Tindakan ini memberi kita laporan seperti berikut di konsol:
Aduh, ada beberapa kebocoran kecil, tetapi stacktrace tidak terlalu membantu karena semua nama fungsi rusak. Mari kita kompilasi ulang dengan info proses debug dasar untuk mempertahankannya:
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
Ini terlihat jauh lebih baik:
Beberapa bagian stacktrace masih terlihat tidak jelas karena mengarah ke internal Emscripten, tetapi kita dapat
mengetahui bahwa kebocoran berasal dari konversi RawImage
ke "jenis kabel" (ke nilai JavaScript) oleh
Embind. Memang, saat melihat kode, kita dapat melihat bahwa kita menampilkan instance C++ RawImage
ke JavaScript, tetapi kita tidak pernah membebaskannya di kedua sisi.
Sebagai pengingat, saat ini tidak ada integrasi pembersihan sampah memori antara JavaScript dan
WebAssembly, meskipun salah satunya sedang dikembangkan. Sebaliknya, Anda harus
mengosongkan memori dan memanggil destruktor secara manual dari sisi JavaScript setelah selesai
dengan objek. Khusus untuk Embind, dokumen resmi menyarankan untuk memanggil metode .delete()
pada class C++ yang terekspos:
Kode JavaScript harus secara eksplisit menghapus setiap objek C++ yang menanganinya yang diterima, atau heap Emscripten akan bertambah tanpa batas.
var x = new Module.MyClass; x.method(); x.delete();
Memang, saat melakukannya di JavaScript untuk class kita:
// …
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
);
}
Kebocoran akan hilang seperti yang diharapkan.
Menemukan lebih banyak masalah terkait pembersih udara
Membuat codec Squoosh lainnya dengan pembersih akan mengungkap masalah serupa dan juga beberapa masalah baru. Misalnya, saya mendapatkan error ini di binding MozJPEG:
Di sini, ini bukan kebocoran, tapi kita menulis ke memori di luar batas yang dialokasikan 😱
Menggali kode MozJPEG, kami menemukan bahwa masalahnya di sini adalah jpeg_mem_dest
—fungsi
yang kita gunakan untuk mengalokasikan tujuan memori untuk JPEG—menggunakan kembali nilai
outbuffer
dan outsize
yang ada saat
bernilai bukan nol:
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;
}
Namun, kami akan memanggilnya tanpa menginisialisasi salah satu variabel tersebut, yang berarti MozJPEG akan menulis hasilnya ke alamat memori berpotensi acak yang kebetulan tersimpan dalam variabel tersebut pada saat panggilan.
uint8_t* output;
unsigned long size;
// …
jpeg_mem_dest(&cinfo, &output, &size);
Inisialisasi kedua variabel sebelum pemanggilan menyelesaikan masalah ini, dan sekarang kode mencapai pemeriksaan kebocoran memori. Untungnya, pemeriksaan berhasil lulus, yang menunjukkan bahwa kita tidak memiliki kebocoran dalam codec ini.
Masalah terkait status bersama
...Atau kita begitu?
Kita tahu bahwa binding codec menyimpan beberapa status serta hasil dalam variabel statis global, dan MozJPEG memiliki beberapa struktur yang sangat rumit.
uint8_t* last_result;
struct jpeg_compress_struct cinfo;
val encode(std::string image_in, int image_width, int image_height, MozJpegOptions opts) {
// …
}
Bagaimana jika beberapa layanan tersebut diinisialisasi dengan lambat saat pertama kali dijalankan, lalu digunakan kembali dengan tidak benar pada proses selanjutnya? Satu panggilan dengan pembersih udara tidak akan melaporkannya sebagai bermasalah.
Mari kita coba dan proses gambar beberapa kali dengan mengklik secara acak pada tingkat kualitas yang berbeda di UI. Kini kita mendapatkan laporan berikut:
262.144 byte—sepertinya seluruh gambar contoh bocor dari jpeg_finish_compress
.
Setelah memeriksa dokumen dan contoh resmi, ternyata jpeg_finish_compress
tidak mengosongkan memori yang dialokasikan oleh panggilan jpeg_mem_dest
kita sebelumnya—ini hanya membebaskan
struktur kompresi, meskipun struktur kompresi sudah mengetahui tentang tujuan
memori kita... Yah.
Kita dapat memperbaikinya dengan mengosongkan data secara manual di fungsi 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);
}
Saya bisa terus memburu bug memori itu satu per satu, tetapi menurut saya sudah cukup jelas bahwa pendekatan pengelolaan memori saat ini menyebabkan beberapa masalah sistematis yang jahat.
Beberapa di antaranya dapat langsung ditangkap oleh pembersih. Yang lainnya membutuhkan trik rumit agar dapat ditangkap. Terakhir, ada masalah di awal postingan yang, seperti yang kita lihat dari log, tidak tertangkap sama sekali oleh sanitizer. Alasannya adalah penyalahgunaan yang sebenarnya terjadi di sisi JavaScript, yang membuat sanitizer tidak memiliki visibilitas. Masalah tersebut hanya akan muncul dalam produksi atau setelah perubahan yang tampaknya tidak terkait pada kode di masa mendatang.
Membuat wrapper yang aman
Mari kita mundur beberapa langkah, dan perbaiki semua masalah ini dengan merestrukturisasi kode dengan cara yang lebih aman. Saya akan menggunakan wrapper ImageQuant sebagai contoh lagi, tetapi aturan pemfaktoran ulang serupa berlaku untuk semua codec, serta codebase serupa lainnya.
Pertama-tama, mari kita perbaiki masalah {i> Use-after-free<i} dari awal postingan. Untuk itu, kita perlu meng-clone data dari tampilan yang didukung WebAssembly sebelum menandainya sebagai bebas di sisi 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;
}
Sekarang, mari pastikan bahwa kita tidak membagikan status apa pun dalam variabel global di antara pemanggilan. Hal ini akan memperbaiki beberapa masalah yang pernah kita lihat, serta akan mempermudah penggunaan codec kita di lingkungan multi-thread di masa mendatang.
Untuk melakukannya, kita memfaktorkan ulang wrapper C++ untuk memastikan bahwa setiap panggilan ke fungsi mengelola datanya sendiri menggunakan variabel lokal. Kemudian, kita dapat mengubah tanda tangan fungsi free_result
untuk
menerima pointer kembali:
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);
}
Namun, karena kita sudah menggunakan Embind di Emscripten untuk berinteraksi dengan JavaScript, kita mungkin juga akan menjadikan API ini lebih aman dengan menyembunyikan detail pengelolaan memori C++ sepenuhnya.
Untuk itu, mari pindahkan bagian new Uint8ClampedArray(…)
dari JavaScript ke sisi C++ dengan
Embind. Kemudian, kita dapat menggunakannya untuk meng-clone data ke dalam memori JavaScript bahkan sebelum ditampilkan dari fungsi:
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;
}
Perlu diketahui, dengan satu perubahan, kita memastikan bahwa array byte yang dihasilkan dimiliki oleh JavaScript
dan tidak didukung oleh memori WebAssembly, dan juga menghapus wrapper RawImage
yang bocor
sebelumnya.
Sekarang JavaScript tidak perlu lagi mengkhawatirkan pengosongan data, dan dapat menggunakan hasilnya seperti objek lainnya yang dibersihkan sampah memori:
// …
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);
}
Ini juga berarti kita tidak lagi memerlukan binding free_result
kustom di sisi C++:
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());
}
Secara keseluruhan, kode wrapper menjadi lebih bersih dan aman pada saat yang sama.
Setelah itu, saya melakukan beberapa peningkatan kecil lebih lanjut pada kode wrapper ImageQuant dan mereplikasi perbaikan pengelolaan memori serupa untuk codec lainnya. Jika ingin mengetahui detail selengkapnya, Anda dapat melihat PR yang dihasilkan di sini: Perbaikan memori untuk codec C++.
Takeaway
Pelajaran apa yang dapat kita pelajari dan bagikan dari pemfaktoran ulang ini yang dapat diterapkan ke codebase lain?
- Jangan gunakan tampilan memori yang didukung oleh WebAssembly—apa pun bahasa pembuatannya—di luar satu pemanggilan. Anda tidak dapat mengandalkannya untuk bertahan lebih lama dari itu, dan Anda tidak akan dapat menangkap bug ini secara konvensional. Jadi, jika Anda perlu menyimpan data untuk nanti, salin ke sisi JavaScript dan simpan di sana.
- Jika memungkinkan, gunakan bahasa pengelolaan memori yang aman atau, setidaknya, wrapper jenis yang aman, bukan beroperasi pada pointer mentah secara langsung. Hal ini tidak akan menghindarkan Anda dari bug pada batas JavaScript Referensi WebAssembly, tetapi setidaknya akan mengurangi kemunculan bug yang ada di kode bahasa statis.
- Apa pun bahasa yang Anda gunakan, jalankan kode dengan sanitizer selama pengembangan. Fungsi ini tidak hanya dapat membantu
menangkap masalah dalam kode bahasa statis, tetapi juga beberapa masalah di seluruh batas JavaScript terkini di
WebAssembly, seperti lupa memanggil
.delete()
atau meneruskan pointer yang tidak valid dari sisi JavaScript. - Jika memungkinkan, hindari mengekspos data dan objek yang tidak dikelola dari WebAssembly ke JavaScript sama sekali. JavaScript adalah bahasa yang mengumpulkan sampah, dan manajemen memori manual tidak umum di dalamnya. Hal ini dapat dianggap sebagai kebocoran abstraksi model memori bahasa yang digunakan WebAssembly Anda, dan pengelolaan yang salah mudah diabaikan dalam codebase JavaScript.
- Hal ini mungkin sudah jelas, tetapi, seperti pada codebase lainnya, hindari menyimpan status yang dapat diubah dalam variabel global. Anda tidak ingin men-debug masalah terkait penggunaan ulangnya di berbagai pemanggilan atau bahkan thread, jadi sebaiknya jaga agar tetap mandiri.