JavaScript ist in dieser Hinsicht recht nachsichtig, statische Sprachen hingegen nicht.
Squoosh.app ist eine PWA, die veranschaulicht, wie sehr sich verschiedene Bildcodecs und -einstellungen auf die Größe von Bilddateien auswirken 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 äußerst wertvoll, Code aus bestehenden Systemen portieren zu können. Es gibt jedoch einige wichtige Unterschiede zwischen diesen statischen Sprachen und JavaScript. Einer davon ist der unterschiedliche Ansatz bei der Arbeitsspeicherverwaltung.
JavaScript ist in dieser Hinsicht recht nachsichtig, bei solchen statischen Sprachen ist das aber 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 zur Objekterstellung und -dealokation 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? Hinweis: Es ist ein Use-After-Free, aber 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 von Wasm mit beliebigen Daten überschrieben werden können.
Bei einigen Implementierungen von free
wird der freigegebene Arbeitsspeicher möglicherweise sogar sofort auf Null gesetzt. Die von Emscripten verwendete free
tut das nicht, aber wir verlassen uns hier auf ein Implementierungsdetail, das nicht garantiert werden kann.
Selbst wenn der Speicher hinter dem Zeiger erhalten bleibt, muss der WebAssembly-Speicher möglicherweise durch eine 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 sich um einen ganz anderen Thread handeln, der die Daten gerade überschreibt, bevor wir sie klonen können.
Nach Speicherfehlern suchen
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) für solche Fälle 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:
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:
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 den offiziellen Docs empfohlen, eine .delete()
-Methode auf freigegebenen C++-Klassen aufzurufen:
JavaScript-Code muss alle empfangenen C++-Objekt-Handles explizit löschen, da der Emscripten-Heap sonst unbegrenzt wächst.
var x = new Module.MyClass;
x.method();
x.delete();
Wenn wir das in JavaScript für unsere Klasse tun, geschieht Folgendes:
// …
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. Beispielsweise erhalte ich in den MozJPEG-Bindungen diesen Fehler:
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);
Wenn Sie beide Variablen vor der Aufrufung auf Null initialisieren, wird dieses Problem behoben. Der Code erreicht jetzt stattdessen eine Prüfung auf Speicherlecks. Glücklicherweise ist die Prüfung erfolgreich, was bedeutet, dass es bei diesem Codec keine Lecks gibt.
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:
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, sondern nur die Komprimierungsstruktur, 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 wiederum lassen sich nur mit ausgeklügelten Tricks fangen. Schließlich gibt es Probleme wie am Anfang des Beitrags, die laut den Protokollen 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 WebAssembly-gestützten Ansicht klonen, bevor wir sie auf der JavaScript-Seite als verfügbar 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 refaktorisieren wir den C++-Wrapper, damit jeder Funktionsaufruf seine eigenen Daten mithilfe lokaler Variablen verwaltet. Anschließend können wir die Signatur unserer free_result
-Funktion ändern, damit der Zeiger zurückgegeben 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 mehr auf C++-Seite 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 entsprechenden 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 Sprache zur sicheren Speicherverwaltung oder zumindest sichere Typ-Wrapper, anstatt direkt mit Rohzeigern zu arbeiten. Das schützt Sie zwar nicht vor Fehlern an der Schnittstelle zwischen JavaScript und WebAssembly, reduziert aber zumindest die Wahrscheinlichkeit für Fehler, die durch den statischen Sprachcode verursacht werden.
- Unabhängig von der verwendeten Sprache sollten Sie während der Entwicklung Code mit Sanitizern ausführen. So lassen sich nicht nur Probleme im Code der statischen Sprache, sondern auch einige Probleme an der Grenze zwischen JavaScript und WebAssembly erkennen, z. B. das Vergessen des Aufrufs von
.delete()
oder das Übergeben ungültiger Pointer 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 Ihr WebAssembly-Code erstellt wurde. Eine falsche Verwaltung ist in einer JavaScript-Codebasis leicht zu übersehen.
- Das mag offensichtlich sein, aber wie in 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.