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

Chociaż 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 przenosić biblioteki napisane w C++ lub Rust do przeglądarki.

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++. Na przykładzie opakowania ImageQuant (obniżonego do części tworzenia i zwalniania obiektu) zobaczmy, jak to działa:

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), przy czym byteOffset i byteLength są ustawione na odpowiedni 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 zapeł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żemy 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 rozbudowywany za pomocą interfejsu JavaScript API lub odpowiedniej instrukcji memory.grow, powoduje to unieważnienie istniejącego ArrayBuffer i w drodze substytucji 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ć obsługę wielowątkowalności do naszych kodeków. 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 konferencji 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 narzędzie LeakSanitizer (włączone w ramach narzędzia 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 pozostać uruchomiona.

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 z wiadomością

Mamy tu drobne wycieki pamięci, ale śledzenie wywołań nie jest zbyt pomocne, ponieważ nazwy funkcji są zdeformowane. Aby zachować te informacje, ponownie skompiluj program, dodając podstawowe informacje 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 śledzenia 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 JavaScriptu z usuwaniem elementów z pamięci podręcznej w WebAssembly, ale ta integracja jest 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ę udało, co oznacza, że nie ma żadnych wycieków w tym kodzie.

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 dezynfekcyjnego nie zostałoby zgłoszone jako problem.

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 został utracony z urządzenia 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 bezpieczniejszy 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 z początku posta. W tym celu musimy sklonować dane z widoku obsługiwanego przez WebAssembly, zanim oznaczymy je jako wolne 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 zmiennych globalnych. 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 mieć pewność, że każde wywołanie funkcji będzie zarządzać własnymi 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 jeszcze bardziej 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ą JavaScript i nie była obsługiwana przez pamięć WebAssembly, oraz pozbywamy się też wcześniej przeciekającego opakowania RawImage.

Teraz JavaScript nie musi już martwić się 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 kodu owijacza ImageQuant i powtórzył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 tej refaktoryzacji, co można zastosować 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, i nie będziesz mieć możliwości wychwycenia tych błędów w tradycyjny sposób. Jeśli chcesz przechowywać 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. zapomnienia wywołania .delete() lub przekazania nieprawidłowych wskaźników z JavaScriptu.
  • Jeśli to możliwe, unikaj ujawniania niezarządzanych 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 kodów źródłowych, unikaj 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ść.