Speicherlecks in WebAssembly mit Emscripten beheben

JavaScript ist zwar ziemlich verzeihend, wenn es darum geht, Daten vollständig zu bereinigen, statische Sprachen sind jedoch definitiv nicht ...

Squoosh.app ist eine PWA, die veranschaulicht, wie viele verschiedene Bild-Codecs sich unterscheiden. und -Einstellungen können die Größe der Bilddatei verbessert werden, ohne die Qualität wesentlich zu beeinträchtigen. Es ist jedoch auch eine technische Demo, die zeigt, wie Sie in C++ oder Rust geschriebene Bibliotheken in den Web.

Code aus vorhandenen Systemen zu portieren ist unglaublich wertvoll, aber es gibt einige statischen Sprachen und JavaScript unterscheiden. Eine davon befindet sich für die Speicherverwaltung.

Zwar verzeiht JavaScript bei der Bereinigung der Daten selbst, solche statischen Sprachen definitiv nicht. Sie müssen explizit einen neuen zugewiesenen Speicher anfordern und geben Sie es anschließend zurück und verwenden Sie es nie wieder. Wenn das nicht passiert, entstehen Lecks ... und geschieht das ziemlich regelmäßig. Sehen wir uns an, wie Sie diese Speicherlecks noch besser, wie Sie Ihren Code so gestalten können, dass solche Probleme beim nächsten Mal vermieden werden.

Verdächtiges Muster

In letzter Zeit habe ich bei Squoosh angefangen und dabei ein interessantes Muster festgestellt. C++-Codec-Wrapper. Sehen wir uns einen ImageQuant-Wrapper als Beispiel (verringert, um nur Teile der Objekterstellung und der Deallocation zu zeigen):

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

Tritt ein Problem auf? Tipp: Es ist Use-After-Free, aber in JavaScript!

In Emscripten gibt typed_memory_view ein JavaScript-Uint8Array zurück, das von WebAssembly (Wasm) unterstützt wird Speicherpuffer, wobei byteOffset und byteLength auf den angegebenen Zeiger und die angegebene Länge gesetzt sind. Die wichtigsten Es handelt sich hierbei um eine TypedArray-view in einem WebAssembly-Speicherpuffer und nicht um einen JavaScript-eigene Kopie der Daten.

Wenn wir free_result aus JavaScript aufrufen, wird wiederum eine standardmäßige C-Funktion (free) aufgerufen, um als für zukünftige Zuweisungen verfügbar. Das sind die Daten, die Uint8Array verweist, kann bei einem zukünftigen Aufruf in Wasm mit beliebigen Daten überschrieben werden.

Oder einige Implementierungen von free entscheiden sogar, den freigegebenen Arbeitsspeicher sofort mit Nullen zu füllen. Die free von Emscripten ist das nicht der Fall, aber wir verlassen uns hier auf ein Implementierungsdetail. die nicht garantiert werden können.

Oder selbst wenn der Arbeitsspeicher hinter dem Zeiger erhalten bleibt, muss die neue Zuweisung möglicherweise den WebAssembly-Arbeitsspeicher. Wenn WebAssembly.Memory entweder über die JavaScript API oder über eine entsprechende memory.grow-Anweisung an, werden die vorhandenen ArrayBuffer und vorübergehend alle Ansichten ungültig. gestützt wird.

Lassen Sie mich die DevTools-Konsole (oder 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

Und schließlich fügen wir unseren Codecs möglicherweise Multithreading-Unterstützung zu einem späteren Zeitpunkt hinzu, selbst wenn wir zwischen free_result und new Uint8ClampedArray nicht explizit Wasm aufrufen. In diesem Fall kurz vor dem Klonen könnte es sich um einen komplett anderen Thread handeln, der die Daten überschreibt.

Suche nach Gedächtnis-Programmfehlern

Ich habe mich dazu entschlossen, zu prüfen, ob dieser Code in der Praxis Probleme aufweist. Dies scheint eine perfekte Gelegenheit zu sein, die neuen Emscripten-Desinfektionsmittel auszuprobieren. , die letztes Jahr hinzugekommen sind, und präsentiert in unserem WebAssembly-Vortrag beim Chrome Dev Summit:

In diesem Fall interessieren wir uns AddressSanitizer verwenden, verschiedene Pointer- und speicherbezogene Probleme erkennen. Um sie verwenden zu können, müssen wir unseren Codec neu kompilieren. mit -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

Dadurch werden automatisch Zeiger-Sicherheitschecks aktiviert. Wir möchten aber auch potenziellen Arbeitsspeicher finden. Lecks. Da wir ImageQuant als Bibliothek und nicht als Programm verwenden, gibt es keinen „Ausgangspunkt“. bei Damit konnte Emscripten automatisch prüfen, ob der gesamte Arbeitsspeicher freigegeben wurde.

Stattdessen bietet LeakSanitizer (in AddressSanitizer enthalten) für solche Fälle die Funktionen __lsan_do_leak_check und __lsan_do_recoverable_leak_check, Dieser kann manuell aufgerufen werden, wenn erwartet wird, dass der gesamte Arbeitsspeicher freigegeben ist, und überprüfen möchten, Annahme. __lsan_do_leak_check ist für den Einsatz am Ende einer laufenden Anwendung vorgesehen, wenn Sie möchten den Prozess abbrechen, falls Datenlecks erkannt werden, während __lsan_do_recoverable_leak_check eignet sich besser für Bibliotheksanwendungen wie unseren, wenn Sie Leaks an die Konsole ausgeben möchten, aber die Anwendung trotzdem weiter ausführen.

Lassen Sie uns diesen zweiten Helfer über Embind verfügbar machen, damit wir ihn 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 es von der JavaScript-Seite aus auf, sobald wir mit dem Bild fertig sind. Dies erfolgt über den JavaScript-Seite anstatt der C++-Seite hilft dabei, sicherzustellen, dass alle Bereiche beendet und alle temporären C++-Objekte wurden beim Ausführen dieser Prüfungen freigegeben:

  // 

  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

Hoppla, es gibt ein paar kleine Datenlecks, aber der Stacktrace ist nicht sehr hilfreich, da alle Funktionsnamen beschädigt sind. Lassen Sie uns die Daten mit grundlegenden Debugging-Informationen neu kompilieren, 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 Nachricht mit dem Text „Direktes Speicherleck von 12 Byte“ von einer GenericBindingType RawImage ::toWireType-Funktion

Einige Teile des Stacktrace sind noch immer unklar, da sie auf das Innere von Emscripten verweisen, aber wir können feststellen, dass die Lücke von einer RawImage-Umwandlung in einen Kabeltyp verursacht wurde (in einen JavaScript-Wert) durch Kombiniert. Wenn wir uns den Code ansehen, sehen wir, dass wir RawImage C++-Instanzen aber wir geben sie niemals kostenlos.

Zur Erinnerung: Derzeit gibt es keine Integration der automatischen Speicherbereinigung zwischen JavaScript und WebAssembly, obwohl sich noch eins in der Entwicklung befindet. Stattdessen müssen Sie um Arbeitsspeicher manuell freizugeben und Destruktoren von JavaScript-Seite aus aufzurufen, sobald Sie mit der -Objekt enthält. Insbesondere für Embind ist die offizielle Dokumentation schlagen vor, eine .delete()-Methode für gefährdete C++-Klassen aufzurufen:

JavaScript-Code muss explizit alle empfangenen C++ Objekt-Handles löschen, oder die Emscripten- Heaps unbegrenzt wachsen.

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 entdecken

Der Aufbau anderer Squoosh-Codecs mit Desinfektionsmitteln zeigt sowohl ähnliche als auch einige neue Probleme auf. Für Beispiel: Ich habe diesen Fehler in MozJPEG-Bindungen:

Screenshot einer Nachricht

Hier ist es kein Leck, sondern wir schreiben in einen Speicher außerhalb der zugewiesenen Grenzen. ☀️

Im Code von MozJPEG stellen wir fest, dass das Problem hier ist, dass jpeg_mem_dest – das -Funktion, mit der wir JPEG ein Speicherziel zuweisen – verwendet die vorhandenen Werte der outbuffer und outsize, wenn sie ungleich null:

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

Es wird jedoch aufgerufen, ohne eine dieser Variablen zu initialisieren, was bedeutet, dass MozJPEG die zu einer potenziell zufällig zufälligen Speicheradresse führen, Zeitpunkt des Anrufs.

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

Das Problem wird durch das Initialisieren beider Variablen vor dem Aufruf behoben. stattdessen eine Speicherleckprüfung. Die Prüfung besteht erfolgreich, was darauf hinweist, dass und Lecks in diesem Codec.

Probleme mit geteiltem Status

...oder auch?

Wir wissen, dass unsere Codec-Bindungen einen Teil des Status speichern und eine globale statische Variablen und 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 bei der ersten Ausführung verzögert initialisiert und dann später nicht ordnungsgemäß wiederverwendet werden Läufe? Dann würde ein einzelner Aufruf mit einem Desinfektionsmittel sie nicht als problematisch melden.

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

Screenshot einer Nachricht

262.144 Byte – scheinbar ist das gesamte Beispielbild von jpeg_finish_compress durchgesickert.

Wir haben uns die Dokumentation und die offiziellen Beispiele angesehen und festgestellt, dass jpeg_finish_compress gibt den durch den vorherigen jpeg_mem_dest-Aufruf zugewiesenen Arbeitsspeicher nicht kostenlos, sondern gibt nur die Komprimierungsstruktur, obwohl sie unseren Speicher bereits kennt Ziel... Seufz.

Dieses Problem lässt sich 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 die Gedächtnis-Bugs einzeln suchen, aber inzwischen ist es klar genug, Herangehensweise an die Speicherverwaltung führt zu einigen schwerwiegenden systematischen Problemen.

Einige davon können sofort vom Desinfektionsmittel gefangen werden. Andere wiederum erfordern komplexe Tricks, um gefangen zu werden. Schließlich gibt es noch Probleme wie am Anfang des Beitrags, die, wie wir aus den Protokollen sehen können, überhaupt nicht vom Desinfektionsmittel abgefangen. Der Grund dafür ist, dass der eigentliche JavaScript-Seite, bei der das Sanitizer keine Einsicht hat. Diese Probleme zeigen sich nur in der Produktion oder nach scheinbaren Änderungen am Code in der Zukunft ausgeführt werden.

Sicheren Wrapper erstellen

Beheben wir stattdessen alle Probleme, indem wir den Code umstrukturieren, zu schützen. Ich verwende den ImageQuant-Wrapper erneut als Beispiel, aber es gelten ähnliche Refaktorierungsregeln. auf alle Codecs sowie auf andere ähnliche Codebasen anwenden.

Zunächst beheben wir das Problem mit der Nutzung ohne Abo von Anfang des Beitrags. Dafür benötigen wir um die Daten aus der durch WebAssembly gesicherten Ansicht zu klonen, bevor Sie 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 zwischen Aufrufen kein Status in globalen Variablen angegeben wird. Dieses werden einige der bereits bekannten Probleme behoben und die Nutzung der in einer Multithread-Umgebung nutzen können.

Dazu refaktorieren wir den C++-Wrapper, um sicherzustellen, dass jeder Aufruf der Funktion seinen eigenen Daten mithilfe lokaler Variablen. Dann können wir die Signatur der Funktion free_result ändern in Zeiger wieder an:

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, machen Sie die API noch sicherer, indem Sie Details zur C++-Speicherverwaltung vollständig verbergen.

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

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 beide sicherstellen, dass das resultierende Byte-Array JavaScript-Inhaber ist. und nicht durch WebAssembly-Arbeitsspeicher gestützt wird, und entfernen Sie den zuvor gehackten RawImage-Wrapper. .

Jetzt muss sich JavaScript nicht mehr um die Freigabe von Daten kümmern und kann das Ergebnis wie folgt verwenden: jeglichen anderen für die automatische Speicherbereinigung vorgesehenen Objekte:

  // 

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

Alles in allem wurde unser Wrapper-Code gleichzeitig sauberer und sicherer.

Anschließend habe ich einige weitere kleinere Verbesserungen am Code des ImageQuant-Wrappers vorgenommen Sie haben ähnliche Fehlerbehebungen für die Speicherverwaltung für andere Codecs repliziert. Wenn Sie mehr erfahren möchten, Hier sehen Sie die resultierende PR: Arbeitsspeicher-Korrekturen für C++ Codecs.

Fazit

Welche Lehren, die wir aus dieser Refaktorierung ziehen können und die auf andere Codebasen angewendet werden könnten, lassen sich daraus ziehen?

  • Verwenden Sie keine von WebAssembly unterstützten Speicheransichten, unabhängig von der verwendeten Sprache, einzelnen Aufruf. Ihr könnt euch nicht darauf verlassen, dass sie länger überleben, um diese Fehler auf konventionelle Weise zu erkennen. Wenn Sie die Daten also für später speichern müssen, kopieren Sie sie und speichern Sie es dort.
  • Verwenden Sie nach Möglichkeit eine sichere Speicherverwaltungssprache oder direkt mit Rohzeigern arbeiten. Fehler bei JavaScript → WebAssembly werden dadurch nicht vorgebeugt Begrenzung, aber zumindest wird dadurch die Oberfläche für Fehler reduziert, die im statischen Sprachcode nicht enthalten sind.
  • Ganz gleich, welche Sprache Sie verwenden, führen Sie während der Entwicklung Code mit Sanitizern aus – diese können helfen, nicht nur Probleme im statischen Sprachcode, sondern auch einige Probleme im JavaScript-Code zu erkennen. WebAssembly-Grenze, z. B. wird vergessen, .delete() aufzurufen oder ungültige Verweise von auf der JavaScript-Seite.
  • Vermeiden Sie es nach Möglichkeit, nicht verwaltete Daten und Objekte aus WebAssembly für JavaScript offenzulegen. JavaScript ist eine automatische Speicherbereinigung, die nicht häufig für die manuelle Speicherverwaltung verwendet wird. Dies kann als Abstraktionsleck des Speichermodells der Sprache betrachtet werden, Falsches Management ist in einer JavaScript-Codebasis leicht zu übersehen.
  • Dies mag offensichtlich sein, aber sollten Sie wie bei jeder anderen Codebasis vermeiden, den änderbaren Status im globalen Variablen. Sie möchten keine Probleme bei der Wiederverwendung in verschiedenen Aufrufen oder sogar -Threads, daher sollten Sie sie so eigenständig wie möglich halten.