Speicherlecks in WebAssembly mit Emscripten beheben

JavaScript ist in dieser Hinsicht recht nachsichtig, statische Sprachen hingegen 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 wesentlich zu beeinträchtigen. Es ist aber auch eine technische Demo, die zeigt, wie Sie in C++ oder Rust geschriebene Bibliotheken im Web verwenden können.

Es ist sehr wertvoll, Code aus bestehenden Systemen portieren zu können. Es gibt jedoch einige wichtige Unterschiede zwischen diesen statischen Sprachen und JavaScript. Eine davon sind die unterschiedlichen Herangehensweisen an das Gedächtnismanagement.

Während JavaScript beim Bereinigen nach sich selbst ziemlich nachsichtig ist, ist das bei solchen statischen Sprachen definitiv nicht der Fall. Sie müssen explizit um einen neuen zugewiesenen Arbeitsspeicher bitten und ihn danach unbedingt zurückgeben und nie wieder verwenden. Wenn das nicht der Fall ist, kommt es zu Lecks. 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 sie das nächste Mal nicht auftreten.

Verdächtiges Muster

Als ich vor Kurzem mit der Arbeit an Squoosh begann, fiel mir ein interessantes Muster in C++-Codec-Wrappern auf. Sehen wir uns als Beispiel einen ImageQuant-Wrapper an (nur die Teile zum Erstellen und Freigeben von Objekten werden angezeigt):

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

Haben Sie ein Problem festgestellt? Tipp: Es ist Use-after-free, nur in JavaScript!

In Emscripten gibt typed_memory_view ein JavaScript-Uint8Array zurück, das vom WebAssembly-Speicherbuffer (Wasm) unterstützt wird. byteOffset und byteLength sind auf den angegebenen Zeiger und die angegebene Länge gesetzt. Der Hauptpunkt ist, dass dies eine TypedArray-Ansicht in einem WebAssembly-Speicherbuffer ist und keine JavaScript-Kopie der Daten.

Wenn wir free_result aus JavaScript aufrufen, wird wiederum eine Standard-C-Funktion free aufgerufen, um diesen Arbeitsspeicher für zukünftige Zuweisungen verfügbar zu machen. Das bedeutet, dass die Daten, auf die unsere Uint8Array-Ansicht verweist, bei einem zukünftigen Aufruf in Wasm mit beliebigen Daten überschrieben werden können.

Oder einige Implementierungen von free entscheiden sogar, den freigegebenen Speicher sofort mit Nullen zu füllen. Die von Emscripten verwendete free tut das nicht, aber wir verlassen uns hier auf ein Implementierungsdetail, das nicht garantiert werden kann.

Oder selbst wenn der Arbeitsspeicher hinter dem Zeiger erhalten bleibt, muss möglicherweise der WebAssembly-Arbeitsspeicher durch neue Zuweisung vergrößert werden. Wenn WebAssembly.Memory entweder über die JavaScript API oder die entsprechende memory.grow-Anweisung erweitert wird, wird die vorhandene ArrayBuffer und damit auch alle von ihr unterstützten Ansichten ungültig.

Ich verwende die DevTools- oder Node.js-Konsole, um dieses Verhalten zu veranschaulichen:

> 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

Auch wenn wir zwischen free_result und new Uint8ClampedArray Wasm nicht mehr explizit aufrufen, werden wir unseren Codecs möglicherweise irgendwann Multithreading-Unterstützung hinzufügen. In diesem Fall könnte es ein völlig anderer Thread sein, der die Daten unmittelbar vor dem Klonen überschreibt.

Suche nach Gedächtnis-Programmfehlern

Ich habe mir den Code genauer angesehen, um zu prüfen, ob er in der Praxis Probleme verursacht. Das ist eine gute Gelegenheit, den neuen Emscripten-Sanitizer-Support auszuprobieren, der letztes Jahr hinzugefügt wurde und in unserem WebAssembly-Vortrag auf dem Chrome Dev Summit vorgestellt wurde:

In diesem Fall interessieren wir uns für den AddressSanitizer, der verschiedene ‑ und ‑bezogene Probleme 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

Dadurch werden automatisch Pointer-Sicherheitschecks aktiviert. Wir möchten aber auch potenzielle Speicherlecks finden. Da wir ImageQuant als Bibliothek und nicht als Programm verwenden, gibt es keinen „Endpunkt“, an dem Emscripten automatisch prüfen könnte, ob der gesamte Arbeitsspeicher freigegeben wurde.

Stattdessen bietet der LeakSanitizer (im AddressSanitizer enthalten) die Funktionen __lsan_do_leak_check und __lsan_do_recoverable_leak_check, die manuell aufgerufen werden können, wenn wir davon ausgehen, dass der gesamte Arbeitsspeicher freigegeben wurde, und diese Annahme überprüfen möchten. __lsan_do_leak_check ist für den Einsatz am Ende einer laufenden Anwendung gedacht, wenn Sie den Prozess abbrechen möchten, falls Lecks erkannt werden. __lsan_do_recoverable_leak_check eignet sich eher für Bibliotheks-Anwendungsfälle wie unseren, wenn Sie Lecks in die Konsole ausgeben, die Anwendung aber trotzdem weiter ausführen möchten.

Stellen wir diesen zweiten Helfer über Embind bereit, damit wir ihn jederzeit über 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);
}

Und wir rufen es auf der JavaScript-Seite auf, sobald wir mit dem Bild fertig sind. Wenn Sie dies auf der JavaScript-Seite statt auf der C++-Seite tun, können Sie sicher sein, dass alle Bereiche beendet und alle temporären C++-Objekte freigegeben wurden, bevor Sie 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
  );
}

In der Konsole wird dann ein Bericht wie der folgende angezeigt:

Screenshot einer Nachricht

Oh, es gibt einige kleine Lecks, aber der Stacktrace ist nicht sehr hilfreich, da alle Funktionsnamen unkenntlich gemacht wurden. Kompilieren wir das Programm noch einmal mit grundlegenden Informationen zur Fehlerbehebung, damit sie erhalten bleiben:

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 viel besser aus:

Screenshot einer Meldung mit dem Text „Direkter Leak von 12 Byte“ von einer GenericBindingType RawImage ::toWireType-Funktion

Einige Teile des Stack-Traces sind noch unklar, da sie auf interne Emscripten-Funktionen verweisen. Wir können jedoch feststellen, dass das Leck durch eine RawImage-Konvertierung in den „Wire-Typ“ (in einen JavaScript-Wert) durch Embind verursacht wird. Wenn wir uns den Code ansehen, sehen wir, dass wir RawImage C++-Instanzen an JavaScript zurückgeben, sie aber auf keiner Seite freigeben.

Zur Erinnerung: Derzeit gibt es keine Garbage-Collection-Integration zwischen JavaScript und WebAssembly. Eine solche wird jedoch entwickelt. Stattdessen müssen Sie den Arbeitsspeicher manuell freigeben und Destruktoren von der JavaScript-Seite aus aufrufen, sobald Sie mit dem Objekt fertig sind. Speziell für Embind wird in der offiziellen Dokumentation empfohlen, eine .delete()-Methode für bereitgestellte 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:

  // 

  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

Beim Erstellen anderer Squoosh-Codecs mit Sanitizern treten sowohl ähnliche als auch einige neue Probleme auf. Beispiel: Ich habe diesen Fehler in MozJPEG-Bindungen:

Screenshot einer Nachricht

Hier handelt es sich nicht um ein Leck, sondern um das Schreiben in einen Speicher außerhalb der zugewiesenen Grenzen. 😱

Wenn wir uns den Code von MozJPEG genauer ansehen, stellen wir fest, dass das Problem darin besteht, 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 0 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. Das bedeutet, dass MozJPEG das Ergebnis in eine potenziell zufällige Speicheradresse schreibt, die zufällig zum Zeitpunkt des Aufrufs in diesen Variablen gespeichert war.

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

Das Problem wird durch das Initialisieren beider Variablen vor dem Aufruf gelöst. Jetzt führt der Code nun eine Speicherleckprüfung durch. Glücklicherweise ist die Prüfung erfolgreich, was darauf hinweist, dass in diesem Codec keine Datenlecks vorliegen.

Probleme mit dem gemeinsamen Status

…Oder doch?

Uns ist bewusst, dass unsere Codec-Bindungen einen Teil des Zustands sowie Ergebnisse in globalen statischen Variablen speichern. MozJPEG hat einige 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 ist, wenn einige davon beim ersten Durchlauf träge initialisiert und dann bei zukünftigen Durchläufen falsch wiederverwendet werden? Dann werden sie nicht als problematisch gemeldet, wenn nur ein Aufruf mit einem Sanitizer erfolgt.

Versuchen wir, das Bild mehrmals zu verarbeiten, indem wir auf der Benutzeroberfläche zufällig auf verschiedene Qualitätsstufen klicken. Tatsächlich erhalten wir jetzt den folgenden Bericht:

Screenshot einer Nachricht

262.144 Byte – anscheinend wurde das gesamte Beispielbild von jpeg_finish_compress gehackt.

Nach der Lektüre der Dokumentation und der offiziellen Beispiele stellt sich heraus, dass jpeg_finish_compress den durch den vorherigen jpeg_mem_dest-Aufruf zugewiesenen Speicher nicht freigibt. Es wird nur das Komprimierungsstruktur freigegeben, obwohl diese Komprimierungsstruktur bereits über unser Speicherziel Bescheid weiß. Seufz.

Wir können das 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 diese Speicherfehler einzeln suchen, aber ich denke, es ist inzwischen klar, dass der aktuelle Ansatz zur Speicherverwaltung zu einigen üblen systematischen Problemen führt.

Einige davon können sofort vom Desinfektionsmittel erfasst werden. Andere erfordern knifflige Tricks, um gefangen zu werden. Schließlich gibt es noch Probleme wie am Anfang des Beitrags, die, wie wir aus den Logs sehen können, vom Sanitizer überhaupt nicht erkannt werden. Der Grund dafür ist, dass der Missbrauch auf JavaScript-Seite erfolgt, auf die der Sanitizer keinen Zugriff hat. Diese Probleme treten erst in der Produktion oder nach scheinbar nicht zusammenhängenden Änderungen am Code auf.

Sicheren Wrapper erstellen

Gehen wir ein paar Schritte zurück und beheben stattdessen alle diese Probleme, indem wir den Code sicherer umstrukturieren. Ich verwende wieder den ImageQuant-Wrapper als Beispiel, aber ähnliche Refactoring-Regeln gelten für alle Codecs sowie für andere ähnliche Codebases.

Beheben wir zuerst das Problem mit dem „Use-After-Free“-Fehler am Anfang des Beitrags. Dazu müssen wir die Daten aus der durch WebAssembly gesicherten 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;
}

Achten wir jetzt darauf, dass wir zwischen den Aufrufen keinen Status in globalen Variablen teilen. Dadurch werden einige der bereits bekannten Probleme behoben und die Verwendung unserer Codecs in einer mehrstufigen Umgebung wird in Zukunft einfacher.

Dazu refaktorieren wir den C++-Wrapper, um sicherzustellen, dass bei jedem Aufruf der Funktion mithilfe lokaler Variablen ihre eigenen Daten verwaltet werden. Anschließend können wir die Signatur der free_result-Funktion so ändern, dass der Zeiger zurück 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 aber bereits Embind in Emscripten verwenden, um mit JavaScript zu interagieren, können wir die API noch sicherer machen, indem wir die Details der C++-Speicherverwaltung vollständig ausblenden.

Verschieben wir dazu den new Uint8ClampedArray(…)-Teil mit Embind von JavaScript auf die C++-Seite. Anschließend können wir die Daten damit vor dem Zurückgeben aus der Funktion in den JavaScript-Speicher klonen:

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 Byte-Array zu JavaScript gehört und nicht vom WebAssembly-Speicher unterstützt wird, und auch den zuvor gehackten RawImage-Wrapper entfernen.

Jetzt muss JavaScript keine Daten mehr freigeben und kann das Ergebnis wie jedes andere Objekt mit Garbage Collection 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 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 wurde unser Wrapper-Code dadurch sowohl übersichtlicher als auch sicherer.

Danach habe ich einige weitere kleinere Verbesserungen am Code des ImageQuant-Wrappers vorgenommen und ähnliche Fehlerkorrekturen für die Speicherverwaltung für andere Codecs repliziert. Weitere Informationen findest du im zugehörigen PR: Speicherkorrekturen für C++-Codecs.

Fazit

Welche Erkenntnisse können wir aus diesem Refactoring gewinnen und teilen, die auf andere Codebases angewendet werden können?

  • Verwenden Sie Speicheransichten, die von WebAssembly unterstützt werden – unabhängig davon, in welcher Sprache sie erstellt wurden – nicht über eine einzelne Aufrufung hinaus. Sie können nicht davon ausgehen, dass sie länger als das überleben, und Sie können diese Fehler nicht mit herkömmlichen Mitteln erkennen. Wenn Sie die Daten also für später speichern möchten, kopieren Sie sie auf die JavaScript-Seite und speichern Sie sie dort.
  • Verwenden Sie nach Möglichkeit eine sichere Speicherverwaltungssprache oder zumindest Wrapper des sicheren Typs, anstatt direkt mit Rohzeigern zu arbeiten. Das schützt Sie zwar nicht vor Fehlern an der Grenze zwischen JavaScript und WebAssembly, reduziert aber zumindest die Wahrscheinlichkeit für Fehler, die sich durch den statischen Sprachcode selbst ergeben.
  • Unabhängig von der verwendeten Sprache sollten Sie Code mit Sanitizern während der Entwicklung ausführen. So können nicht nur Probleme im statischen Sprachcode erkannt werden, sondern auch Probleme über die JavaScript → WebAssembly-Grenze hinweg erkannt werden, z. B. das Vergessen des Aufrufs von .delete() oder das Übergeben ungültiger Zeiger von der JavaScript-Seite.
  • Setzen Sie nicht verwaltete Daten und Objekte aus WebAssembly möglichst nicht für JavaScript frei. JavaScript ist eine Sprache mit Garbage Collection und die manuelle Speicherverwaltung ist darin nicht üblich. Dies kann als Abstraktionsleck des Speichermodells der Sprache betrachtet werden, aus der WebAssembly erstellt wurde, und in einer JavaScript-Codebasis kann eine falsche Verwaltung leicht übersehen werden.
  • Das mag offensichtlich erscheinen, aber wie bei jeder anderen Codebasis sollten Sie veränderliche Zustände nicht in globalen Variablen speichern. Sie möchten keine Probleme bei der Wiederverwendung bei verschiedenen Aufrufen oder sogar Threads beheben müssen. Daher sollten Sie sie so autonom wie möglich gestalten.