Embora o JavaScript seja bastante tolerante em limpar a si mesmo, as linguagens estáticas definitivamente não são…
O Squoosh.app é uma 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.
A capacidade de portar o código de ecossistemas existentes é extremamente valiosa, mas há algumas diferenças importantes entre essas linguagens estáticas e o JavaScript. Uma delas é nas diferentes abordagens de gerenciamento de memória.
Embora o JavaScript seja bastante tolerante em limpar a si mesmo, essas linguagens estáticas definitivamente 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 (ou melhor, 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 com suporte do buffer de memória do WebAssembly (Wasm), com byteOffset
e byteLength
definidos como o ponteiro e o comprimento fornecidos. 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 o WebAssembly.Memory
é criado pela API JavaScript ou pela instrução
memory.grow
correspondente, ele invalida o ArrayBuffer
existente e, por extensão, todas as visualizações
apoiadas por ele.
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 ponteiros e memória. Para usá-lo, precisamos recompilar o 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 ativa 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:
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:
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á 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.
Descobrir mais problemas com desinfetantes
A criação de outros codecs do Squoosh com limpadores revela problemas semelhantes e alguns novos. Por exemplo, recebi este erro nas vinculações do MozJPEG:
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 resultados 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 informaria 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:
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);
}
Poderia continuar procurando esses bugs de memória um por um, mas acho que já está claro que a abordagem atual de 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
Vamos voltar um pouco e corrigir todos esses problemas reestruturando o código de uma maneira 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 sem custo financeiro 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 seja apoiada pela memória do WebAssembly, e também se livra do wrapper RawImage
que vazou
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. Não é recomendável depurar problemas com a reutilização em várias invocações ou mesmo em linhas de execução. Portanto, é melhor manter o método o mais independente possível.