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

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

Ингвар Степанян
Ingvar Stepanyan

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 , могут быть перезаписаны произвольными данными при любом будущем вызове Wasm.

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

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

Ингвар Степанян
Ingvar Stepanyan

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 , могут быть перезаписаны произвольными данными при любом будущем вызове Wasm.

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

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

Ингвар Степанян
Ingvar Stepanyan

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 , могут быть перезаписаны произвольными данными при любом будущем вызове Wasm.

Или какая-то реализация 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 Summit:

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

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

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

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

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

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 байтов», исходящих от общего числа RAWIMAGE :: функция TowireType

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

В качестве напоминания, в настоящее время между JavaScript и Webassembly не существует интеграции сбора мусора, хотя он разрабатывается . Вместо этого вы должны вручную освободить любую память и вызвать деструкторы со стороны JavaScript, как только вы закончите с объектом. Для конкретного применения официальные документы предлагают вызвать метод .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);
}

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

Для этого давайте перенесем new Uint8ClampedArray(…) от JavaScript в сторону C ++ с ElcInd. Затем мы можем использовать его для клонирования данных в память 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, но, по крайней мере, это уменьшит поверхность для ошибок, автономные статическими языковыми кодом.
  • Независимо от того, какой язык вы используете, запустите код с дезинфицирующими средствами во время разработки - они могут помочь не только выявить не только проблемы в статическом языковом коде, но и некоторые проблемы по всей границе Webassembly, такие как забывание вызовать .delete() или передача недопустимых указателей со стороны JavaScript.
  • Если возможно, не разоблачают неуправляемые данные и объекты от Webassembly до JavaScript. JavaScript-это язык, созданный мусором, и управление памятью ручной памяти не распространено в нем. Это можно считать утечкой абстракции модели памяти языка.
  • Это может быть очевидно, но, как и в любой другой кодовой базе, избегайте хранения изменяемого состояния в глобальных переменных. Вы не хотите отлаживать проблемы с его повторным использованием в различных призыве или даже нити, поэтому лучше сохранить его как можно более автономным.
,

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

ИНГВАР СТАПАНАНАН
Ingvar Stepanyan

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

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

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

Подозрительный рисунок

Недавно, начиная работать над Squoosh, я не мог не заметить интересную шаблон в обертках C ++ Codec. Давайте посмотрим на обертку 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
  );
}

Вы заметите проблему? Подсказка: это без использования , но в JavaScript!

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

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

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

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

Позвольте мне использовать консоль 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 на Summit 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 мог бы автоматически подтвердить, что вся память была освобождена.

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

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

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

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

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

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 байтов», исходящих от общего числа RAWIMAGE :: функция TowireType

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

В качестве напоминания, в настоящее время между JavaScript и Webassembly не существует интеграции сбора мусора, хотя он разрабатывается . Вместо этого вы должны вручную освободить любую память и вызвать деструкторы со стороны JavaScript, как только вы закончите с объектом. Для конкретного применения официальные документы предлагают вызвать метод .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);
}

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

Для этого давайте перенесем new Uint8ClampedArray(…) от JavaScript в сторону C ++ с ElcInd. Затем мы можем использовать его для клонирования данных в память 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, но, по крайней мере, это уменьшит поверхность для ошибок, автономные статическими языковыми кодом.
  • Независимо от того, какой язык вы используете, запустите код с дезинфицирующими средствами во время разработки - они могут помочь не только выявить не только проблемы в статическом языковом коде, но и некоторые проблемы по всей границе Webassembly, такие как забывание вызовать .delete() или передача недопустимых указателей со стороны JavaScript.
  • Если возможно, не разоблачают неуправляемые данные и объекты от Webassembly до JavaScript. JavaScript-это язык, созданный мусором, и управление памятью ручной памяти не распространено в нем. Это можно считать утечкой абстракции модели памяти языка.
  • Это может быть очевидно, но, как и в любой другой кодовой базе, избегайте хранения изменяемого состояния в глобальных переменных. Вы не хотите отлаживать проблемы с его повторным использованием в различных призыве или даже нити, поэтому лучше сохранить его как можно более автономным.