Même si JavaScript est assez parlant pour nettoyer après lui-même, les langages statiques ne sont absolument pas...
Squoosh.app est une PWA qui illustre les différents codecs d'image peuvent améliorer la taille du fichier image sans affecter considérablement la qualité. Cependant, il est aussi une démo technique montrant comment prendre des bibliothèques écrites en C++ ou Rust et les importer dans le Web.
Pouvoir transférer du code depuis des écosystèmes existants est extrêmement utile, mais certains éléments clés les différences entre ces langages statiques et JavaScript. L'une d'entre elles se trouve approches de la gestion de la mémoire.
Si JavaScript est assez parlant pour nettoyer après lui-même, de tels langages statiques sont certainement pas. Vous devez demander explicitement une nouvelle mémoire allouée et assurez-vous de le rendre après et de ne plus jamais l’utiliser. Si ce n'est pas le cas, vous avez des fuites... et cela se produit en fait assez régulièrement. Voyons comment vous pouvez déboguer ces fuites de mémoire et, comment concevoir votre code pour les éviter la prochaine fois.
Schéma suspect
Récemment, alors que je commençais à travailler sur Squoosh, je n'ai pas pu m'empêcher de remarquer un modèle intéressant dans Wrappers de codec C++. Examinons un wrapper ImageQuant en tant que exemple (réduit pour n'afficher que les parties de création et de désallocation d'objets):
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 (oui, 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
);
}
Repérez-vous un problème ? Indice: il est use-after-free, mais dans JavaScript!
Dans Emscripten, typed_memory_view
renvoie un Uint8Array
JavaScript reposant sur WebAssembly (Wasm).
tampon de mémoire, avec byteOffset
et byteLength
définis sur le pointeur et la longueur donnés. La principale
est qu'il s'agit d'une vue TypedArray dans un tampon de mémoire WebAssembly, et non d'une
Copie des données appartenant à JavaScript.
Lorsque nous appelons free_result
à partir de JavaScript, il appelle à son tour une fonction C standard free
pour marquer
cette mémoire comme disponible pour toute allocation future, ce qui signifie que les données de notre vue Uint8Array
peut être remplacé par des données arbitraires lors d'un futur appel dans Wasm.
Ou bien, une implémentation de free
peut décider de remplir à zéro la mémoire libérée immédiatement. La
free
utilisé par Emscripten ne le fait pas, mais nous nous appuyons sur un détail d'implémentation ici.
qui ne peuvent pas être garanties.
Ou, même si la mémoire derrière le pointeur est préservée, une nouvelle allocation peut avoir besoin d'augmenter la
Mémoire WebAssembly. Lorsque WebAssembly.Memory
est développé soit via l'API JavaScript, soit via une
l'instruction memory.grow
, cela invalide le ArrayBuffer
existant et, de manière transitoire, toutes les vues.
s'appuient dessus.
Je vais utiliser la console des outils de développement (ou Node.js) pour illustrer ce comportement:
> 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
Enfin, même si nous n'appelons pas explicitement Wasm entre free_result
et new
Uint8ClampedArray
, il se peut qu'à un moment donné, nous ajoutions la prise en charge du multithreading à nos codecs. Dans ce cas,
peut être un thread complètement différent qui écrase
les données juste avant de parvenir à les cloner.
Recherche de bugs dans la mémoire...
Au cas où, j'ai décidé d'aller plus loin et de vérifier si ce code présente des problèmes dans la pratique. Ça semble être l'occasion idéale d'essayer les tout nouveaux désinfectants Emscripten que nous avons ajoutées l'année dernière et présenté lors de notre conférence WebAssembly lors du Chrome Dev Summit:
Dans ce cas, nous nous intéressons
AddressSanitizer, qui
peut détecter divers problèmes liés aux pointeurs et à la mémoire. Pour l'utiliser, nous devons recompiler notre codec
avec -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
Cela activera automatiquement les contrôles de sécurité du pointeur, mais nous voulons aussi trouver la mémoire potentielle les fuites sonores. Étant donné que nous utilisons ImageQuant comme bibliothèque plutôt que comme programme, il n'y a pas de "point de sortie" à Emscripten peut valider automatiquement que toute la mémoire a été libérée.
Dans ce cas, leakSanitizer (inclus dans AddressSanitizer) fournit les fonctions
__lsan_do_leak_check
et
__lsan_do_recoverable_leak_check
,
qui peut être appelé manuellement chaque fois que toute la mémoire doit être libérée et que nous voulons vérifier que
cette hypothèse. __lsan_do_leak_check
est destiné à être utilisé à la fin d'une application en cours d'exécution, lorsque vous
voulez annuler le processus en cas de détection de fuite, tandis que __lsan_do_recoverable_leak_check
est plus adapté aux cas d'utilisation des bibliothèques comme le nôtre, lorsque vous souhaitez imprimer des fuites sur la console, mais que
pour que l'application continue à s'exécuter.
Exposons cette deuxième aide via Embind afin de pouvoir l'appeler à tout moment à partir de JavaScript:
#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);
}
Et l'appeler à partir du côté JavaScript une fois que nous avons terminé l'image. Pour ce faire, Côté JavaScript, plutôt que C++, permet de s'assurer que tous les champs d'application ont été et tous les objets C++ temporaires ont été libérés au moment de l'exécution de ces vérifications:
// …
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
);
}
Nous obtenons ainsi un rapport semblable à celui-ci dans la console:
Oups, il y a de petites fuites, mais la trace de la pile n'est pas très utile, car tous les noms de fonctions s'emmêlent. Recompilons avec des informations de débogage de base pour les conserver:
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
Cela semble beaucoup mieux:
Certaines parties de la trace de la pile semblent encore obscurs, car elles pointent vers les composants internes d'Empscripten, mais nous pouvons
indiquer que la fuite provient d'une conversion RawImage
en "type de fil" (à une valeur JavaScript) par
Associer. En examinant le code, nous constatons que nous renvoyons les instances C++ RawImage
à
en JavaScript, mais nous ne les libérons jamais de chaque côté.
Pour rappel, il n'existe actuellement aucune intégration de récupération de mémoire entre JavaScript et
WebAssembly, bien qu'une propriété soit en cours de développement. Au lieu de cela, vous devez
pour libérer manuellement toute mémoire et appeler des destructeurs depuis le côté JavaScript une fois que vous avez terminé
. Pour Embind en particulier, la version officielle
documents
suggérez d'appeler une méthode .delete()
sur les classes C++ exposées:
Le code JavaScript doit supprimer explicitement tous les descripteurs d'objet C++ qu'il a reçus, ou l'emscripten le tas de mémoire croît indéfiniment.
var x = new Module.MyClass; x.method(); x.delete();
En effet, lorsque nous faisons cela en JavaScript pour notre 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 fuite disparaît comme prévu.
Découvrir plus de problèmes liés aux désinfectants
La création d'autres codecs Squoosh avec des désinfectants révèle des problèmes similaires ainsi que de nouveaux problèmes. Pour J'ai cette erreur dans les liaisons MozJPEG:
Ici, il ne s'agit pas d'une fuite, mais nous écrivons dans une mémoire en dehors des limites allouées ph.
En examinant le code de MozJPEG, nous constatons que le problème est que jpeg_mem_dest
, le
que nous utilisons afin d'allouer une destination mémoire au format JPEG : réutilise les valeurs existantes
outbuffer
et outsize
lorsqu'ils
autre que zéro:
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;
}
Cependant, nous l'invoquons sans initialiser ces variables, ce qui signifie que MozJPEG écrit le une adresse mémoire potentiellement aléatoire qui a été stockée dans ces variables au niveau à l'heure de l'appel.
uint8_t* output;
unsigned long size;
// …
jpeg_mem_dest(&cinfo, &output, &size);
L'initialisation à zéro des deux variables avant que l'appel ne résolve ce problème, et le code atteint maintenant une une vérification de fuite de mémoire à la place. Heureusement, la vérification réussit, ce qui indique que nous n'avons dans ce codec.
Problèmes liés à l'état partagé
Ou est-ce le cas ?
Nous savons que nos liaisons de codec stockent une partie de l'état et génèrent des données et MozJPEG présente des structures particulièrement compliquées.
uint8_t* last_result;
struct jpeg_compress_struct cinfo;
val encode(std::string image_in, int image_width, int image_height, MozJpegOptions opts) {
// …
}
Que se passe-t-il si certaines d'entre elles sont initialisées de manière différée lors de la première exécution, puis réutilisées de manière incorrecte lors des prochaines s'exécute ? Ainsi, un seul appel avec un désinfectant ne les signalerait pas comme problématique.
Essayons de traiter l'image plusieurs fois en cliquant de manière aléatoire sur différents niveaux de qualité. dans l'UI. Nous obtenons à présent le rapport suivant:
262 144 octets. On dirait que l'intégralité de l'exemple d'image a été divulguée de jpeg_finish_compress
.
Après avoir consulté la documentation et les exemples officiels, il s'avère que jpeg_finish_compress
ne libère pas la mémoire allouée par notre appel jpeg_mem_dest
précédent, mais uniquement le
de compression, même si celle-ci connaît déjà l'existence
destination... Soupir.
Pour résoudre ce problème, libérez les données manuellement dans la fonction 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);
}
Je pourrais continuer à les fouiller un par un, mais je pense qu'à présent, il est assez clair l'approche actuelle de la gestion de la mémoire entraîne des problèmes systématiques désagréables.
Certains peuvent être éliminés immédiatement par le désinfectant. D'autres nécessitent des tours complexes pour être interceptés. Enfin, il y a des problèmes comme au début de l'article qui, comme nous pouvons le voir dans les journaux, ne sont pas interceptés par le désinfectant. La raison est que l'usage abusif réel se produit sur le Côté JavaScript, dans lequel le désinfectant n'a aucune visibilité. Ces problèmes se révéleront qu'en production ou après des modifications ultérieures du code apparemment sans rapport.
Créer un wrapper sécurisé
Revenons un peu en arrière pour résoudre tous ces problèmes en restructurant le code de manière plus sûre. Je vais à nouveau utiliser le wrapper ImageQuant comme exemple, mais des règles de refactorisation similaires s'appliquent. à tous les codecs, ainsi qu'à d'autres codebases similaires.
Tout d'abord, corrigeons le problème "use-after-free" dès le début de cet article. Pour cela, nous avons besoin pour cloner les données de la vue reposant sur WebAssembly avant de les marquer comme libres du côté 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;
}
Vérifions à présent que nous ne partageons aucun état dans les variables globales entre les appels. Ce permet de résoudre certains des problèmes déjà rencontrés et facilite l'utilisation de notre dans un environnement multithread à l'avenir.
Pour ce faire, nous refactorisons le wrapper C++ pour nous assurer que chaque appel à la fonction gère sa propre
à l'aide de variables locales. Ensuite, nous pouvons remplacer la signature de notre fonction free_result
par
acceptez le pointeur de retour:
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);
}
Mais, comme nous utilisons déjà Embind dans Emscripten pour interagir avec JavaScript, nous pourrions rendre l'API encore plus sûre en masquant les détails de la gestion de la mémoire C++.
Pour cela, déplaçons la partie new Uint8ClampedArray(…)
de JavaScript vers C++ avec
Associer. Nous pouvons ensuite l'utiliser pour cloner les données dans la mémoire JavaScript, même avant de renvoyer
de la fonction:
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;
}
Notez qu'en une seule modification, nous nous assurons tous deux que le tableau d'octets obtenu appartient à JavaScript.
et non sauvegardés par la mémoire WebAssembly, et supprimez le wrapper RawImage
précédemment divulgué.
.
JavaScript n'a plus à se soucier de libérer des données et peut utiliser le résultat tout autre objet récupéré avec la récupération de mémoire:
// …
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);
}
Cela signifie également que nous n'avons plus besoin d'une liaison free_result
personnalisée côté 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());
}
Dans l'ensemble, notre code wrapper est devenu à la fois plus propre et plus sûr.
Ensuite, j'ai apporté d'autres améliorations mineures au code du wrapper ImageQuant. des correctifs de gestion de mémoire similaires pour d'autres codecs. Pour en savoir plus, vous pouvez consulter le PR résultant ici: Correctifs de mémoire pour C++ codecs.
Points à retenir
Quelles leçons pouvons-nous tirer de cette refactorisation et en tirer des enseignements qui pourraient être appliqués à d'autres codebases ?
- N'utilisez pas de vues de mémoire sauvegardées par WebAssembly, quel que soit le langage de développement, au-delà d'un à un appel unique. Vous ne pouvez pas compter sur leur survie plus longtemps et vous ne pourrez pas pour détecter ces bugs par des moyens conventionnels. Par conséquent, si vous devez stocker les données pour plus tard, copiez-les côté JavaScript et y stocker les données.
- Si possible, utilisez un langage de gestion de la mémoire sécurisé ou, au moins, des wrappers de type sûr, au lieu de directement sur les pointeurs bruts. Cela ne vous évitera pas de bugs dans JavaScript → WebAssembly mais au moins cela réduira la surface de signalement de bugs que le code de langage statique impliquera.
- Quel que soit le langage utilisé, exécutez du code avec des désinfectants pendant le développement : ils peuvent vous aider à
détecter non seulement les problèmes dans le code de langage statique, mais aussi ceux qui concernent le code JavaScript ;"
Limite WebAssembly, par exemple en oubliant d'appeler
.delete()
ou en transmettant des pointeurs non valides depuis côté JavaScript. - Si possible, évitez d'exposer des données et des objets non gérés de WebAssembly à JavaScript. JavaScript est un langage de récupération de mémoire, pour lequel la gestion manuelle de la mémoire n'est pas courante. Cela peut être considéré comme une fuite d'abstraction du modèle de mémoire du langage de votre WebAssembly et la gestion incorrecte est facile à négliger dans un codebase JavaScript.
- Cela peut sembler évident, mais comme dans n'importe quel autre codebase, évitez de stocker un état modifiable dans des variables. Vous ne voulez pas déboguer les problèmes de réutilisation lors de divers appels ou même des threads, il est donc préférable de le garder aussi autonome que possible.