Debug delle perdite di memoria in WebAssembly utilizzando Emscripten

Sebbene JavaScript sia abbastanza tollerante nel ripulire da se stesso, i linguaggi statici non sono assolutamente...

Ingvar Stepanyan
Ingvar Stepanyan

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

La capacità di trasferire il codice da ecosistemi esistenti è incredibilmente importante, ma ci sono alcune le differenze tra questi linguaggi statici e JavaScript. Uno di questi è il loro diverso di gestione della memoria.

Sebbene JavaScript permetta di ripulire dopo se stesso, i linguaggi statici sono assolutamente no. Occorre chiedere esplicitamente una nuova memoria allocata e assicurati di restituirlo in seguito e di non utilizzarlo più. Se non accade, si verificano delle perdite... e in realtà accade abbastanza regolarmente. Vediamo come eseguire il debug di queste perdite di memoria è ancora meglio, come puoi progettare il tuo codice per evitarli la prossima volta.

Pattern sospetto

Recentemente, mentre iniziavo a lavorare su Squoosh, non ho potuto non notare un modello interessante Wrapper codec C++. Diamo un'occhiata a un wrapper ImageQuant esempio (ridotto per mostrare solo le parti relative alla creazione di oggetti e alla 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 (Bene, 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
  );
}

Riesci a individuare un problema? Suggerimento: use-after-free, ma in JavaScript!

In Emscripten, typed_memory_view restituisce un Uint8Array JavaScript supportato da WebAssembly (Wasm) buffer di memoria, con byteOffset e byteLength impostati sul puntatore e sulla lunghezza specificati. Il principale è che questa è una vista TypedArray in un buffer di memoria WebAssembly, anziché 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 che il nostro Uint8Array visualizza punta a, può essere sovrascritta con dati arbitrari da qualsiasi chiamata futura in Wasm.

In alternativa, alcune implementazioni di free potrebbero anche decidere di azzerare immediatamente la memoria liberata. La free utilizzato da Emscripten non lo fa, ma ci affidiamo a un dettaglio di implementazione qui che non possono essere garantiti.

Oppure, anche se la memoria dietro il puntatore viene conservata, la nuova allocazione potrebbe dover aumentare Memoria WebAssembly. Quando WebAssembly.Memory viene cresciuto tramite l'API JavaScript o corrispondente memory.grow, invalida ArrayBuffer e, transitivamente, qualsiasi vista supportata.

Consentimi di utilizzare 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 richiamiamo esplicitamente Wasm tra le ore free_result e le ore new Uint8ClampedArray, a un certo punto potremmo aggiungere il supporto del multithreading ai nostri codec. In questo caso potrebbe essere 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 ulteriore passo e verificare se il codice presenta problemi. Mi sembra l'occasione perfetta per provare i nuovi disinfettanti Emscripten assistenza aggiunta l'anno scorso e presentato nel nostro discorso WebAssembly al Chrome Dev Summit:

In questo caso, ci interessa il AddressSanitizer, è in grado di rilevare vari problemi relativi a puntatore e memoria. Per utilizzarlo, dobbiamo ricompilare il nostro 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

In questo modo verranno attivati automaticamente i controlli di sicurezza del puntatore, ma vogliamo anche trovare potenziale memoria di fughe di notizie. Poiché utilizziamo ImageQuant come libreria anziché come programma, non esiste un "punto di uscita" alle che Emscripten può confermare automaticamente che tutta la memoria è stata liberata.

In questi casi, LeakSanitizer (incluso in AddressSanitizer) fornisce le funzioni __lsan_do_leak_check e __lsan_do_recoverable_leak_check, che può essere richiamato manualmente ogni volta che ci aspettiamo che tutta la memoria venga liberata e vogliamo convalidarla presupposto. __lsan_do_leak_check è pensato per essere utilizzato alla fine di un'applicazione in esecuzione, quando interrompere la procedura nel caso in cui venissero rilevate eventuali perdite, mentre __lsan_do_recoverable_leak_check è più adatta per casi d'uso da biblioteca come il nostro, quando si desidera stampare fughe di notizie sulla console, ma e mantenere l'applicazione in esecuzione.

Esponiamo quel secondo aiutante tramite Embind in modo da poterlo chiamare da JavaScript in qualsiasi momento:

#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 richiamalo dal lato JavaScript una volta che abbiamo finito di creare l'immagine. Questa operazione lato JavaScript, anziché C++, aiuta a garantire che tutti gli ambiti siano stati e tutti gli oggetti C++ temporanei sono stati liberati prima dell'esecuzione dei 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
  );
}

In questo modo, nella console viene visualizzato un report simile al seguente:

Screenshot di un messaggio

Ci sono alcune piccole infiltrazioni, ma l'analisi dello stack non è molto utile perché tutti i nomi delle funzioni vengono storditi. Ricompiliamoli con alcune 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 del messaggio &quot;fuga diretta di 12 byte&quot; proveniente da una funzione GenericBindingType RawImage ::toWireType

Alcune parti dell'analisi dello stack sembrano ancora oscure in quanto puntano a componenti interni di Emscripten, ma possiamo Indica che la perdita proviene da una conversione di RawImage a "tipo di cavo" (a un valore JavaScript) per Embind. In effetti, quando osserviamo il codice, possiamo vedere che restituiamo RawImage istanze C++ in JavaScript, ma non li liberiamo mai da nessuna parte e dall'altra.

Ti ricordiamo che al momento non esiste un'integrazione per la garbage collection tra JavaScript e WebAssembly, anche se ne è in fase di sviluppo. Hai invece per liberare manualmente qualsiasi memoria e chiamare i distruttori dal lato JavaScript una volta che hai finito con la . Per Embind in particolare, il di assistenza suggerisci di chiamare un metodo .delete() su classi C++ esposte:

Il codice JavaScript deve eliminare esplicitamente tutti gli handle di oggetti C++ ricevuti o l'emscripten heap è infinita.

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 fuga di dati scompare come previsto.

Scoprire altri problemi relativi ai disinfettanti

La creazione di altri codec Squoosh con igienizzanti rivela sia problemi simili che alcuni nuovi. Per esempio, ho questo errore nelle associazioni MozJPEG:

Screenshot di un messaggio

Qui non è una fuga di notizie, ma noi scriviamo a un ricordo al di fuori dei confini allocati 🔍

Analizzando il codice di MozJPEG, scopriamo che il problema è che jpeg_mem_dest, funzione che usiamo per allocare una destinazione di memoria per JPEG: riutilizza i valori esistenti di outbuffer e outsize quando sono diverso 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 richiamiamo senza inizializzare nessuna di queste variabili, il che significa che MozJPEG scrive il parametro in un indirizzo di memoria potenzialmente casuale che era archiviato in quelle variabili l'ora della chiamata.

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

L'inizializzazione zero di entrambe le variabili prima che la chiamata risolva il problema e ora il codice raggiunge una controllo di fughe di memoria. Fortunatamente, il controllo ha esito positivo e indica che non sono presenti perdite di questo codec.

Problemi con lo stato condiviso

O no?

Sappiamo che le nostre associazioni di codec memorizzano parte dello stato e producono e MozJPEG presenta 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 di questi vengono inizializzati in modo lento alla prima esecuzione e poi riutilizzati impropriamente in futuro viene eseguito? 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 casualmente con diversi livelli qualitativi nell'interfaccia utente. Ora invece riceviamo il seguente rapporto:

Screenshot di un messaggio

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

Dopo aver consultato la documentazione e gli esempi ufficiali, risulta che jpeg_finish_compress libera la memoria allocata dalla nostra precedente chiamata jpeg_mem_dest, libera solo struttura di compressione, anche se conosce già la nostra memoria destinazione... Peccato.

Possiamo risolvere il problema liberando manualmente i dati 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 cacciare gli insetti di memoria uno alla volta, ma penso che ormai sia chiaro che l'attuale approccio alla gestione della memoria porta ad alcuni brutti problemi sistematici.

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

Creazione di un wrapper sicuro

Facciamo un paio di passaggi indietro e risolviamo tutti questi problemi riorganizzando il codice in modo più sicuro. Userò di nuovo il wrapper ImageQuant come esempio, ma si applicano regole di refactoring simili a tutti i codec e ad altri codebase simili.

Prima di tutto, risolviamo il problema dell'utilizzo senza sosta dall'inizio del post. A questo scopo, abbiamo bisogno per clonare i dati dalla vista supportata da WebAssembly prima di contrassegnarli come senza costi sul 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 nessuno stato nelle variabili globali tra le chiamate. Questo risolveranno alcuni dei problemi riscontrati in precedenza e faciliteranno l'utilizzo della nostra codec in un ambiente multithread in futuro.

Per farlo, occorre refactoring del wrapper C++ per assicurarci che ogni chiamata alla funzione gestisca il proprio utilizzando variabili locali. Poi, possiamo cambiare la firma della funzione free_result in accetta 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);
}

Ma, 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++.

Per questo, spostiamo la parte new Uint8ClampedArray(…) da JavaScript al lato C++ con Embind. Quindi possiamo utilizzarlo per clonare i dati nella memoria JavaScript anche prima di restituirlo 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;
}

Nota come, con una singola modifica, entrambi ci assicuriamo che l'array di byte risultante sia di proprietà di JavaScript e non supportato dalla memoria di WebAssembly, ed elimina il wrapper RawImage divulgato in precedenza .

Ora JavaScript non deve più preoccuparsi di liberare i dati e può utilizzare il risultato Qualsiasi altro oggetto garbage-collect:

  // 

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

Inoltre, questo significa che non abbiamo più bisogno di un'associazione free_result personalizzata sul 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.

In seguito, ho apportato ulteriori piccoli miglioramenti al codice del wrapper ImageQuant e correzioni di replica di gestione della memoria simili per altri codec. Per maggiori dettagli, puoi vedere il PR risultante qui: Correzioni di memoria per C++ codec.

Concetti principali

Quali lezioni possiamo imparare e condividere da questo refactoring che potrebbe essere applicato ad altri codebase?

  • Non utilizzare le visualizzazioni della memoria supportate da WebAssembly, indipendentemente dal linguaggio da cui è stata creata, al di là di un una singola chiamata. Non puoi fare affidamento sul fatto che sopravvivano più a lungo di così, e non potrai per individuare questi bug con mezzi convenzionali. Se devi archiviare i dati per un secondo momento, copiali il lato JavaScript e memorizzarlo lì.
  • Se possibile, usa un linguaggio sicuro per la gestione della memoria o, almeno, wrapper di tipo sicuro, anziché che operano direttamente sui cursori non elaborati. Non ti eviterai dai bug in JavaScript possibilità di WebAssembly ma per lo meno ridurrà la superficie di eventuali bug indipendenti dal codice in linguaggio statico.
  • Indipendentemente dal linguaggio che utilizzi, esegui il codice con i sanitizer durante lo sviluppo: questi strumenti possono aiutarti a rilevare non solo problemi con il codice del linguaggio statico, ma anche alcuni problemi nel codice JavaScript usi Confine WebAssembly, ad esempio la mancata chiamata a .delete() o l'inserimento di puntatori non validi da dal lato JavaScript.
  • Se possibile, evita di esporre del tutto i dati e gli oggetti non gestiti da WebAssembly a JavaScript. JavaScript è un linguaggio di garbage-collecting e la gestione manuale della memoria non è comune. Questa può essere considerata una perdita di astrazione del modello di memoria del linguaggio del tuo WebAssembly è stato creato ed è facile trascurare la gestione errata in un codebase JavaScript.
  • Questo può essere ovvio ma, come in qualsiasi altro codebase, evita di archiviare lo stato modificabile nelle come la codifica one-hot delle variabili categoriche. Non vuoi eseguire il debug dei problemi relativi al suo riutilizzo per varie chiamate o persino quindi è meglio mantenerlo il più indipendente possibile.