Debug delle perdite di memoria in WebAssembly utilizzando Emscripten

Sebbene JavaScript sia abbastanza tollerante per quanto riguarda la pulizia dopo l'uso, i linguaggi statici non lo sono affatto…

Ingvar Stepanyan
Ingvar Stepanyan

Squoosh.app è una PWA che spiega quanti codec e impostazioni delle immagini diversi possono migliorare le dimensioni del file immagine senza influire significativamente sulla qualità. Tuttavia, è anche una demo tecnica che mostra come puoi trasferire le librerie scritte in C++ o Rust sul web.

La possibilità di eseguire il porting del codice da ecosistemi esistenti è incredibilmente preziosa, ma esistono alcune differenze fondamentali tra questi linguaggi statici e JavaScript. Uno di questi è nei diversi approcci alla gestione della memoria.

Sebbene JavaScript sia abbastanza tollerante per quanto riguarda la pulizia dopo l'uso, questi linguaggi statici non lo sono. Devi richiedere esplicitamente una nuova memoria allocata e assicurarti di restituirla in seguito e di non usarla mai più. Se ciò non accade, si verificano perdite e accade abbastanza regolarmente. Vediamo come puoi eseguire il debug delle perdite di memoria e, meglio ancora, come puoi progettare il codice per evitarle la prossima volta.

Pattern sospetto

Di recente, mentre iniziavo a lavorare a Squoosh, non ho potuto fare a meno di notare uno schema interessante nei wrapper dei codec C++. Diamo un'occhiata a un wrapper ImageQuant come esempio (ridotto per mostrare solo le parti per la creazione di oggetti e la deallocation):

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 (o meglio 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
  );
}

Hai notato un problema? Suggerimento: si tratta di uso dopo svuotamento, ma in JavaScript.

In Emscripten, typed_memory_view restituisce un Uint8Array JavaScript supportato dal buffer della memoria WebAssembly (Wasm), con byteOffset e byteLength impostati sul puntatore e sulla lunghezza specificati. Il punto principale è che si tratta di una visualizzazione TypedArray in un buffer della memoria WebAssembly, anziché di una copia dei dati di proprietà di JavaScript.

Quando chiamiamo free_result da JavaScript, questa a sua volta chiama una funzione C standard free per contrassegnare questa memoria come disponibile per eventuali allocazioni future, il che significa che i dati a cui fa riferimento la nostra vista Uint8Array possono essere sovrascritti con dati arbitrari da qualsiasi chiamata futura in Wasm.

In alternativa, alcune implementazioni di free potrebbero anche decidere di azzerare immediatamente la memoria liberata. free utilizzato da Emscripten non lo fa, ma stiamo facendo affidamento su un dettaglio di implementazione che non può essere garantito.

Oppure, anche se la memoria dietro il puntatore viene conservata, la nuova allocazione potrebbe dover aumentare la memoria di WebAssembly. Quando WebAssembly.Memory viene ampliato tramite l'API JavaScript o l'istruzione memory.grow corrispondente, viene invalidato ArrayBuffer esistente e, per estensione, tutte le visualizzazioni supportate.

Utilizzerò la console DevTools (o Node.js) per dimostrare questo comportamento:

> 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

Infine, anche se non chiamiamo esplicitamente Wasm tra free_result e new Uint8ClampedArray, a un certo punto potremmo aggiungere il supporto del multithreading ai nostri codec. In questo caso, potrebbe trattarsi di un thread completamente diverso che sovrascrive i dati appena prima che riusciamo a clonarli.

Ricerca di bug di memoria

Per sicurezza, ho deciso di andare oltre e verificare se il codice presenta problemi nella pratica. Mi sembra l'occasione perfetta per provare il nuovo supporto per i disinfettanti Emscripten che è stato aggiunto lo scorso anno e presentato nel nostro discorso WebAssembly al Chrome Dev Summit:

In questo caso, siamo interessati a AddressSanitizer, che può rilevare vari problemi relativi a puntatore e memoria. Per utilizzarlo, dobbiamo ricompilare il codec con -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

Verranno attivati automaticamente i controlli di sicurezza degli indicatori, ma vogliamo anche trovare potenziali perdite di memoria. Poiché utilizziamo ImageQuant come libreria anziché come programma, non esiste un "punto di uscita" in cui Emscripten possa convalidare automaticamente che tutta la memoria sia stata liberata.

In questi casi, LeakSanitizer (incluso in AddressSanitizer) fornisce le funzioni __lsan_do_leak_check e __lsan_do_recoverable_leak_check, che possono essere richiamate manualmente ogni volta che ci aspettiamo che tutta la memoria venga liberata e vogliamo convalidare questa ipotesi. __lsan_do_leak_check è pensato per essere utilizzato alla fine di un'applicazione in esecuzione, quando vuoi interrompere il processo nel caso in cui vengano rilevati perdite, mentre __lsan_do_recoverable_leak_check è più adatto per casi d'uso della libreria come il nostro, quando vuoi stampare le perdite nella console, ma mantenere l'applicazione in esecuzione indipendentemente da ciò.

Esponiamo il secondo helper tramite Embind in modo da poterlo chiamare in qualsiasi momento da 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);
}

E invocalo lato JavaScript al termine dell'immagine. Eseguire questa operazione dal lato JavaScript, anziché da quello C++, consente di assicurarsi che tutti gli ambiti siano stati chiusi e che tutti gli oggetti C++ temporanei siano stati liberati al momento dell'esecuzione di questi controlli:

  // 

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

Nella console viene visualizzato un report come il seguente:

Screenshot di un messaggio

Ahimè, ci sono alcuni piccoli leak, ma lo stack trace non è molto utile perché tutti i nomi delle funzioni sono manipolati. Ricompilamolo con informazioni di debug di base per conservarle:

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

Ha un aspetto molto migliore:

Screenshot di un messaggio con dicitura &quot;fuga diretta di 12 byte&quot; proveniente da una funzione RawImage GenericBindingType ::toWireType

Alcune parti dello stacktrace sembrano ancora oscure poiché puntano a elementi interni di Emscripten, ma possiamo dimostrare che la perdita proviene da una conversione RawImage in "wire type" (a un valore JavaScript) da Embind. Infatti, se osserviamo il codice, possiamo vedere che restituiamo RawImage istanze C++ a JavaScript, ma non le liberiamo mai su entrambi i lati.

Ti ricordiamo che al momento non esiste un'integrazione della raccolta dei rifiuti tra JavaScript e WebAssembly, anche se ne è in corso lo sviluppo. Devi invece liberare manualmente la memoria e chiamare i distruttori lato JavaScript al termine dell'uso dell'oggetto. In particolare per Embind, i documenti ufficiali suggeriscono di chiamare un metodo .delete() sulle classi C++ esposte:

Il codice JavaScript deve eliminare esplicitamente tutti gli handle degli oggetti C++ che ha ricevuto, altrimenti l'heap di Emscripten crescerà indefinitamente.

var x = new Module.MyClass;
x.method();
x.delete();

In effetti, quando lo facciamo in JavaScript per la nostra classe:

  // 

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

La perdita scompare come previsto.

Scoprire altri problemi relativi ai disinfettanti

La creazione di altri codec Squoosh con gli strumenti di sanitizzazione rivela problemi simili e alcuni nuovi. Ad esempio, ho ricevuto questo errore nelle associazioni MozJPEG:

Screenshot di un messaggio

Qui non si tratta di una perdita, ma di una scrittura in una memoria al di fuori dei confini allocati 😱

Analizzando il codice di MozJPEG, scopriamo che il problema è che jpeg_mem_dest, la funzione che utilizziamo per allocare una destinazione di memoria per i file JPEG, riutilizza i valori esistenti di outbuffer e outsize quando sono diversi da zero:

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;
}

Tuttavia, lo chiamiamo senza inizializzare nessuna di queste variabili, il che significa che MozJPEG scrive il risultato in un indirizzo di memoria potenzialmente casuale che si trovava memorizzato in queste variabili al momento della chiamata.

uint8_t* output;
unsigned long size;
// …
jpeg_mem_dest(&cinfo, &output, &size);

L'inizializzazione a zero di entrambe le variabili prima dell'invocazione risolve il problema e ora il codice raggiunge un controllo di perdita di memoria. Fortunatamente, il controllo è andato a buon fine, il che indica che non ci sono fughe di dati in questo codec.

Problemi con lo stato condiviso

…O no?

Sappiamo che le nostre associazioni di codec memorizzano parte dello stato e i risultati in variabili statiche globali e che MozJPEG ha alcune strutture particolarmente complicate.

uint8_t* last_result;
struct jpeg_compress_struct cinfo;

val encode(std::string image_in, int image_width, int image_height, MozJpegOptions opts) {
  // …
}

Cosa succede se alcuni vengono inizializzati in modo lazy alla prima esecuzione e poi riutilizzati in modo improprio nelle esecuzioni future? In tal caso, una singola chiamata con un disinfettante non li segnalerebbe come problematici.

Proviamo a elaborare l'immagine un paio di volte facendo clic in modo casuale su diversi livelli di qualità nell'interfaccia utente. Ora invece riceviamo il seguente rapporto:

Screenshot di un messaggio

262.144 byte: sembra che l'intera immagine di esempio sia stata divulgata da jpeg_finish_compress.

Dopo aver controllato la documentazione e gli esempi ufficiali, è emerso che jpeg_finish_compress non libera la memoria allocata dalla chiamata jpeg_mem_dest precedente, ma solo la struttura di compressione, anche se questa struttura di compressione conosce già la destinazione della memoria…

Possiamo risolvere il problema liberando i dati manualmente nella funzione 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);
}

Potrei continuare a cercare questi bug di memoria uno per uno, ma penso che a questo punto sia abbastanza chiaro che l'attuale approccio alla gestione della memoria porta a dei problemi sistematici molto gravi.

Alcuni di questi possono essere rilevati immediatamente dal disinfettante. Altri richiedono trucchi intricati per essere catturati. Infine, ci sono problemi come all'inizio del post che, come possiamo vedere dai log, non vengono rilevati dal programma di sanificazione. Il motivo è che l'effettivo uso improprio si verifica sul lato JavaScript, in cui il disinfettante non ha visibilità. Questi problemi si manifesteranno solo in produzione o dopo modifiche apparentemente non correlate al codice in futuro.

Creazione di un wrapper sicuro

Facciamo un paio di passi indietro e risolviamo tutti questi problemi ristrutturando il codice in modo più sicuro. Utilizzerò di nuovo il wrapper ImageQuant come esempio, ma regole di refactoring simili si applicano a tutti i codec, nonché ad altre basi di codice simili.

Prima di tutto, risolviamo il problema dell'utilizzo senza sosta dall'inizio del post. Per farlo, dobbiamo clonare i dati dalla visualizzazione basata su WebAssembly prima di contrassegnarli come gratuiti lato 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;
}

Ora assicuriamoci di non condividere alcun stato nelle variabili globali tra le invocazioni. In questo modo, risolveremo alcuni dei problemi che abbiamo già riscontrato e, in futuro, sarà più facile utilizzare i nostri codec in un ambiente multithread.

Per farlo, occorre refactoring del wrapper C++ per assicurarci che ogni chiamata alla funzione gestisca i propri dati utilizzando le variabili locali. Poi possiamo modificare la firma della nostra funzione free_result in modo da accettare di nuovo il puntatore:

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

Tuttavia, poiché utilizziamo già Embind in Emscripten per interagire con JavaScript, potremmo anche rendere l'API ancora più sicura nascondendo del tutto i dettagli di gestione della memoria C++.

A questo scopo, spostiamo la parte new Uint8ClampedArray(…) da JavaScript al lato C++ con Embind. Poi possiamo utilizzarlo per clonare i dati nella memoria JavaScript anche prima di uscire dalla funzione:

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;
}

Tieni presente che, con una singola modifica, ci assicuriamo che l'array di byte risultante sia di proprietà di JavaScript e non sia supportato dalla memoria WebAssembly, e ci liberiamo anche del wrapper RawImage precedentemente divulgato.

Ora JavaScript non deve più preoccuparsi di liberare i dati e può utilizzare il risultato come qualsiasi altro oggetto sottoposto a garbage collection:

  // 

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

Ciò significa anche che non abbiamo più bisogno di una binding free_result personalizzata lato 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());
}

Nel complesso, il nostro codice del wrapper è diventato più pulito e sicuro allo stesso tempo.

Dopodiché ho apportato alcuni ulteriori miglioramenti minori al codice del wrapper ImageQuant e ho replicato correzioni simili per la gestione della memoria per altri codec. Se ti interessano maggiori dettagli, puoi vedere il PR risultante qui: Correzioni di memoria per i codec C++.

Concetti principali

Quali lezioni possiamo imparare e condividere da questo refactoring che potrebbero essere applicate ad altre basi di codice?

  • Non utilizzare le visualizzazioni della memoria basate su WebAssembly, indipendentemente dal linguaggio in cui sono create, oltre a una singola chiamata. Non puoi fare affidamento sulla loro sopravvivenza per più tempo e non potrai rilevare questi bug con i mezzi convenzionali, quindi se devi archiviare i dati per un uso successivo, copiali sul lato JavaScript e archiviali lì.
  • Se possibile, utilizza un linguaggio di gestione della memoria sicuro o, almeno, wrapper di tipo sicuro anziché operare direttamente sui puntatori non elaborati. Questo non ti eviterà di bug nel confine di JavaScript ↔ WebAssembly, ma almeno ridurrà la superficie di bug autonomi dal codice del linguaggio statico.
  • Indipendentemente dal linguaggio che utilizzi, esegui il codice con gli strumenti di sanitizzazione durante lo sviluppo: possono aiutarti a rilevare non solo i problemi nel codice del linguaggio statico, ma anche alcuni problemi al confine tra JavaScript e WebAssembly, ad esempio l'oblio di chiamare .delete() o il passaggio di puntatori non validi dal lato JavaScript.
  • Se possibile, evita di esporre del tutto i dati e gli oggetti non gestiti da WebAssembly a JavaScript. JavaScript è un linguaggio con garbage collection e la gestione manuale della memoria non è comune. Questo può essere considerato una perdita di astrazione del modello di memoria del linguaggio in cui è stato creato il codice WebAssembly e la gestione errata è facile da trascurare in una base di codice JavaScript.
  • Questo potrebbe essere ovvio ma, come in qualsiasi altro codebase, evita di archiviare lo stato modificabile in variabili globali. Non vuoi dover eseguire il debug di problemi relativi al riutilizzo in varie invocazioni o persino in vari thread, quindi è meglio mantenerlo il più autonomo possibile.