Отладка утечек памяти в WebAssembly с помощью Emscripten

В то время как JavaScript довольно щаден в уборке за собой, статические языки определенно не…

Squoosh.app — это PWA, которое показывает, насколько различные кодеки и настройки изображений могут улучшить размер файла изображения без существенного влияния на качество. Однако это также техническая демонстрация, демонстрирующая, как можно использовать библиотеки, написанные на C++ или Rust, и разместить их в Интернете.

Возможность портировать код из существующих экосистем невероятно ценна, но между этими статическими языками и JavaScript есть некоторые ключевые различия. Одним из них являются разные подходы к управлению памятью.

В то время как JavaScript довольно щаден в уборке после себя, такие статические языки определенно не таковы. Вам нужно явно запросить новую выделенную память, и вам действительно нужно убедиться, что вы потом вернете ее и никогда больше не используете. Если этого не произойдет, возникнут утечки… и на самом деле это происходит довольно регулярно. Давайте посмотрим, как можно отладить эти утечки памяти и, что еще лучше, как спроектировать код так, чтобы избежать их в следующий раз.

Подозрительный образец

Недавно, приступая к работе над Squoosh, я не мог не заметить интересную закономерность в оболочках кодеков C++. Давайте рассмотрим в качестве примера оболочку ImageQuant (уменьшенную, чтобы показать только части создания и освобождения объекта):

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

Вы заметили проблему? Подсказка: это use-after-free , но на JavaScript!

В Emscripten typed_memory_view возвращает JavaScript Uint8Array поддерживаемый буфером памяти WebAssembly (Wasm), с byteOffset и byteLength установленными на заданный указатель и длину. Суть в том, что это представление TypedArray в буфере памяти WebAssembly, а не копия данных, принадлежащая JavaScript.

Когда мы вызываем free_result из JavaScript, он, в свою очередь, вызывает стандартную функцию C free чтобы пометить эту память как доступную для любых будущих выделений, что означает, что данные, на которые указывает наше представление Uint8Array , могут быть перезаписаны произвольными данными при любом будущем вызове. в Васм.

Или какая-то реализация free может даже решить немедленно заполнить освобожденную память нулями. free версия, которую использует Emscripten, не делает этого, но мы полагаемся на детали реализации, которые не могут быть гарантированы.

Или, даже если память, находящаяся за указателем, сохранится, может потребоваться новое выделение для увеличения памяти WebAssembly. Когда WebAssembly.Memory увеличивается либо с помощью API JavaScript, либо с помощью соответствующей инструкции memory.grow , он делает недействительным существующий ArrayBuffer и, транзитивно, любые представления, поддерживаемые им.

Позвольте мне использовать консоль DevTools (или Node.js), чтобы продемонстрировать такое поведение:

> 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

Наконец, даже если мы не будем снова явно вызывать Wasm между free_result и new Uint8ClampedArray , в какой-то момент мы можем добавить поддержку многопоточности в наши кодеки. В этом случае это может быть совершенно другой поток, который перезаписывает данные непосредственно перед тем, как нам удастся их клонировать.

Ищем ошибки памяти

На всякий случай я решил пойти дальше и проверить, не вызывает ли этот код каких-либо проблем на практике. Кажется, это прекрасная возможность опробовать новую (почти) поддержку дезинфицирующих средств Emscripten , которая была добавлена ​​в прошлом году и представлена ​​в нашем докладе о WebAssembly на саммите Chrome Dev:

В данном случае нас интересует AddressSanitizer , который может обнаруживать различные проблемы, связанные с указателями и памятью. Чтобы использовать его, нам нужно перекомпилировать наш кодек с -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

Это автоматически включит проверку безопасности указателя, но мы также хотим найти потенциальные утечки памяти. Поскольку мы используем ImageQuant как библиотеку, а не программу, не существует «точки выхода», в которой Emscripten мог бы автоматически проверить, что вся память освобождена.

Вместо этого для таких случаев LeakSanitizer (включенный в AddressSanitizer) предоставляет функции __lsan_do_leak_check и __lsan_do_recoverable_leak_check , которые можно вызывать вручную всякий раз, когда мы ожидаем освобождения всей памяти и хотим проверить это предположение. __lsan_do_leak_check предназначен для использования в конце работающего приложения, когда вы хотите прервать процесс в случае обнаружения каких-либо утечек, а __lsan_do_recoverable_leak_check больше подходит для таких случаев использования библиотеки, как наш, когда вы хотите вывести утечки на консоль , но продолжайте работу приложения в любом случае.

Давайте откроем этот второй помощник через Embind, чтобы мы могли вызывать его из 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);
}

И вызовите его со стороны JavaScript, как только мы закончим с изображением. Выполнение этого со стороны JavaScript, а не со стороны C++, помогает гарантировать, что все области были закрыты и все временные объекты C++ были освобождены к моменту запуска этих проверок:

  // 

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

Это дает нам отчет, подобный следующему в консоли:

Скриншот сообщения

Ой-ой, есть небольшие утечки, но трассировка стека не очень полезна, поскольку все имена функций искажены. Давайте перекомпилируем их с базовой отладочной информацией, чтобы сохранить их:

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

Это выглядит намного лучше:

Снимок экрана: сообщение «Прямая утечка 12 байтов», исходящее от функции GenericBindingType RawImage::toWireType

Некоторые части трассировки стека по-прежнему выглядят неясными, поскольку указывают на внутренние компоненты Emscripten, но мы можем сказать, что утечка происходит из-за преобразования RawImage в «тип провода» (в значение JavaScript) с помощью Embind. Действительно, когда мы смотрим на код, мы видим, что мы возвращаем экземпляры RawImage C++ в JavaScript, но никогда не освобождаем их ни с одной стороны.

Напоминаем, что на данный момент интеграции сборки мусора между JavaScript и WebAssembly нет, хотя она разрабатывается . Вместо этого вам придется вручную освободить всю память и вызвать деструкторы со стороны JavaScript, как только вы закончите работу с объектом. В частности, для Embind в официальной документации предлагается вызывать метод .delete() для открытых классов C++:

Код JavaScript должен явно удалять все полученные дескрипторы объектов C++, иначе куча Emscripten будет расти бесконечно.

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

Действительно, когда мы делаем это в JavaScript для нашего класса:

  // 

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

Утечка исчезла, как и ожидалось.

Обнаружение дополнительных проблем с дезинфицирующими средствами

Создание других кодеков Squoosh с использованием дезинфицирующих средств обнаруживает как схожие, так и некоторые новые проблемы. Например, у меня есть эта ошибка в привязках MozJPEG:

Скриншот сообщения

Здесь не утечка, а запись в память за пределами выделенных границ 😱

Копаясь в коде MozJPEG, мы обнаруживаем, что проблема здесь в том, что jpeg_mem_dest — функция, которую мы используем для выделения места назначения памяти для JPEG — повторно использует существующие значения outbuffer и outsize , если они не равны нулю :

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

Однако мы вызываем его, не инициализируя ни одну из этих переменных, а это означает, что MozJPEG записывает результат в потенциально случайный адрес памяти, который оказался сохранен в этих переменных во время вызова!

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

Обнуление обеих переменных перед вызовом решает эту проблему, и теперь вместо этого код достигает проверки утечки памяти. К счастью, проверка проходит успешно, что свидетельствует об отсутствии утечек в этом кодеке.

Проблемы с общим состоянием

…Или мы?

Мы знаем, что привязки наших кодеков хранят часть состояния, а также результаты в глобальных статических переменных, а MozJPEG имеет некоторые особенно сложные структуры.

uint8_t* last_result;
struct jpeg_compress_struct cinfo;

val encode(std::string image_in, int image_width, int image_height, MozJpegOptions opts) {
  // …
}

Что, если некоторые из них будут лениво инициализированы при первом запуске, а затем неправильно повторно использованы при последующих запусках? Тогда один вызов дезинфицирующего средства не сообщал бы о них как о проблемах.

Давайте попробуем обработать изображение пару раз, случайным образом щелкнув разные уровни качества в пользовательском интерфейсе. Действительно, теперь мы получаем следующий отчет:

Скриншот сообщения

262 144 байта — похоже, весь образец изображения утек из jpeg_finish_compress !

После изучения документации и официальных примеров выяснилось, что jpeg_finish_compress не освобождает память, выделенную нашим предыдущим вызовом jpeg_mem_dest — он освобождает только структуру сжатия, хотя эта структура сжатия уже знает о месте назначения нашей памяти… Эх.

Мы можем это исправить, освободив данные вручную в функции 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);
}

Я мог бы продолжать искать эти ошибки в памяти одну за другой, но думаю, теперь уже достаточно ясно, что нынешний подход к управлению памятью приводит к некоторым неприятным систематическим проблемам.

Некоторые из них могут быть сразу же пойманы дезинфицирующим средством. Чтобы поймать других, требуются сложные трюки. Наконец, есть проблемы, подобные описанным в начале статьи, которые, как мы видим из журналов, вообще не обнаруживаются дезинфицирующим средством. Причина в том, что фактическое неправильное использование происходит на стороне JavaScript, которую дезинфицирующее средство не видит. Эти проблемы проявятся только в рабочей среде или после, казалось бы, несвязанных изменений кода в будущем.

Создание безопасной оболочки

Давайте сделаем пару шагов назад и вместо этого исправим все эти проблемы, реструктурировав код более безопасным способом. Я снова буду использовать оболочку ImageQuant в качестве примера, но аналогичные правила рефакторинга применимы ко всем кодекам, а также к другим подобным базам кода.

Прежде всего, давайте исправим проблему использования после освобождения из начала статьи. Для этого нам нужно клонировать данные из представления, поддерживаемого WebAssembly, прежде чем пометить их как свободные на стороне 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;
}

Теперь давайте удостоверимся, что мы не разделяем состояние глобальных переменных между вызовами. Это исправит некоторые проблемы, с которыми мы уже столкнулись, а также облегчит использование наших кодеков в многопоточной среде в будущем.

Для этого мы реорганизуем оболочку C++, чтобы гарантировать, что каждый вызов функции управляет собственными данными с использованием локальных переменных. Затем мы можем изменить сигнатуру нашей функции free_result , чтобы она принимала указатель обратно:

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

Но поскольку мы уже используем Embind в Emscripten для взаимодействия с JavaScript, мы могли бы сделать API еще безопаснее, полностью скрыв детали управления памятью C++!

Для этого давайте переместим new Uint8ClampedArray(…) из JavaScript на сторону C++ с помощью Embind. Затем мы можем использовать его для клонирования данных в память JavaScript еще до возврата из функции:

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

Обратите внимание, как одним изменением мы гарантируем, что результирующий массив байтов будет принадлежать JavaScript и не будет поддерживаться памятью WebAssembly, а также избавимся от ранее утекшей оболочки RawImage .

Теперь JavaScript вообще не нужно беспокоиться об освобождении данных, и он может использовать результат, как любой другой объект, собирающий мусор:

  // 

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

Это также означает, что нам больше не нужна специальная привязка free_result на стороне 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());
}

В целом, наш код-обертка стал одновременно и чище, и безопаснее.

После этого я внес некоторые дальнейшие незначительные улучшения в код оболочки ImageQuant и повторил аналогичные исправления управления памятью для других кодеков. Если вас интересуют подробности, вы можете посмотреть получившийся PR здесь: Исправления памяти для кодеков C++ .

Вынос

Какие уроки мы можем извлечь из этого рефакторинга и поделиться ими, чтобы их можно было применить к другим базам кода?

  • Не используйте представления памяти, поддерживаемые WebAssembly (независимо от того, на каком языке они созданы), кроме одного вызова. Вы не можете рассчитывать на то, что они проживут дольше этого срока, и вы не сможете обнаружить эти ошибки обычными способами, поэтому, если вам нужно сохранить данные для последующего использования, скопируйте их на сторону JavaScript и сохраните там.
  • Если возможно, используйте безопасный язык управления памятью или, по крайней мере, безопасные оболочки типов вместо прямой работы с необработанными указателями. Это не избавит вас от ошибок на границе JavaScript ↔ WebAssembly, но, по крайней мере, уменьшит вероятность ошибок, содержащихся в статическом коде языка.
  • Независимо от того, какой язык вы используете, запускайте код с дезинфицирующими средствами во время разработки — они могут помочь выявить не только проблемы в коде статического языка, но и некоторые проблемы на границе JavaScript ↔ WebAssembly, такие как забывание вызова .delete() или передача в недопустимых указателях со стороны JavaScript.
  • Если возможно, вообще избегайте раскрытия неуправляемых данных и объектов из WebAssembly в JavaScript. JavaScript — это язык со сборкой мусора, и ручное управление памятью в нем не распространено. Это можно рассматривать как утечку абстракции модели памяти языка, на котором была создана ваша WebAssembly, а неправильное управление легко не заметить в базе кода JavaScript.
  • Это может быть очевидно, но, как и в любой другой базе кода, избегайте хранения изменяемого состояния в глобальных переменных. Вы не хотите отлаживать проблемы с его повторным использованием в различных вызовах или даже потоках, поэтому лучше сохранить его как можно более автономным.