Si bien JavaScript es bastante tolerante a la limpieza después de sí mismo, los lenguajes estáticos definitivamente no...
Squoosh.app es una AWP que ilustra cuánto pueden mejorar los diferentes parámetros de configuración y códecs de imagen el tamaño del archivo de imagen sin afectar significativamente la calidad. Sin embargo, también es una demostración técnica que muestra cómo puedes tomar bibliotecas escritas en C++ o Rust y llevarlas a la Web.
Poder portar código de ecosistemas existentes es increíblemente valioso, pero existen algunas diferencias clave entre esos lenguajes estáticos y JavaScript. Una de ellas tiene que ver con sus diferentes enfoques de administración de la memoria.
Si bien JavaScript es bastante tolerante a la hora de limpiarse, estos lenguajes estáticos no lo son. Debes solicitar explícitamente una nueva memoria asignada y asegurarte de devolverla después y no volver a usarla. Si eso no sucede, se producen filtraciones, y esto sucede con bastante frecuencia. Veamos cómo puedes depurar esas fugas de memoria y, mejor aún, cómo puedes diseñar tu código para evitarlas la próxima vez.
Patrón sospechoso
Recientemente, cuando comencé a trabajar en Squoosh, no pude evitar notar un patrón interesante en los wrappers de códecs de C++. Veamos un wrapper ImageQuant como ejemplo (reducido para mostrar solo las partes de la creación y la desasignación de objetos):
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 (en realidad, 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
);
}
¿Detectas algún problema? Pista: Es uso después de la liberación, pero en JavaScript.
En Emscripten, typed_memory_view
muestra un Uint8Array
de JavaScript respaldado por el búfer de memoria de WebAssembly (Wasm), con byteOffset
y byteLength
configurados en el puntero y la longitud determinados. El punto principal es que esta es una vista de TypedArray en un búfer de memoria de WebAssembly, en lugar de una copia de los datos que pertenece a JavaScript.
Cuando llamamos a free_result
desde JavaScript, este, a su vez, llama a una función estándar de C free
para marcar esta memoria como disponible para cualquier asignación futura, lo que significa que los datos a los que apunta nuestra vista Uint8Array
se pueden reemplazar con datos arbitrarios mediante cualquier llamada futura a Wasm.
O bien, alguna implementación de free
podría decidir completar la memoria liberada con cero de inmediato. El free
que usa Emscripten no lo hace, pero aquí dependemos de un detalle de implementación que no se puede garantizar.
O, incluso si se conserva la memoria detrás del puntero, es posible que la asignación nueva deba aumentar la memoria de WebAssembly. Cuando se aumenta WebAssembly.Memory
a través de la API de JavaScript o la instrucción memory.grow
correspondiente, se invalida el ArrayBuffer
existente y, de forma transitiva, cualquier vista que lo respalde.
Usaré la consola de DevTools (o Node.js) para demostrar este comportamiento:
> 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
Por último, incluso si no volvamos a llamar a Wasm de forma explícita entre free_result
y new
Uint8ClampedArray
, es posible que, en algún momento, agreguemos compatibilidad con subprocesos a nuestros códecs. En ese caso, podría ser un subproceso completamente diferente que reemplace los datos justo antes de que logremos clonarlo.
Busca errores de memoria
Por si acaso, decidí ir más allá y verificar si este código presenta algún problema en la práctica. Esta parece una oportunidad perfecta para probar la nueva compatibilidad con los validadores de Emscripten que se agregó el año pasado y se presentó en nuestra charla sobre WebAssembly en Chrome Dev Summit:
En este caso, nos interesa AddressSanitizer, que puede detectar varios problemas relacionados con punteros y memoria. Para usarlo, debemos volver a compilar nuestro codec con -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
Esto habilitará automáticamente las verificaciones de seguridad de los punteros, pero también queremos encontrar posibles fugas de memoria. Como usamos ImageQuant como una biblioteca en lugar de un programa, no hay un "punto de salida" en el que Emscripten pueda validar automáticamente que se liberó toda la memoria.
En cambio, para esos casos, LeakSanitizer (incluido en AddressSanitizer) proporciona las funciones __lsan_do_leak_check
y __lsan_do_recoverable_leak_check
, que se pueden invocar de forma manual cada vez que esperamos que se libere toda la memoria y queramos validar esa suposición. __lsan_do_leak_check
está diseñado para usarse al final de una aplicación en ejecución, cuando quieres abortar el proceso en caso de que se detecten fugas, mientras que __lsan_do_recoverable_leak_check
es más adecuado para casos de uso de bibliotecas como el nuestro, cuando quieres imprimir fugas en la consola, pero mantener la aplicación en ejecución de todos modos.
Expongamos ese segundo ayudante a través de Embind para que podamos llamarlo desde JavaScript en cualquier momento:
#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);
}
Y lo invocaremos desde el lado de JavaScript una vez que terminemos con la imagen. Hacer esto desde el lado de JavaScript, en lugar del de C++, ayuda a garantizar que se hayan cerrado todos los alcances y que se hayan liberado todos los objetos temporales de C++ para el momento en que ejecutamos esas verificaciones:
// …
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
);
}
Esto nos brinda un informe como el siguiente en la consola:
Uy, hay algunas fugas pequeñas, pero el seguimiento de pila no es muy útil, ya que todos los nombres de las funciones están dañados. Volvamos a compilar con una información de depuración básica para conservarlos:
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
Se ve mucho mejor:
Algunas partes del seguimiento de pila aún parecen oscuras, ya que apuntan a los elementos internos de Emscripten, pero podemos decir que la filtración proviene de una conversión de RawImage
a "tipo de cable" (a un valor de JavaScript) por Embind. De hecho, cuando observamos el código, podemos ver que devolvemos RawImage
instancias de C++ a JavaScript, pero nunca las liberamos en ningún lado.
Recuerda que, actualmente, no hay integración de recolección de basura entre JavaScript y WebAssembly, aunque se está desarrollando una. En su lugar, debes liberar manualmente cualquier memoria y llamar a destructores desde el lado de JavaScript una vez que hayas terminado con el objeto. En el caso específico de Embind, los documentos oficiales sugieren llamar a un método .delete()
en clases C++ expuestas:
El código de JavaScript debe borrar de forma explícita cualquier controlador de objetos C++ que haya recibido, o el montón de Emscripten crecerá de forma indefinida.
var x = new Module.MyClass; x.method(); x.delete();
De hecho, cuando hacemos eso en JavaScript para nuestra clase, sucede lo siguiente:
// …
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 fuga desaparece según lo esperado.
Cómo descubrir más problemas con los desinfectantes
La compilación de otros códecs de Squoosh con validadores revela problemas similares y algunos nuevos. Por ejemplo, tengo este error en las vinculaciones de MozJPEG:
Aquí, no se trata de una filtración, sino de que escribimos en una memoria fuera de los límites asignados 😱
Si analizamos el código de MozJPEG, descubrimos que el problema es que jpeg_mem_dest
, la función que usamos para asignar un destino de memoria para JPEG, vuelve a usar los valores existentes de outbuffer
y outsize
cuando no son cero:
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;
}
Sin embargo, lo invocamos sin inicializar ninguna de esas variables, lo que significa que MozJPEG escribe el resultado en una dirección de memoria potencialmente aleatoria que se almacenó en esas variables en el momento de la llamada.
uint8_t* output;
unsigned long size;
// …
jpeg_mem_dest(&cinfo, &output, &size);
La inicialización en cero de ambas variables antes de la invocación resuelve este problema, y ahora el código llega a una comprobación de fuga de memoria. Por suerte, la verificación se realiza correctamente, lo que indica que no tenemos ninguna fuga en este códec.
Problemas con el estado compartido
¿O no?
Sabemos que nuestras vinculaciones de códecs almacenan parte del estado, así como los resultados en variables estáticas globales, y MozJPEG tiene algunas estructuras particularmente complicadas.
uint8_t* last_result;
struct jpeg_compress_struct cinfo;
val encode(std::string image_in, int image_width, int image_height, MozJpegOptions opts) {
// …
}
¿Qué sucede si algunos de ellos se inicializan de forma diferida en la primera ejecución y, luego, se vuelven a usar de forma incorrecta en ejecuciones futuras? De esta manera, una sola llamada con un elemento de limpieza no los informará como problemáticos.
Intentemos procesar la imagen un par de veces haciendo clic de forma aleatoria en diferentes niveles de calidad en la IU. De hecho, ahora recibimos el siguiente informe:
262,144 bytes: Parece que se filtró toda la imagen de muestra de jpeg_finish_compress
.
Después de revisar la documentación y los ejemplos oficiales, resulta que jpeg_finish_compress
no libera la memoria asignada por nuestra llamada jpeg_mem_dest
anterior, solo libera la estructura de compresión, aunque esa estructura de compresión ya conoce nuestro destino de memoria… suspiro.
Para corregir este problema, liberamos los datos de forma manual en la función 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);
}
Podría seguir buscando esos errores de memoria uno por uno, pero creo que a estas alturas está bastante claro que el enfoque actual de la administración de memoria genera algunos problemas sistemáticos desagradables.
Algunos de ellos pueden ser detectados de inmediato por el desinfectante. Otras requieren trucos complejos para ser atrapados. Por último, hay problemas como los del principio de la publicación que, como podemos ver en los registros, el validador no detecta. El motivo es que el uso inadecuado real ocurre en el lado de JavaScript, en el que el validador no tiene visibilidad. Esos problemas se revelarán solo en producción o después de cambios aparentemente no relacionados en el código en el futuro.
Cómo compilar un wrapper seguro
Retrocedamos un par de pasos y, en su lugar, solucionemos todos estos problemas reestructurando el código de una manera más segura. Volveré a usar el wrapper de ImageQuant como ejemplo, pero se aplican reglas de refactorización similares a todos los códecs, así como a otras bases de código similares.
En primer lugar, corrijamos el problema de uso después de la liberación del principio de la publicación. Para ello, debemos clonar los datos de la vista respaldada por WebAssembly antes de marcarlos como gratuitos en el lado de 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;
}
Ahora, asegurémonos de no compartir ningún estado en las variables globales entre invocaciones. Esto corregirá algunos de los problemas que ya vimos y facilitará el uso de nuestros códecs en un entorno multiproceso en el futuro.
Para ello, refactorizamos el wrapper de C++ para asegurarnos de que cada llamada a la función administre sus propios datos con variables locales. Luego, podemos cambiar la firma de nuestra función free_result
para
aceptar el puntero de vuelta:
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);
}
Sin embargo, como ya usamos Embind en Emscripten para interactuar con JavaScript, podríamos hacer que la API sea aún más segura si ocultáramos todos los detalles de la administración de memoria de C++.
Para ello, muevamos la parte new Uint8ClampedArray(…)
de JavaScript al lado de C++ con Embind. Luego, podemos usarlo para clonar los datos en la memoria de JavaScript incluso antes de regresar de la función:
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;
}
Ten en cuenta que, con un solo cambio, nos aseguramos de que el array de bytes resultante sea propiedad de JavaScript y no esté respaldado por la memoria de WebAssembly, y también nos deshacemos del wrapper RawImage
filtrado con anterioridad.
Ahora JavaScript no tiene que preocuparse por liberar datos y puede usar el resultado como cualquier otro objeto de recolección de basura:
// …
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);
}
Esto también significa que ya no necesitamos una vinculación free_result
personalizada en el lado de 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());
}
En definitiva, nuestro código wrapper se volvió más limpio y seguro al mismo tiempo.
Después de esto, hice algunas mejoras menores en el código del wrapper de ImageQuant y repliqué correcciones de administración de memoria similares para otros códecs. Si te interesan más detalles, puedes ver la PR resultante aquí: Correcciones de memoria para los códecs de C++.
Conclusiones
¿Qué lecciones podemos aprender y compartir de esta refactorización que se podrían aplicar a otras bases de código?
- No uses vistas de memoria respaldadas por WebAssembly, independientemente del lenguaje en el que se compilan, más allá de una sola invocación. No podrás confiar en que sobrevivirán por más tiempo, y no podrás detectar estos errores por medios convencionales, por lo que, si necesitas almacenar los datos para más adelante, cópialos en JavaScript y almacénalos allí.
- Si es posible, usa un lenguaje de administración de memoria seguro o, al menos, wrappers de tipo seguro, en lugar de operar directamente en punteros sin procesar. Esto no te salvará de los errores en el límite de JavaScript ↔ WebAssembly, pero, al menos, reducirá la superficie de errores independientes del código de lenguaje estático.
- Independientemente del lenguaje que uses, ejecuta el código con validadores durante el desarrollo. Pueden ayudar a detectar no solo problemas en el código del lenguaje estático, sino también algunos problemas en el límite de JavaScript ↔ WebAssembly, como olvidarse de llamar a
.delete()
o pasar punteros no válidos desde el lado de JavaScript. - Si es posible, evita exponer datos y objetos no administrados de WebAssembly a JavaScript por completo. JavaScript es un lenguaje con recolección de elementos no utilizados, y no es común la administración manual de la memoria. Esto se puede considerar una filtración de abstracción del modelo de memoria del lenguaje a partir del cual se compiló tu WebAssembly, y es fácil pasar por alto la administración incorrecta en una base de código de JavaScript.
- Esto puede ser obvio, pero, al igual que en cualquier otra base de código, evita almacenar el estado mutable en variables globales. No querrás depurar problemas con la reutilización en varias invocaciones o incluso subprocesos, por lo que es mejor mantenerlo lo más autónomo posible.