Cómo depurar fugas de memoria en WebAssembly con Emscripten

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 la cantidad de códecs de imagen diferentes. y configuración puede mejorar el tamaño del archivo de imagen sin afectar la calidad de forma significativa. Sin embargo, también es una demostración técnica en la que se muestra cómo puedes tomar bibliotecas escritas en C++ o Rust y llevarlas al web.

Poder transferir código de ecosistemas existentes es increíblemente valioso, pero hay algunas entre esos lenguajes estáticos y JavaScript. Una de ellas se encuentra en su enfoques sobre la administración de la memoria.

Si bien JavaScript es bastante tolerante a la limpieza después de sí mismo, estos lenguajes estáticos son definitivamente no. Debes solicitar explícitamente una nueva memoria asignada asegúrate de devolverla después y no volver a usarla. Si eso no sucede, se producirán filtraciones... en realidad sucede con bastante regularidad. Echemos un vistazo a cómo puedes depurar esas fugas de memoria y, aún mejor, cómo puedes diseñar tu código para evitarlos la próxima vez.

Patrón sospechoso

Recientemente, cuando empecé a trabajar en Squoosh, no pude dejar de notar un interesante patrón en Wrappers de códec C++. Veamos un wrapper de ImageQuant como un Ejemplo (se reduce 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 (bien, 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
  );
}

¿Identificas algún problema? Una pista: es usar después de la liberación, pero ¡JavaScript!

En Emscripten, typed_memory_view muestra un Uint8Array de JavaScript respaldado por WebAssembly (Wasm). búfer de memoria, con byteOffset y byteLength establecidos en el puntero y la longitud determinados. El principal punto es que esta es una vista de TypedArray en un búfer de memoria de WebAssembly, en lugar de un Copia de los datos propiedad de JavaScript.

Cuando llamamos a free_result desde JavaScript, este, a su vez, llama a una función C estándar free para marcar esta memoria como disponible para cualquier asignación futura, lo que significa que los datos que Uint8Array ve a los que apunta, pueden ser reemplazadas con datos arbitrarios por cualquier llamada futura a Wasm.

O bien, es posible que alguna implementación de free decida incluso no llenar la memoria liberada de inmediato. El La free que usa Emscripten no hace eso, pero dependemos de un detalle de implementación que se muestra aquí que no se pueden garantizar.

O, incluso si se conserva la memoria detrás del puntero, es posible que la nueva asignación deba aumentar memoria de WebAssembly. Cuando se cultive WebAssembly.Memory, ya sea a través de la API de JavaScript o correspondiente memory.grow, invalida el ArrayBuffer existente y, de forma transitiva, cualquier vista respaldado por ella.

Usaré la consola de Herramientas para desarrolladores (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 volvemos a llamar explícitamente a Wasm entre free_result y new Uint8ClampedArray, en algún momento podríamos agregar compatibilidad con varios subprocesos a nuestros códecs. En ese caso, podría ser un subproceso completamente diferente que reemplace los datos justo antes de que logremos clonarlos.

Buscando errores de memoria

Por si acaso, decidí profundizar y verificar si el código muestra algún problema en la práctica. Esta parece ser una oportunidad perfecta para probar los nuevos desinfectantes Emscripten de asistencia que se agregó el año pasado y la presentamos en nuestra charla de WebAssembly en la Chrome Dev Summit:

En este caso, nos interesa la AddressSanitizer, que puede detectar varios problemas relacionados con el puntero y la memoria. Para usarlo, debemos volver a compilar el códec 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 del puntero, pero también fugas. Debido a que usamos ImageQuant como biblioteca en lugar de programa, no existe un "punto de salida" en en el que Emscripten podría validar automáticamente que se haya liberado toda la memoria.

En esos casos, LeakSanitizer (incluido en AddressSanitizer) proporciona las funciones __lsan_do_leak_check y __lsan_do_recoverable_leak_check, que se puede invocar manualmente cada vez que esperamos que se libere toda la memoria y queremos validar que suposición. __lsan_do_leak_check está diseñado para usarse al final de una aplicación en ejecución, cuando desean anular el proceso en caso de que se detecten fugas, mientras que __lsan_do_recoverable_leak_check es más adecuada para casos de uso de bibliotecas como el nuestro, cuando quieres imprimir filtraciones en la consola, pero mantener la aplicación ejecutándose independientemente.

Expondremos ese segundo asistente 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);
}

Invoque el código desde JavaScript cuando terminemos con la imagen. Para hacer esto desde el JavaScript, en lugar de C++, ayuda a garantizar que todos los ámbitos se hayan salió y todos los objetos C++ temporales se liberaron cuando 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:

Captura de pantalla de un mensaje

Hay algunas pequeñas fugas, pero el seguimiento de pila no es muy útil, ya que todos los nombres de las funciones se alteran. Volvamos a compilar con información de depuración básica para preservarlos:

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

Esto se ve mucho mejor:

Captura de pantalla de un mensaje que dice &quot;Fuga directa de 12 bytes&quot; procedente de una función GenericBindingType RawImage ::toWireType

Algunas partes del seguimiento de pila aún se ven oscuras ya que apuntan a partes internas de Emscripten, pero podemos Indicar que la fuga proviene de una conversión de RawImage a "tipo de cable". (a un valor de JavaScript) Embind. De hecho, cuando miramos el código, podemos ver que devolvemos instancias de C++ de RawImage a JavaScript, pero nunca los liberamos de ninguno de los dos lados.

Como recordatorio, actualmente no hay una integración de recolección de elementos no utilizados entre JavaScript y WebAssembly, aunque están en desarrollo. En cambio, tienes para liberar memoria y llamar a los destructores de JavaScript cuando termines . Para Embind específicamente, el oficial documentos Se sugiere llamar a un método .delete() en las clases C++ expuestas:

El código JavaScript debe borrar de forma explícita cualquier controlador de objeto C++ que haya recibido o la etiqueta Emscripten el montón crecerá indefinidamente.

var x = new Module.MyClass;
x.method();
x.delete();

De hecho, cuando hacemos eso en JavaScript para nuestra clase:

  // 

  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

Compilar otros códecs de Squoosh con desinfectantes revela problemas similares y algunos nuevos. Para ejemplo, tengo este error en las vinculaciones de MozJPEG:

Captura de pantalla de un mensaje

Aquí, no es una fuga, sino que escribimos en una memoria fuera de los límites asignados 😱

Si profundizamos en el código de MozJPEG, encontramos que el problema aquí es que jpeg_mem_dest, el que usamos para asignar un destino de memoria para JPEG; reutiliza los valores existentes de outbuffer y outsize cuando estén distinto de 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, por lo que MozJPEG escribe el en una dirección de memoria potencialmente aleatoria que se almacenó en esas variables en el el momento de la llamada.

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

Inicializar en cero ambas variables antes de la invocación resuelve este problema, y ahora el código llega la comprobación de fuga de memoria. Por suerte, la comprobación se aprueba con éxito, lo que indica que no tenemos fugas en este códec.

Problemas con el estado compartido

... ¿O no?

Sabemos que las vinculaciones de nuestro códec almacenan parte del estado y da como resultado imágenes estáticas globales variables, y MozJPEG tiene algunas estructuras 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é pasa si algunos de ellos se inicializan de forma diferida en la primera ejecución y, luego, se reutilizan de forma inadecuada en una etapa futura ? Entonces, una sola llamada con un limpiador no los informaría como problemáticas.

Intentemos procesar la imagen un par de veces haciendo clic de forma aleatoria en diferentes niveles de calidad. en la IU. De hecho, ahora obtenemos el siguiente informe:

Captura de pantalla de un mensaje

262,144 bytes: Parece que se filtró la imagen de muestra completa de jpeg_finish_compress.

Después de consultar los documentos y los ejemplos oficiales, resulta que jpeg_finish_compress no libera la memoria asignada por la llamada anterior a jpeg_mem_dest; solo libera la de compresión, aunque esta ya conozca la memoria destino... Ay.

Para solucionar 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 cazando esos errores de memoria uno por uno, pero creo que por ahora está lo suficientemente claro como para el enfoque actual de administración de la memoria genera algunos problemas sistemáticos desagradables.

El desinfectante puede infectar algunos de ellos de inmediato. Otras requieren trucos complejos para ser atrapados. Por último, hay problemas como al principio de la publicación, que, como podemos ver en los registros, no se enganchen el desinfectante. Esto se debe a que el uso inadecuado real ocurre Lado de JavaScript, dentro del cual el limpiador no tiene visibilidad. Esos problemas aparecerán solo en producción o después de cambios en el código que no se relacionan en el futuro.

Compila un wrapper seguro

Retrocedamos un par de pasos y, en su lugar, corrijamos todos estos problemas reestructurando el código. de una forma más segura. Usaré el wrapper de ImageQuant como ejemplo, pero se aplican reglas de refactorización similares a todos los códecs, al igual que a otras bases de código similares.

En primer lugar, corrijamos el problema del uso después de la liberación desde el comienzo de la publicación. Para eso, necesitamos para clonar los datos de la vista respaldada por WebAssembly antes de marcarlos como libres en 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. Esta solucionarán algunos de los problemas que ya vimos y facilitarán el uso de en un entorno multiproceso.

Para ello, refactorizamos el wrapper de C++ para asegurarnos de que cada llamada a la función administre su propio con variables locales. Luego, podemos cambiar la firma de nuestra función free_result por acepta 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, dado que ya usamos Embind en Emscripten para interactuar con JavaScript, también podríamos haz que la API sea aún más segura ocultando por completo los detalles de administración de memoria C++.

Para ello, pasemos 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 mostrar 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;
}

Observa que, con un solo cambio, ambos nos aseguramos de que el array de bytes resultante sea propiedad de JavaScript. que no estén respaldadas por la memoria de WebAssembly, y debes deshacerte del wrapper RawImage que se filtró antes también.

Ahora, JavaScript ya no tiene que preocuparse por liberar datos, y puede usar el resultado como Cualquier otro objeto recolectado como elemento no utilizado:

  // 

  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, realicé algunas mejoras menores adicionales al código del wrapper de ImageQuant y replican correcciones de administración de memoria similares para otros códecs. Si deseas obtener más detalles, Puedes ver la solicitud de extracción resultante aquí: Correcciones de memoria para C++. códecs.

Conclusiones

¿Qué lecciones podemos aprender y compartir de esta refactorización que podrían aplicarse a otras bases de código?

  • No uses vistas de memoria respaldadas por WebAssembly, independientemente del lenguaje en el que se compiló, más allá de un una sola invocación. No puedes confiar en que sobrevivirán más que eso, y no podrás para detectar estos errores por medios convencionales, así que, si necesitas almacenar los datos para más tarde, cópialos JavaScript y almacenarlo 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 errores en JavaScript → WebAssembly pero al menos reducirá la superficie de errores que el código de lenguaje estático incluye.
  • Sin importar el lenguaje que uses, ejecuta código con limpiadores durante el desarrollo, ya que pueden ayudarte a detectar no solo problemas en el código de lenguaje estático, sino también algunos problemas en JavaScript → Límite de WebAssembly, como olvidar llamar a .delete() o pasar punteros no válidos de el lado de JavaScript.
  • Si es posible, evita por completo la exposición de datos y objetos no administrados de WebAssembly a JavaScript. JavaScript es un lenguaje con recolección de elementos no utilizados, y no es común la administración manual de la memoria. Esto puede considerarse una fuga de abstracción del modelo de memoria del lenguaje de tu WebAssembly. y la administración incorrecta es fácil de pasar por alto 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 global variables. No querrás depurar problemas con su reutilización en varias invocaciones ni por lo que es mejor mantenerlo lo más independiente posible.