Debugowanie wycieków pamięci w WebAssembly za pomocą Emscripten

Mimo że JavaScript jest dość łagodny w czyszczeniu po sobie, języki statyczne zdecydowanie nie są…

Squoosh.app to PWA, która pokazuje, jak bardzo różne kodeki obrazów i ustawienia mogą zmniejszać rozmiar pliku obrazu bez znacznego wpływu na jakość. Jest to jednak też demonstracja techniczna pokazująca, jak można przekształcić biblioteki napisane w C++ lub Rust w biblioteki internetowe.

Możliwość przenoszenia kodu z dotychczasowych środowisk jest niezwykle cenna, ale istnieją pewne kluczowe różnice między tymi językami statycznymi a JavaScriptem. Jednym z nich jest odmienne podejście do zarządzania pamięcią.

Chociaż JavaScript jest dość łagodny w czyszczeniu po sobie, takie języki statyczne zdecydowanie nie są. Musisz wyraźnie poprosić o nowo przydzieloną pamięć i zadbać o to, aby ją zwrócić i nigdy więcej jej nie używać. Jeśli tak się nie stanie, dochodzi do wycieków. Zdarza się to dość często. Przyjrzyjmy się, jak debugować wycieki pamięci i jak zaprojektować kod, aby uniknąć ich w przyszłości.

Podejrzany wzorzec

Niedawno, gdy zaczynałem pracować nad Squoosh, zauważyłem ciekawy wzorzec w opakowaniach kodeków C++. Jako przykład zobaczmy opakowanie ImageQuant (obniżone do części tworzenia i zwalniania obiektu):

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

Czy widzisz problem? Wskazówka: to użyj po wygaśnięciu, ale w JavaScript.

W Emscripten typed_memory_view zwraca JavaScript Uint8Array oparty na buforze pamięci WebAssembly (Wasm), z byteOffset i byteLength ustawionymi na określony wskaźnik i długość. Najważniejsze jest to, że jest to widok typu TypedArray w buforze pamięci WebAssembly, a nie kopia danych należąca do JavaScript.

Gdy wywołujemy free_result z JavaScriptu, wywołuje on standardową funkcję C free, aby oznaczyć tę pamięć jako dostępną dla wszystkich przyszłych alokacji, co oznacza, że dane, na które wskazuje widok Uint8Array, mogą zostać zastąpione dowolnymi danymi przez dowolne przyszłe wywołanie Wasm.

Niektóre implementacje free mogą nawet zdecydować się na natychmiastowe wypełnienie zwolnionej pamięci zerami o wartości 0. free używana przez Emscripten nie robi tego, ale polegamy tu na szczegółach implementacji, których nie można zagwarantować.

Nawet jeśli pamięć związana z wskaźnikiem zostanie zachowana, nowe przydzielenie może wymagać zwiększenia pamięci WebAssembly. Gdy WebAssembly.Memory jest rozszerzana za pomocą interfejsu JavaScript API lub odpowiedniej instrukcji memory.grow, powoduje to unieważnienie istniejącego obiektu ArrayBuffer, a w konsekwencji wszystkich widoków, które są przez niego obsługiwane.

Aby zademonstrować to zachowanie, użyję konsoli DevTools (lub 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

Na koniec, nawet jeśli nie wywołamy ponownie Wasm między free_resultnew Uint8ClampedArray, w jakimś momencie możemy dodać do naszych kodeków obsługę wielowątkowości. W tym przypadku może to być zupełnie inny wątek, który nadpisuje dane tuż przed ich sklonowaniem.

Wyszukiwanie błędów związanych z pamięcią

Na wszelki wypadek postanowiliśmy sprawdzić, czy ten kod powoduje jakieś problemy w praktyce. To świetna okazja, aby wypróbować nową(prawie) obsługę walidatorów Emscripten, która została dodana w zeszłym roku i przedstawiona podczas prezentacji WebAssembly na Chrome Dev Summit:

W tym przypadku interesuje nas narzędzie AddressSanitizer, które może wykrywać różne problemy związane z wskaźnikami i pamięcią. Aby go użyć, musimy ponownie skompilować nasz kodek z użyciem -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

Spowoduje to automatyczne włączenie kontroli bezpieczeństwa wskaźnika, ale chcemy też znaleźć potencjalne wycieki pamięci. Ponieważ używamy ImageQuant jako biblioteki, a nie programu, nie ma „punktu wyjścia”, w którym Emscripten mógłby automatycznie sprawdzić, czy cała pamięć została zwolniona.

W takich przypadkach zamiast tego funkcja LeakSanitizer (włączona w ramach funkcji AddressSanitizer) udostępnia funkcje __lsan_do_leak_check__lsan_do_recoverable_leak_check, które można wywołać ręcznie, gdy oczekujemy, że cała pamięć zostanie zwolniona, i chcemy sprawdzić to założenie. Funkcja __lsan_do_leak_check jest przeznaczona do stosowania na końcu uruchomionej aplikacji, gdy chcesz przerwać proces w przypadku wykrycia wycieków pamięci. Funkcja __lsan_do_recoverable_leak_check jest bardziej odpowiednia w przypadku biblioteki, gdy chcesz wydrukować wycieki pamięci na konsoli, ale aplikacja ma nadal działać.

Udostępnimy drugiego pomocnika za pomocą Embind, aby można było go w dowolnym momencie wywołać z JavaScriptu:

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

Gdy skończymy z obrazem, wywołajmy go po stronie JavaScriptu. Wykonywanie tych czynności po stronie JavaScriptu, a nie C++, pomaga zapewnić, że wszystkie zakresy zostaną zamknięte, a wszystkie tymczasowe obiekty C++ zostaną zwolnione do czasu wykonania tych kontroli:

  // 

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

W konsoli pojawi się raport podobny do tego:

Zrzut ekranu pokazujący wiadomość

O jej, są tu małe wycieki, ale ślad wywołania nie jest zbyt pomocny, ponieważ wszystkie nazwy funkcji są zdeformowane. Aby zachować te informacje, skompiluj ponownie z podstawowymi informacjami debugowania:

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

Wygląda to znacznie lepiej:

Zrzut ekranu pokazujący komunikat „Direct leak of 12 bytes” („Bezpośrednie wycieknięcie 12 bajtów”) pochodzący z funkcji GenericBindingType RawImage ::toWireType

Niektóre części ścieżki błędów nadal wyglądają niejasno, ponieważ wskazują na wewnętrzne funkcje Emscripten, ale możemy stwierdzić, że wyciek pochodzi z konwersji RawImage na „typ danych” (na wartość JavaScript) przez Embind. Sprawdziliśmy kod i okazało się, że zwracamy instancje RawImage C++ do JavaScriptu, ale nigdy ich nie zwalniamy po żadnej stronie.

Przypominamy, że obecnie nie ma integracji zbierania elementów z pamięci podręcznej między JavaScriptem a WebAssembly, ale jest ona opracowywana. Zamiast tego musisz ręcznie zwolnić pamięć i wywołać destruktory po stronie JavaScript, gdy obiekt przestanie być potrzebny. W przypadku Embind oficjalne dokumenty sugerują wywołanie metody .delete() w eksponowanych klasach C++:

Kod JavaScript musi wyraźnie usuwać wszystkie otrzymane uchwyty obiektów C++, ponieważ w przeciwnym razie stos Emscripten będzie nieustannie rosnąć.

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

Gdy to zrobimy w JavaScript w naszej klasie:

  // 

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

Wyciek znika zgodnie z oczekiwaniami.

wykrywanie kolejnych problemów z płynami do dezynfekcji,

Tworzenie innych kodeków Squoosh z oczyszczaczami ujawnia podobne, ale też nowe problemy. Na przykład w połączeniach MozJPEG wystąpił u mnie ten błąd:

Zrzut ekranu z wiadomością

Tutaj nie ma wycieku, ale piszemy do pamięci poza przydzielonymi granicami 😱

Po zapoznaniu się z kodem MozJPEG stwierdziliśmy, że problem polega na tym, że jpeg_mem_dest (funkcja, której używamy do przydzielenia miejsca docelowego w pamięci dla JPEG) ponownie używa istniejących wartości zmiennych outbufferoutsize, gdy nie są one równe 0:

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

Jednak wywołujemy go bez inicjowania żadnej z tych zmiennych, co oznacza, że MozJPEG zapisuje wynik w potencjalnie losowym adresie pamięci, który w momencie wywołania był przechowywany w tych zmiennych.

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

Inicjowanie obu zmiennych na wartość 0 przed wywołaniem rozwiązuje ten problem, a kod dociera teraz do sprawdzania wycieku pamięci. Na szczęście sprawdzenie się powiodło, co oznacza, że nie ma żadnych wycieków w tym kodeki.

Problemy ze stanem współdzielonym

…Czy jednak?

Wiemy, że nasze łączniki kodeków przechowują część stanu i wyników w globalnych zmiennych statycznych, a MozJPEG ma kilka szczególnie skomplikowanych struktur.

uint8_t* last_result;
struct jpeg_compress_struct cinfo;

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

Co się stanie, jeśli niektóre z nich zostaną zainicjowane leniwie podczas pierwszego uruchomienia, a następnie niewłaściwie użyte w kolejnych? W takim przypadku jednorazowe użycie środka do dezynfekcji nie zostałoby zgłoszone jako problemowe.

Spróbujmy przetworzyć obraz kilka razy, losowo klikając różne poziomy jakości w interfejsie. I faktycznie, otrzymujemy teraz taki raport:

Zrzut ekranu z wiadomością

262 144 bajty – wygląda na to,że cały przykładowy obraz wyciekł z jpeg_finish_compress.

Po sprawdzeniu dokumentacji i oficjalnych przykładów okazało się, że funkcja jpeg_finish_compress nie zwalnia pamięci przydzielonej przez wcześniejsze wywołanie funkcji jpeg_mem_dest – zwalnia tylko strukturę kompresji, mimo że ta struktura już zna miejsce docelowe pamięci… Ach.

Możemy to naprawić, ręcznie uwalniając dane w funkcji 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);
}

Mogę nadal polować na te błędy związane z pamięcią pojedynczo, ale myślę, że już teraz jest jasne, że obecne podejście do zarządzania pamięcią prowadzi do poważnych systemowych problemów.

Niektóre z nich mogą zostać od razu usunięte przez środek dezynfekujący. Inne wymagają skomplikowanych sztuczek. I wreszcie są problemy takie jak na początku posta, które, jak widać z logów, w cale nie są wychwytywane przez funkcję sanitize. Dzieje się tak, ponieważ rzeczywiste niewłaściwe użycie ma miejsce po stronie JavaScript, do której nie ma dostępu. Te problemy będą widoczne tylko w wersji produkcyjnej lub po wprowadzeniu w przyszłości pozornie niezwiązanych ze sobą zmian w kodzie.

Tworzenie bezpiecznego opakowania

Wróćmy o kilka kroków i zamiast tego rozwiążmy wszystkie te problemy, przekształcając kod w bardziej bezpieczny sposób. Ponownie użyję jako przykładu opakowania ImageQuant, ale podobne reguły refaktoryzacji mają zastosowanie do wszystkich kodeków oraz innych podobnych baz kodu.

Najpierw rozwiążmy problem z użyciem po bezpłatnym okresie próbnym na początku posta. W tym celu musimy sklonować dane z widoku obsługiwanego przez WebAssembly, zanim oznaczymy je jako bezpłatne po stronie 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;
}

Upewnij się, że między wywołaniami nie udostępniasz żadnych stanów w globalnych zmiennych. Pozwoli to rozwiązać niektóre z problemów, które już wystąpiły, a także ułatwi w przyszłości korzystanie z naszych kodeków w środowisku wielowątkowym.

W tym celu przerabiamy opakowanie C++, aby każde wywołanie funkcji zarządzało swoimi danymi za pomocą zmiennych lokalnych. Następnie możemy zmienić sygnaturę funkcji free_result, aby przyjmowała wskaźnik z powrotem:

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

Ponieważ jednak używamy już Embind w Emscripten do interakcji z JavaScriptem, możemy dodatkowo zwiększyć bezpieczeństwo interfejsu API, całkowicie ukrywając szczegóły zarządzania pamięcią w C++.

W tym celu przeniesiemy część new Uint8ClampedArray(…) z JavaScriptu do C++ za pomocą Embind. Następnie możemy użyć go do sklonowania danych do pamięci JavaScript nawet przed zwróceniem funkcji:

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

Zwróć uwagę, że dzięki jednej zmianie zapewniamy, aby tablica bajtów była własnością JavaScriptu i nie była obsługiwana przez pamięć WebAssembly, oraz pozbywamy się też wcześniejszego opakowania RawImage.

Teraz JavaScript nie musi się już martwić o zwolnienie danych i może używać wyniku jak dowolnego innego obiektu z zbieraniem elementów zbędnych:

  // 

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

Oznacza to również, że nie potrzebujemy już niestandardowego wiązania free_result po stronie 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());
}

Ogólnie rzecz biorąc, nasz kod opakowania stał się czystszy i bezpieczniejszy.

Następnie wprowadziłem drobne ulepszenia w kodzie oprogramowania ImageQuant i wprowadziłem podobne poprawki zarządzania pamięcią w przypadku innych kodeków. Jeśli chcesz dowiedzieć się więcej, możesz zapoznać się z efektem tego zgłoszenia: Poprawki dotyczące pamięci dla kodeków C++.

Wnioski

Czego możemy się nauczyć i co możemy wykorzystać w przyszłości dzięki temu refaktoringowi? Czy można zastosować go w innych bazach kodu?

  • Nie używaj widoków pamięci obsługiwanych przez WebAssembly (niezależnie od tego, w jakim języku zostały utworzone) poza pojedynczym wywołaniem. Nie możesz polegać na tym, że przetrwają one dłużej, a nie będziesz też w stanie wykryć tych błędów w tradycyjny sposób. Jeśli chcesz zachować dane na później, skopiuj je do strony JavaScript.
  • Zamiast operowania bezpośrednio na nieprzetworzonych wskaźnikach, używaj, jeśli to możliwe, języka z bezpiecznym zarządzaniem pamięcią lub przynajmniej otulenia typu bezpiecznego. Nie uchroni Cię to przed błędami na granicy JavaScriptu i WebAssembly, ale przynajmniej zmniejszy powierzchnię, na której mogą wystąpić błędy w ramach stałego kodu języka.
  • Niezależnie od tego, którego języka używasz, podczas tworzenia kodu korzystaj z sanitatorów. Mogą one pomóc w rozpoznawaniu problemów nie tylko w kodzie języka statycznego, ale też w granicach między JavaScriptem a WebAssembly, np. zapomnienie wywołania .delete() lub przekazanie nieprawidłowych wskaźników z JavaScriptu.
  • Jeśli to możliwe, unikaj ujawniania nieustrukturyzowanych danych i obiektów z WebAssembly do JavaScriptu. Język JavaScript jest językiem z automatycznym usuwaniem elementów, a ręczne zarządzanie pamięcią nie jest w nim powszechne. Może to być spowodowane wyciekiem abstrakcji modelu pamięci języka, na podstawie którego utworzono WebAssembly. Nieprawidłowe zarządzanie jest łatwe do przeoczenia w kodzie JavaScript.
  • To może być oczywiste, ale tak jak w przypadku innych baz kodu, należy unikać przechowywania zmiennych stanów w globalnych zmiennych. Nie chcesz debugować problemów z wielokrotnym użyciem w różnych wywołaniach czy wątkach, dlatego najlepiej jest zachować jak największą niezależność.