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 mostra quanto i diversi codec e le diverse impostazioni delle immagini possano migliorare le dimensioni dei file immagine senza influire in modo significativo 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 chiedere esplicitamente una nuova memoria allocata e assicurarti di restituirla e di non utilizzarla 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.

Modello 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 di creazione e deallocazione degli oggetti):

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 riempire immediatamente la memoria libera con zeri. free utilizzato da Emscripten non lo fa, ma stiamo facendo affidamento su un dettaglio di implementazione che non può essere garantito.

In alternativa, anche se la memoria dietro il puntatore viene conservata, la nuova allocazione potrebbe richiedere un aumento della memoria WebAssembly. Quando WebAssembly.Memory viene ampliato tramite l'API JavaScript o l'istruzione memory.grow corrispondente, viene invalidato l'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 di nuovo 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 fare un passo avanti e controllare se questo codice presenta problemi nella pratica. Sembra un'occasione perfetta per provare il nuovo supporto dei sanitizzatori Emscripten, aggiunto lo scorso anno e presentato nel nostro talk su WebAssembly al Chrome Dev Summit:

In questo caso, ci interessa AddressSanitizer, che può rilevare vari problemi relativi a puntatori 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.

Per 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

Molto meglio:

Screenshot di un messaggio che indica &quot;Perdita diretta di 12 byte&quot; proveniente da una funzione GenericBindingType RawImage ::toWireType

Alcune parti dello stack trace sembrano ancora oscure perché rimandano agli elementi interni di Emscripten, ma possiamo dire che la perdita proviene da una RawImage conversione in "wire type" (in un valore JavaScript) da parte di 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, la documentazione ufficiale suggerisce 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();

Infatti, quando lo facciamo in JavaScript per il nostro corso:

  // 

  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.

Scoperta di altri problemi con gli sterilizzatori

La creazione di altri codec Squoosh con gli strumenti di sanitizzazione rivela problemi simili e alcuni nuovi. Ad esempio, ho riscontrato 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 😱

Esaminando il codice di MozJPEG, abbiamo riscontrato che il problema è che jpeg_mem_dest, la funzione che utilizziamo per allocare una destinazione della memoria per JPEG, riutilizza i valori esistenti di outbuffer e outsize quando non sono pari a 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? 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. Infatti, ora riceviamo il seguente report:

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'uso improprio effettivo si verifica sul lato JavaScript, in cui lo strumento di convalida 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 altri codebase simili.

Innanzitutto, correggiamo il problema di uso dopo svuotamento all'inizio del post. Per farlo, dobbiamo clonare i dati dalla visualizzazione basata su WebAssembly prima di contrassegnarli come senza costi 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, riorganizziamo il wrapper C++ per assicurarci che ogni chiamata alla funzione gestisca i propri dati utilizzando 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 wrapper è diventato più pulito e più 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 ulteriori dettagli, puoi visualizzare la PR risultante qui: Correzioni della 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 supportate da 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. Ciò non ti salverà dai bug al confine tra JavaScript e WebAssembly, ma almeno ridurrà la superficie per i bug autocontenuti dal codice di lingua statico.
  • Indipendentemente dal linguaggio che utilizzi, esegui il codice con gli strumenti di sanitizzazione durante lo sviluppo. Questi strumenti possono aiutarti a rilevare non solo i problemi nel codice del linguaggio statico, ma anche alcuni problemi relativi al confine tra JavaScript e WebAssembly, ad esempio la mancata chiamata di .delete() o il passaggio di puntatori non validi dal lato JavaScript.
  • Se possibile, evita di esporre dati e 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.
  • Potrebbe sembrare ovvio, ma, come in qualsiasi altro codice di base, evita di memorizzare lo stato mutabile nelle variabili globali. Non vuoi dover risolvere i problemi relativi al riutilizzo in varie invocazioni o persino in vari thread, quindi è meglio mantenerlo il più autonomo possibile.