Como depurar vazamentos de memória no WebAssembly usando o Emscripten

Embora o JavaScript seja bastante tolerante em limpar a si mesmo, as linguagens estáticas definitivamente não são…

O Squoosh.app é um PWA que ilustra como diferentes codecs e configurações de imagem podem melhorar o tamanho do arquivo de imagem sem afetar significativamente a qualidade. No entanto, também é uma demonstração técnica que mostra como é possível usar bibliotecas escritas em C++ ou Rust e levá-las para a Web.

Poder fazer a portabilidade do código dos ecossistemas existentes é incrivelmente valioso, mas há algumas diferenças importantes entre essas linguagens estáticas e o JavaScript. Uma delas é as diferentes abordagens ao gerenciamento de memória.

Embora o JavaScript seja bastante tolerante em limpar a si mesmo, essas linguagens estáticas não são. Você precisa pedir explicitamente uma nova memória alocada e precisa garantir que a memória será devolvida depois e nunca será usada novamente. Se isso não acontecer, você terá vazamentos… e isso acontece com bastante frequência. Vamos analisar como depurar esses vazamentos de memória e, melhor ainda, como projetar seu código para evitá-los na próxima vez.

Padrão suspeito

Recentemente, ao começar a trabalhar no Squoosh, notei um padrão interessante em wrappers de codecs C++. Vamos conferir um wrapper do ImageQuant como exemplo (reduzido para mostrar apenas a criação de objetos e partes de dealocação):

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

Você identificou um problema? Dica: é um uso após a liberação, mas em JavaScript.

No Emscripten, typed_memory_view retorna um Uint8Array JavaScript apoiado pelo buffer de memória WebAssembly (Wasm), com byteOffset e byteLength definidos para o ponteiro e o comprimento especificados. O ponto principal é que essa é uma visualização de TypedArray em um buffer de memória da WebAssembly, em vez de uma cópia dos dados pertencentes ao JavaScript.

Quando chamamos free_result do JavaScript, ele chama uma função C padrão free para marcar essa memória como disponível para alocações futuras, o que significa que os dados para os quais a nossa visualização Uint8Array aponta podem ser substituídos por dados arbitrários por qualquer chamada futura no Wasm.

Ou, algumas implementações de free podem até decidir preencher a memória liberada com zeros imediatamente. O free usado pelo Emscripten não faz isso, mas estamos dependendo de um detalhe de implementação que não pode ser garantido.

Ou, mesmo que a memória por trás do ponteiro seja preservada, pode ser necessário aumentar a memória do WebAssembly com uma nova alocação. Quando a WebAssembly.Memory é aumentada usando a API JavaScript ou a instrução memory.grow correspondente, ela invalida a ArrayBuffer existente e, transitivamente, todas as visualizações apoiadas por ela.

Vamos usar o console do DevTools (ou Node.js) para demonstrar esse comportamento:

> 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 fim, mesmo que não chamemos explicitamente o Wasm entre free_result e new Uint8ClampedArray, podemos adicionar suporte à multithreading aos nossos codecs. Nesse caso, pode ser uma linha de execução completamente diferente que substitui os dados antes que possamos cloná-los.

Procurar bugs de memória

Só para garantir, decidi ir mais longe e verificar se esse código apresenta algum problema na prática. Essa parece uma oportunidade perfeita para testar o novo suporte a limpadores Emscripten, que foi adicionado no ano passado e apresentado na nossa palestra sobre WebAssembly no Chrome Dev Summit:

Neste caso, estamos interessados no AddressSanitizer, que pode detectar vários problemas relacionados a ponteiro e memória. Para usá-lo, precisamos recompilar nosso codec com -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

Isso vai ativar automaticamente as verificações de segurança do ponteiro, mas também queremos encontrar possíveis vazamentos de memória. Como estamos usando o ImageQuant como uma biblioteca em vez de um programa, não há um "ponto de saída" em que o Emscripten possa validar automaticamente que toda a memória foi liberada.

Em vez disso, para esses casos, o LeakSanitizer (incluído no AddressSanitizer) fornece as funções __lsan_do_leak_check e __lsan_do_recoverable_leak_check, que podem ser invocadas manualmente sempre que esperamos que toda a memória seja liberada e queremos validar essa suposição. __lsan_do_leak_check deve ser usado no final de um aplicativo em execução, quando você quer abortar o processo caso algum vazamento seja detectado. Já __lsan_do_recoverable_leak_check é mais adequado para casos de uso de biblioteca, como o nosso, quando você quer imprimir vazamentos no console, mas manter o aplicativo em execução.

Vamos expor esse segundo auxiliar pelo Embind para que possamos chamá-lo do JavaScript a qualquer 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);
}

E invocar do lado do JavaScript quando terminarmos com a imagem. Fazer isso do lado do JavaScript, em vez do C++, ajuda a garantir que todos os escopos tenham sido encerrados e que todos os objetos C++ temporários tenham sido liberados até o momento em que executamos essas verificações:

  // 

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

Isso gera um relatório como este no console:

Captura de tela de uma mensagem

Uh-oh, há alguns vazamentos pequenos, mas o stacktrace não é muito útil, porque todos os nomes de função estão corrompidos. Vamos recompilar com informações básicas de depuração para preservar:

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

Isso está muito melhor:

Captura de tela de uma mensagem que mostra &quot;Vazamento direto de 12 bytes&quot; de uma função RawImage ::toWireType de GenericBindingType.

Algumas partes do stacktrace ainda parecem obscuras, porque apontam para elementos internos do Emscripten, mas podemos afirmar que o vazamento vem de uma conversão RawImage para "tipo de fio" (para um valor JavaScript) pelo Embind. De fato, quando analisamos o código, podemos ver que retornamos instâncias RawImage C++ para JavaScript, mas nunca as liberamos em nenhum dos lados.

Não há atualmente integração de coleta de lixo entre JavaScript e WebAssembly, mas uma está sendo desenvolvida. Em vez disso, você precisa liberar manualmente qualquer memória e chamar destrutores do lado do JavaScript depois de terminar com o objeto. Para Embind especificamente, os documentos oficiais sugerem chamar um método .delete() em classes C++ expostas:

O código JavaScript precisa excluir explicitamente todos os identificadores de objetos C++ que recebeu, ou a pilha do Emscripten vai crescer indefinidamente.

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

De fato, quando fazemos isso em JavaScript para nossa 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
  );
}

O vazamento desaparece conforme esperado.

Descoberta de mais problemas com desinfetantes

Criar outros codecs Squoosh com limpadores revela problemas semelhantes e novos. Por exemplo, recebi este erro nas vinculações do MozJPEG:

Captura de tela de uma mensagem

Aqui, não há vazamento, mas estamos gravando em uma memória fora dos limites alocados 😱

Analisando o código do MozJPEG, descobrimos que o problema é que jpeg_mem_dest, a função que usamos para alocar um destino de memória para JPEG, reutiliza os valores atuais de outbuffer e outsize quando eles são diferentes de zero:

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

No entanto, ele é invocado sem inicializar nenhuma dessas variáveis, o que significa que o MozJPEG grava o resultado em um endereço de memória potencialmente aleatório que foi armazenado nessas variáveis no momento da chamada.

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

A inicialização de zero de ambas as variáveis antes da invocação resolve esse problema, e agora o código chega a uma verificação de vazamento de memória. Felizmente, a verificação é bem-sucedida, indicando que não há vazamentos nesse codec.

Problemas com o estado compartilhado

…Ou será que não?

Sabemos que nossas vinculações de codec armazenam parte do estado e resulta em variáveis estáticas globais, e o MozJPEG tem algumas estruturas 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) {
  // …
}

E se alguns deles forem inicializados de forma lenta na primeira execução e reutilizados incorretamente em execuções futuras? Uma única chamada com um desinfetante não os denunciaria como problemáticos.

Vamos tentar processar a imagem algumas vezes clicando aleatoriamente em diferentes níveis de qualidade na interface. Agora, temos o seguinte relatório:

Captura de tela de uma mensagem

262.144 bytes: parece que toda a imagem de amostra vazou de jpeg_finish_compress.

Depois de conferir os documentos e os exemplos oficiais, descobrimos que jpeg_finish_compress não libera a memória alocada pela nossa chamada jpeg_mem_dest anterior. Ela só libera a estrutura de compactação, mesmo que essa estrutura já saiba sobre nosso destino de memória.

Podemos corrigir isso liberando os dados manualmente na função 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);
}

Eu poderia continuar caçando esses insetos de memória, um por um, mas acho que agora está claro que a abordagem atual do gerenciamento de memória leva a alguns problemas sistemáticos desagradáveis.

Alguns deles podem ser detectados pelo desinfetante imediatamente. Outros exigem truques complicados para serem capturados. Por fim, há problemas como no início da postagem que, como podemos ver nos registros, não são detectados pelo limpador. O motivo é que o uso indevido real acontece no lado do JavaScript, em que o limpador não tem visibilidade. Esses problemas só vão aparecer na produção ou após mudanças aparentemente não relacionadas no código no futuro.

Como criar um wrapper seguro

Agora, vamos voltar um pouco e corrigir todos esses problemas reestruturando o código de forma mais segura. Vou usar o wrapper ImageQuant como exemplo novamente, mas regras de refatoração semelhantes se aplicam a todos os codecs e outras bases de código semelhantes.

Primeiro, vamos corrigir o problema de uso após a liberação do início da postagem. Para isso, precisamos clonar os dados da visualização com suporte do WebAssembly antes de marcá-la como gratuita no lado do 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;
}

Agora, vamos garantir que não compartilhamos nenhum estado em variáveis globais entre invocações. Isso vai corrigir alguns dos problemas que já vimos e facilitar o uso dos codecs em um ambiente multithread no futuro.

Para isso, refatorizamos o wrapper C++ para garantir que cada chamada para a função gerencie os próprios dados usando variáveis locais. Em seguida, podemos mudar a assinatura da nossa função free_result para aceitar o ponteiro:

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

No entanto, como já estamos usando o Embind no Emscripten para interagir com o JavaScript, podemos tornar a API ainda mais segura ocultando completamente os detalhes de gerenciamento de memória do C++.

Para isso, vamos mover a parte new Uint8ClampedArray(…) do JavaScript para o lado do C++ com o Embind. Em seguida, podemos usá-lo para clonar os dados na memória do JavaScript, mesmo antes de retornar da função:

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

Observe como, com uma única mudança, garantimos que a matriz de bytes resultante seja de propriedade do JavaScript e não tenha suporte da memória do WebAssembly, além de eliminar o wrapper RawImage vazado anteriormente.

Agora o JavaScript não precisa mais se preocupar em liberar dados e pode usar o resultado como qualquer outro objeto de coleta de lixo:

  // 

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

Isso também significa que não precisamos mais de uma vinculação free_result personalizada no lado do 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());
}

No geral, nosso código de wrapper ficou mais limpo e seguro ao mesmo tempo.

Depois disso, fiz algumas melhorias menores no código do wrapper do ImageQuant e repliquei correções de gerenciamento de memória semelhantes para outros codecs. Se você quiser mais detalhes, confira a PR resultante aqui: Correções de memória para codecs C++.

Aprendizados

Quais lições podemos aprender e compartilhar com essa refatoração que podem ser aplicadas a outras bases de código?

  • Não use visualizações de memória com suporte do WebAssembly, não importa em qual linguagem ele foi criado, além de uma única invocação. Não é possível contar com a sobrevivência deles por mais tempo que isso, e não será possível detectar esses bugs por meios convencionais. Portanto, se você precisar armazenar os dados para mais tarde, copie-os para o lado do JavaScript e armazene-os lá.
  • Se possível, use uma linguagem segura de gerenciamento de memória ou, pelo menos, wrappers de tipo seguros em vez de operar diretamente em ponteiros brutos. Isso não vai evitar bugs no limite do JavaScript ↔ WebAssembly, mas pelo menos vai reduzir a superfície de bugs independentes do código de linguagem estático.
  • Não importa qual linguagem você usa, execute o código com limpadores durante o desenvolvimento. Eles podem ajudar a detectar não apenas problemas no código da linguagem estática, mas também alguns problemas no limite do JavaScript ↔ WebAssembly, como esquecer de chamar .delete() ou transmitir ponteiros inválidos do lado do JavaScript.
  • Se possível, evite expor dados e objetos não gerenciados do WebAssembly para o JavaScript. O JavaScript é uma linguagem com coleta de lixo, e o gerenciamento manual de memória não é comum nela. Isso pode ser considerado um vazamento de abstração do modelo de memória da linguagem em que o WebAssembly foi criado, e o gerenciamento incorreto é fácil de ignorar em uma base de código JavaScript.
  • Isso pode ser óbvio, mas, como em qualquer outra base de código, evite armazenar o estado mutável em variáveis globais. Você não quer depurar problemas com a reutilização em várias invocações ou até mesmo linhas de execução. Por isso, é melhor mantê-la o mais independente possível.