Speicherlecks in WebAssembly mit Emscripten beheben

Während es bei JavaScript recht verzeihend ist, selbst Daten zu bereinigen, sind statische Sprachen definitiv nicht...

Squoosh.app ist eine PWA, die zeigt, wie sehr verschiedene Bild-Codecs und -Einstellungen die Größe der Bilddatei verbessern können, ohne die Qualität erheblich zu beeinträchtigen. Es handelt sich jedoch auch um eine technische Demo, die zeigt, wie Sie in C++ oder Rust geschriebene Bibliotheken ins Web bringen können.

Die Möglichkeit, Code aus vorhandenen Umgebungen zu portieren, ist unglaublich wertvoll, aber es gibt einige wesentliche Unterschiede zwischen diesen statischen Sprachen und JavaScript. Dazu gehören die verschiedenen Ansätze der Gedächtnisverwaltung.

JavaScript ist zwar nach und nach recht vernachlässigbar, doch solche statischen Sprachen tun dies definitiv nicht. Sie müssen explizit nach einem neuen zugewiesenen Speicher fragen und darauf achten, dass Sie ihn anschließend zurückgeben und nie wieder verwenden. Geschieht das nicht, treten undichte Stellen auf und das passiert tatsächlich ziemlich regelmäßig. Sehen wir uns an, wie Sie diese Speicherlecks beheben und, noch besser, wie Sie Ihren Code so gestalten können, dass diese Fehler beim nächsten Mal vermieden werden.

Verdächtiges Muster

Als ich in letzter Zeit mit Squoosh gearbeitet habe, ist mir ein interessantes Muster in C++-Codec-Wrappern aufgefallen. Sehen wir uns als Beispiel einen ImageQuant-Wrapper an (verringert, um nur die Teile der Objekterstellung und der Objektfreigabe zu sehen):

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 (na ja, 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
  );
}

Erkennst du ein Problem? Hinweis: Es ist use-after-free, nur für JavaScript.

In Emscripten gibt typed_memory_view einen JavaScript-Uint8Array zurück, der vom WebAssembly-Arbeitsspeicherpuffer (Wasm) unterstützt wird, wobei byteOffset und byteLength auf den angegebenen Zeiger und die angegebene Länge festgelegt sind. Wichtig ist, dass es sich hierbei um eine TypedArray-Ansicht in einem WebAssembly-Speicherpuffer und nicht um eine JavaScript-eigene Kopie der Daten handelt.

Wenn free_result aus JavaScript aufgerufen wird, wird wiederum eine Standard-C-Funktion free aufgerufen, um diesen Speicher für zukünftige Zuweisungen als verfügbar zu markieren. Das bedeutet, dass die Daten, auf die Uint8Array verweist, bei jedem zukünftigen Aufruf von Wasm mit beliebigen Daten überschrieben werden können.

Oder eine Implementierung von free kann sogar beschließen, den freigegebenen Speicher sofort mit null zu füllen. Das von Emscripten verwendete free erfüllt dies nicht. Wir stützen uns hier jedoch auf ein Implementierungsdetail, das nicht garantiert werden kann.

Oder auch wenn der Arbeitsspeicher hinter dem Zeiger erhalten bleibt, muss der WebAssembly-Arbeitsspeicher durch eine neue Zuweisung möglicherweise erweitert werden. Wenn WebAssembly.Memory entweder über die JavaScript API oder die entsprechende memory.grow-Anweisung erweitert wird, werden die vorhandene ArrayBuffer und vorübergehend alle damit stützenden Aufrufe ungültig.

Als Beispiel werde ich die Entwicklertools- bzw. Node.js-Konsole verwenden, um dieses Verhalten zu demonstrieren:

> 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

Selbst wenn wir Wasm zwischen free_result und new Uint8ClampedArray nicht noch einmal explizit aufrufen, können wir unseren Codecs möglicherweise Multithreading hinzufügen. In diesem Fall könnte es ein ganz anderer Thread sein, der die Daten überschreibt, bevor wir sie klonen, sie zu klonen.

Nach Arbeitsspeicherfehlern suchen

Ich habe mich entschlossen, weiter zu gehen und zu prüfen, ob dieser Code in der Praxis Probleme aufweist. Das scheint eine perfekte Gelegenheit zu sein, die neue Unterstützung für Emscripten-Sanitizer auszuprobieren, die letztes Jahr hinzugefügt und in unserem WebAssembly-Vortrag auf dem Chrome Dev Summit vorgestellt wurde:

In diesem Fall interessiert uns der AddressSanitizer, der verschiedene Zeiger- und Speicherprobleme erkennen kann. Um ihn zu verwenden, müssen wir unseren Codec mit -fsanitize=address neu kompilieren:

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

Dies aktiviert automatisch Zeiger-Sicherheitschecks, aber wir möchten auch potenzielle Speicherlecks finden. Da wir ImageQuant als Bibliothek und nicht als Programm verwenden, gibt es keinen „Ausstiegspunkt“, an dem Emscripten automatisch validieren könnte, ob der gesamte Speicher freigegeben wurde.

In solchen Fällen bietet der LeakSanitizer (im AddressSanitizer enthalten) stattdessen die Funktionen __lsan_do_leak_check und __lsan_do_recoverable_leak_check, die immer dann manuell aufgerufen werden können, wenn davon auszugehen ist, dass der gesamte Arbeitsspeicher freigegeben wird und diese Annahme überprüft werden soll. __lsan_do_leak_check wird am Ende einer laufenden Anwendung verwendet, wenn Sie den Prozess abbrechen möchten, wenn Speicherlecks festgestellt werden. __lsan_do_recoverable_leak_check ist dagegen besser für Bibliotheksanwendungsfälle wie wir geeignet, wenn Sie Datenlecks in der Konsole ausgeben, die Anwendung aber trotzdem weiter ausführen möchten.

Wir stellen dieses zweite Hilfsprogramm über Embind bereit, damit wir es jederzeit aus JavaScript aufrufen können:

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

Rufen Sie ihn von der JavaScript-Seite aus auf, sobald wir mit dem Bild fertig sind. Wenn Sie diesen Vorgang auf der JavaScript-Seite statt in C++ ausführen, können Sie dafür sorgen, dass alle Bereiche geschlossen und alle temporären C++-Objekte freigegeben wurden, wenn wir diese Prüfungen ausführen:

  // …

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

Daraufhin wird in der Konsole ein Bericht wie der folgende angezeigt:

Screenshot einer Nachricht

Es gibt einige kleine Speicherlecks, aber der Stacktrace ist nicht sehr hilfreich, da alle Funktionsnamen falsch sind. Wir kompilieren eine Neukompilierung mit grundlegenden Debugging-Informationen, um sie beizubehalten:

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

Das sieht jetzt viel besser aus:

Screenshot der Nachricht „Direct leak of 12 bytes“ von einer GenericBindingType RawImage ::toWireType -Funktion

Einige Teile des Stacktrace sehen noch unklar aus, da sie auf Emscripten-Interna verweisen, aber wir können erkennen, dass das Leck von einer RawImage-Konvertierung in einen Drahttyp (zu einem JavaScript-Wert) von Embind stammt. Wenn wir uns den Code ansehen, sehen wir, dass RawImage C++-Instanzen an JavaScript zurückgegeben werden, die aber nie auf beiden Seiten freigegeben werden.

Zur Erinnerung: Derzeit gibt es keine automatische Speicherbereinigung zwischen JavaScript und WebAssembly, obwohl in der Entwicklungsphase ist. Stattdessen müssen Sie manuell Arbeitsspeicher freigeben und Destruktoren von der JavaScript-Seite aus aufrufen, sobald Sie mit dem Objekt fertig sind. Insbesondere für Embind wird in den offiziellen Dokumenten empfohlen, eine .delete()-Methode für preisgegebene C++-Klassen aufzurufen:

Der JavaScript-Code muss alle empfangenen C++-Objekt-Handles explizit löschen. Andernfalls wächst der Emscripten-Heap auf unbestimmte Zeit.

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

Wenn wir das in JavaScript für unsere Klasse tun, tun wir das tatsächlich:

  // …

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

Das Leck verschwindet wie erwartet.

Weitere Probleme mit Desinfektionsmitteln entdecken

Das Erstellen weiterer Squoosh-Codecs mit Sanitizern zeigt sowohl ähnliche als auch einige neue Probleme. Angenommen, ich habe diesen Fehler in MozJPEG-Bindungen:

Screenshot einer Nachricht

Hier ist es kein Leck, aber wir schreiben in eine Erinnerung außerhalb der zugewiesenen Grenzen ☀

Wenn wir uns den MozJPEG-Code genauer ansehen, stellen wir fest, dass das Problem hier ist, dass jpeg_mem_dest, die Funktion, mit der wir ein Speicherziel für JPEG zuweisen, vorhandene Werte von outbuffer und outsize wiederverwendet, wenn sie ungleich null sind:

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

Wir rufen sie jedoch auf, ohne eine dieser Variablen zu initialisieren. MozJPEG schreibt das Ergebnis also in eine potenziell zufällige Speicheradresse, die zum Zeitpunkt des Aufrufs in diesen Variablen gespeichert war.

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

Das Problem wurde dadurch behoben, dass beide Variablen vor dem Aufruf nicht initialisiert werden. Jetzt erreicht der Code stattdessen eine Speicherleckprüfung. Glücklicherweise besteht die Prüfung erfolgreich, was darauf hinweist, dass in diesem Codec keine Speicherlecks vorhanden sind.

Probleme mit dem gemeinsamen Status

...oder wir?

Wir wissen, dass unsere Codec-Bindungen einen Teil des Zustands und Ergebnisse in globalen statischen Variablen speichern. MozJPEG hat besonders komplizierte Strukturen.

uint8_t* last_result;
struct jpeg_compress_struct cinfo;

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

Was passiert, wenn einige davon bei der ersten Ausführung verzögert initialisiert und bei zukünftigen Ausführungen falsch wiederverwendet werden? Dann würde ein einziger Anruf mit einem Desinfektionsmittel sie nicht als problematisch melden.

Versuchen wir, das Bild ein paar Mal zu verarbeiten. Klicken Sie dazu in der Benutzeroberfläche willkürlich auf verschiedene Qualitätsstufen. Jetzt erhalten wir den folgenden Bericht:

Screenshot einer Nachricht

262.144 Byte – das gesamte Beispielbild wurde von jpeg_finish_compress geleakt.

Nachdem Sie sich die Dokumente und die offiziellen Beispiele angesehen haben, stellen Sie fest, dass jpeg_finish_compress nicht den von unserem früheren jpeg_mem_dest-Aufruf zugewiesenen Arbeitsspeicher freigibt. Es gibt nur die Komprimierungsstruktur kostenlos, obwohl diese Komprimierungsstruktur das Speicherziel bereits kennt... Seufz.

Wir können dieses Problem beheben, indem wir die Daten manuell in der Funktion free_result freigeben:

void free_result() {
  /* This is an important step since it will release a good deal of memory. */
  free(last_result);
  jpeg_destroy_compress(&cinfo);
}

Ich könnte weiter nach diesen Arbeitsspeicherfehlern suchen, aber inzwischen ist mir klar, dass der aktuelle Ansatz für die Speicherverwaltung zu einigen heiklen systematischen Problemen führt.

Einige davon können sofort vom Desinfektionsmittel abgefangen werden. Andere können mit aufwendigen Tricks erwischt werden. Außerdem gibt es Probleme wie am Anfang des Beitrags, die, wie wir in den Protokollen sehen können, überhaupt nicht vom Desinfektionsmittel abgefangen werden. Der Grund dafür ist, dass der tatsächliche Missbrauch auf der JavaScript-Seite erfolgt, sodass das Desinfektionsmittel nicht sichtbar ist. Diese Probleme treten erst in der Produktionsumgebung oder später nach scheinbar irrelevanten Änderungen am Code auf.

Sicheren Wrapper erstellen

Gehen wir ein paar Schritte zurück und beheben stattdessen alle diese Probleme, indem wir den Code auf sicherere Weise umstrukturieren. Als Beispiel verwende ich wieder den ImageQuant-Wrapper, aber ähnliche Refaktorierungsregeln gelten für alle Codecs sowie ähnliche Codebasen.

Zunächst sollten wir das Problem „Nach Nutzung ohne Verwendung“ zu Beginn des Beitrags beheben. Dazu müssen wir die Daten aus der WebAssembly-gestützten Ansicht klonen, bevor wir sie auf der JavaScript-Seite als kostenlos kennzeichnen:

  // …

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

Stellen wir nun sicher, dass kein Status in globalen Variablen zwischen Aufrufen geteilt wird. Dadurch werden einige der bereits bekannten Probleme behoben und die zukünftige Verwendung unserer Codecs in einer Multithread-Umgebung wird vereinfacht.

Dazu refaktorieren wir den C++-Wrapper, damit jeder Aufruf der Funktion seine eigenen Daten mithilfe lokaler Variablen verwaltet. Anschließend können wir die Signatur unserer free_result-Funktion so ändern, dass der Verweis wieder akzeptiert wird:

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

Da wir jedoch bereits Embind in Emscripten für die Interaktion mit JavaScript verwenden, können wir die API noch sicherer machen, indem wir alle Details zur C++-Speicherverwaltung ausblenden.

Dazu verschieben wir den new Uint8ClampedArray(…)-Teil von JavaScript auf die C++-Seite mit Embind. Anschließend können wir damit die Daten in den JavaScript-Speicher klonen, bevor sie von der Funktion zurückkehren:

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

Beachten Sie, dass wir mit einer einzigen Änderung dafür sorgen, dass das resultierende Bytearray zu JavaScript gehört und nicht vom WebAssembly-Arbeitsspeicher unterstützt wird und auch den zuvor gehackten RawImage-Wrapper beseitigt.

Jetzt muss JavaScript keine Daten mehr freigeben und kann das Ergebnis wie jedes andere Objekt für die automatische Speicherbereinigung verwenden:

  // …

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

Das bedeutet auch, dass wir keine benutzerdefinierte free_result-Bindung auf der C++-Seite mehr benötigen:

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

Insgesamt ist unser Wrapper-Code nun sowohl übersichtlicher als auch sicherer geworden.

Danach habe ich weitere kleinere Verbesserungen am Code des ImageQuant-Wrappers vorgenommen und ähnliche Fehlerkorrekturen für die Arbeitsspeicherverwaltung für andere Codecs repliziert. Weitere Informationen zur resultierenden PR findest du hier: Speicherkorrekturen für C++-Codecs.

Takeaways

Welche Erkenntnisse können wir aus dieser Refaktorierung gewinnen, die wir auf andere Codebasen anwenden könnten?

  • Verwenden Sie von WebAssembly unterstützte Speicheransichten nicht über einen einzelnen Aufruf hinaus, unabhängig davon, aus welcher Sprache sie erstellt wurde. Sie können sich nicht darauf verlassen, dass sie länger bestehen, und Sie können diese Fehler nicht auf konventionelle Weise erkennen. Wenn Sie die Daten für später speichern müssen, kopieren Sie sie auf die JavaScript-Seite und speichern Sie sie dort.
  • Verwenden Sie nach Möglichkeit eine sichere Speicherverwaltungssprache oder zumindest sichere Wrapper, anstatt direkt mit Rohzeigern zu arbeiten. Dadurch werden Sie zwar nicht vor Fehlern an der JavaScript-/WebAssembly-Grenze bewahrt, aber zumindest wird die Oberfläche für Fehler reduziert, die vom statischen Sprachcode unabhängig sind.
  • Unabhängig von der verwendeten Sprache sollten Sie während der Entwicklung Code mit Sanitizern ausführen. Diese können dabei helfen, nicht nur Probleme im statischen Sprachcode, sondern auch Probleme innerhalb der JavaScript-WebAssembly-Grenze zu erkennen, z. B. wenn Sie vergessen, .delete() aufzurufen oder ungültige Zeiger von der JavaScript-Seite aus übergeben zu müssen.
  • Vermeiden Sie es nach Möglichkeit, nicht verwaltete Daten und Objekte aus WebAssembly für JavaScript freizugeben. JavaScript ist eine Sprache für die automatische Speicherbereinigung und eine manuelle Speicherverwaltung ist darin nicht üblich. Dies kann als Abstraktions-Leak im Speichermodell der Sprache betrachtet werden, aus der Ihre WebAssembly erstellt wurde. Eine falsche Verwaltung kann in einer JavaScript-Codebasis leicht übersehen werden.
  • Dies mag offensichtlich sein, aber wie bei jeder anderen Codebasis sollten Sie keinen änderbaren Status in globalen Variablen speichern. Sie möchten keine Probleme beheben, die bei der Wiederverwendung für verschiedene Aufrufe oder sogar für Threads auftreten. Daher sollten Sie es so eigenständig wie möglich halten.