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

Chociaż JavaScript w dużym stopniu ustępuje porządkowaniu po sobie, języki statyczne zdecydowanie nie są...

Squoosh.app to aplikacja PWA, która pokazuje, jak różne kodeki i ustawienia obrazu mogą poprawić rozmiar pliku obrazu bez znacznego wpływu na jakość. Jest to jednak również techniczna prezentacja, która pokazuje, jak wprowadzić biblioteki napisane w języku C++ lub Rust do internetu.

Możliwość przenoszenia kodu z istniejących ekosystemów jest niezwykle cenna, ale występują pewne istotne różnice między językami statycznymi a JavaScriptem. Jednym z nich są różne podejścia do zarządzania pamięcią.

JavaScriptu w większym stopniu ustępuje porządkowanie się po sobie, ale takie statyczne języki z pewnością się nie wykluczają. Musisz wyraźnie poprosić o nową przydzieloną pamięć, a potem upewnić się, że ją wykorzystasz i nie używaj ponownie. Jeśli tak się nie stanie, pojawią się wycieki, które zdarzają się dość regularnie. Przyjrzyjmy się, jak można debugować te wycieki pamięci, a co najlepiej jak zaprojektować kod, aby uniknąć takich wycieków w przyszłości.

Podejrzany wzorzec

Ostatnio podczas pracy nad Squooshem zauważyłem interesujący wzorzec w kodeku C++. Spójrzmy na przykładowy kod towarzyszący ImageQuant (zredukowano, aby pokazać tylko części tworzenia obiektów i transakcji lokalizacji):

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 (dobrze, 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 dostrzegasz jakiś problem? Podpowiedź: jest use-po-free, ale w języku JavaScript.

W Emscripten funkcja typed_memory_view zwraca kod Uint8Array JavaScriptu oparty na buforze pamięci WebAssembly (Wasm), gdzie byteOffset i byteLength są ustawione na podany wskaźnik i długość. Głównym problemem jest to, że jest to widok TypedTrack w buforze pamięci WebAssembly, a nie kopia danych należąca do JavaScriptu.

Wywołanie free_result z JavaScriptu wywołuje z kolei standardową funkcję C free, aby oznaczyć tę pamięć jako dostępną na potrzeby przyszłych przydziałów. Oznacza to, że dane, do których wskazuje widok Uint8Array, mogą zostać zastąpione dowolnymi danymi przy użyciu dowolnego przyszłego wywołania Wasm.

W niektórych implementacjach free może się nawet zdarzyć, że wolna pamięć zostanie natychmiast uzupełniona. Interfejs free używany przez Emscripten tego nie umożliwia, ale polegamy na szczegółach implementacji, których nie można zagwarantować.

Nawet jeśli pamięć za wskaźnikiem zostanie zachowana, nowy przydział może wymagać zwiększenia pamięci WebAssembly. Jeśli buduje się WebAssembly.Memory za pomocą interfejsu JavaScript API lub odpowiedniej instrukcji memory.grow, unieważnia istniejącą właściwość ArrayBuffer oraz przechodnie wszystkie obsługiwane przez nią widoki.

Aby zademonstrować to działanie, 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

Nawet jeśli między free_result a new Uint8ClampedArray nie będziemy bezpośrednio wywoływać Wasm, w którymś momencie możemy dodać obsługę wielowątkowości do naszych kodeków. W takim przypadku może to być zupełnie inny wątek, który zastąpi dane tuż przed ich sklonowaniem.

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

Na wszelki wypadek zdecydowaliśmy się sprawdzić, czy kod wykazuje jakieś problemy w praktyce. Myślę, że to doskonała okazja, by wypróbować nową pomoc Emscripten sanitizers, która została dodana w zeszłym roku i przedstawiona podczas prezentacji WebAssembly podczas Chrome Dev Summit:

W tym przypadku interesuje nas narzędzie AddressSanitizer, które może wykrywać różne problemy ze wskaźnikami i pamięcią. Aby go użyć, musimy ponownie skompilować nasz kodek za pomocą polecenia -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źników, ale chcemy też wykrywać potencjalne wycieki pamięci. Używamy ImageQuant jako biblioteki, a nie programu, więc nie ma „punktu wyjścia”, w którym Emscripten mógłby automatycznie sprawdzić, czy cała pamięć została zwolniona.

Zamiast tego w takich przypadkach LeakSanitizer (zawarty w narzędziu AddressSanitizer) udostępnia funkcje __lsan_do_leak_check i __lsan_do_recoverable_leak_check, które można wywołać ręcznie za każdym razem, gdy oczekujemy zwolnienia całej pamięci i chcemy to sprawdzić. Z usługi __lsan_do_leak_check należy korzystać na końcu działającej aplikacji, gdy chcesz przerwać proces w przypadku wykrycia wycieków. Z kolei __lsan_do_recoverable_leak_check lepiej nadaje się do zastosowań bibliotecznych, takich jak nasz, gdy chcesz drukować wycieki w konsoli, ale aplikacja powinna nadal działać.

Ujawnijmy ten drugi obiekt pomocniczy za pomocą Embind, abyśmy mogli go w każdej chwili 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);
}

Wywołaj ją po stronie JavaScriptu, gdy zakończymy pracę z obrazem. Jeśli zrobisz to po stronie JavaScriptu, a nie z C++, będziesz mieć pewność, że wszystkie zakresy są zamknięte, a wszystkie tymczasowe obiekty C++ są zwolnione do czasu przeprowadzenia tych testów:

  // …

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

Wyświetli się raport podobny do tego w konsoli:

Zrzut ekranu z wiadomością

Pojawiło się kilka drobnych wycieków, ale zrzut stosu nie jest zbyt przydatny, ponieważ wszystkie nazwy funkcji są zniekształcone. Aby je zachować, spróbujmy ponownie skompilować podstawowe dane 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 z komunikatem „Bezpośredni wyciek 12 bajtów” pochodzący z funkcji OgólneBindingType RawImage ::toWireType

Niektóre części zrzutu stosu wciąż wyglądają na niejasno, ponieważ wskazują na elementy wewnętrzne Emscripten, ale możemy stwierdzić, że wyciek wynika z konwersji RawImage na „typ przewodu” (wartości JavaScript) w ramach usługi Embind. Gdy przyjrzymy się kodowi, widzimy, że zwracamy do JavaScriptu RawImage instancje C++, ale nigdy nie zostaje on wolny po obu stronach.

Przypominamy, że obecnie nie ma możliwości integracji funkcji czyszczenia pamięci między JavaScriptem a WebAssembly, ale pracujemy nad taką funkcją. Zamiast tego musisz ręcznie zwolnić pamięć i wywołać destrukcje po stronie JavaScriptu, gdy skończysz pracę z obiektem. Jeśli chodzi o Embind, oficjalne dokumenty zalecają wywoływanie metody .delete() w przypadku ujawnionych klas C++:

Kod JavaScript musi jawnie usunąć wszystkie otrzymane uchwyty obiektów C++. W przeciwnym razie sterty Emscripten rozszerzą się w nieskończoność.

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

Jeśli robimy to w JavaScript dla naszej klasy:

  // …

  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 zniknie zgodnie z oczekiwaniami.

Wykrywanie dodatkowych problemów ze środkami dezynfekującymi

Budowanie innych kodeków Squoosh z użyciem środków dezynfekujących ujawnia zarówno podobne, jak i nowe problemy. Oto przykład tego błędu w powiązaniach MozJPEG:

Zrzut ekranu z wiadomością

Tutaj nie chodzi o przeciek, tylko o to, że zapisujemy wspomnienie poza wyznaczonymi granicami 😱

W kodzie MozJPEG stwierdzamy, że problem polega na tym, że jpeg_mem_dest – funkcja, której używamy do przydzielania miejsca docelowego pamięci dla pliku JPEG – wykorzystuje ponownie istniejące wartości outbuffer i outsize, gdy mają wartość różną od zera:

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

Wywołujemy ją jednak bez zainicjowania którejś z tych zmiennych, co oznacza, że MozJPEG zapisuje wynik w potencjalnie losowym adresie pamięci, który był przechowywany w tych zmiennych w momencie wywołania.

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

Zero inicjowania obu zmiennych, zanim wywołanie rozwiąże ten problem. Teraz kod przechodzi zamiast tego do sprawdzenia wycieku pamięci. Na szczęście kontrola przebiegła pomyślnie, co oznacza, że w tym kodeku nie ma żadnych wycieków.

Problemy ze stanem udostępniania

...A może my?

Wiemy, że nasze wiązania kodeków przechowują część informacji o stanie i generują globalne zmienne statyczne, a MozJPEG ma wyjątkowo złożone struktury.

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ą leniwie zainicjowane przy pierwszym uruchomieniu, a potem zostaną nieprawidłowo użyte przy kolejnych uruchomieniach? W takiej sytuacji pojedyncze połączenie z użyciem środka dezynfekującego nie spowoduje zgłoszenia problemu.

Spróbujmy kilka razy przetworzyć ten obraz, losowo klikając w interfejsie różne poziomy jakości. Właśnie teraz otrzymujemy 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ładach okazuje się, że jpeg_finish_compress nie zwalnia pamięci przydzielonej przez nasze wcześniejsze wywołanie jpeg_mem_dest – uwalnia tylko strukturę kompresji, mimo że struktura kompresji wie już o naszym miejscu docelowym pamięci... Westchnienie.

Możemy to naprawić, ręcznie zwalniają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);
}

Mógłbym cały czas szukać pojedynczych błędów związanych z pamięcią, ale myślę, że już widać, że obecne podejście do zarządzania pamięcią prowadzi do nieprzyjemnych, systematycznych problemów.

Niektóre z nich mogą zostać natychmiast przechwycone przez środek dezynfekujący. Inne wymagają skomplikowanych sztuczek. Istnieją również problemy, takie jak na przykład na początku posta, które, jak wynika z dzienników, w ogóle nie zostały wychwycone przez aplikację sanitizer. Dzieje się tak, ponieważ faktyczne nadużycie odbywa się po stronie JavaScriptu, gdzie narzędzie sanitizer nie ma wglądu. Problemy te ujawnią się tylko w wersji produkcyjnej lub po pozornie niepowiązanych zmianach w kodzie w przyszłości.

Tworzenie bezpiecznego kodu

Cofnijmy się o kilka kroków i rozwiążmy wszystkie problemy, przebudowując kod w bezpieczniejszy sposób. Jako przykładu użyję otoki ImageQuant, ale podobne reguły refaktoryzacji mają zastosowanie do wszystkich kodeków i innych podobnych baz kodu.

Zacznijmy od początku tego posta. W tym celu musimy skopiować dane z widoku obsługiwanego przez WebAssembly, zanim oznaczymy je jako wolne po stronie JavaScriptu:

  // …

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

Upewnijmy się teraz, że między wywołaniami nie ma wspólnego stanu w zmiennych globalnych. Usunie to część problemów, które już zaobserwowaliśmy, i ułatwi używanie naszych kodeków w środowisku wielowątkowym w przyszłości.

W tym celu refaktoryzujemy opakowanie C++, aby mieć pewność, że każde wywołanie funkcji zarządza własnymi danymi z wykorzystaniem zmiennych lokalnych. Następnie możemy zmienić podpis funkcji free_result, aby z powrotem zaakceptować wskaźnik:

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 już używamy Embind w Emscripten do interakcji z JavaScriptem, możemy jeszcze bardziej zwiększyć bezpieczeństwo interfejsu API, ukrywając szczegóły zarządzania pamięcią C++.

Aby to zrobić, przenieśmy część new Uint8ClampedArray(…) z JavaScriptu na stronę C++ i użyjmy Embind. Następnie możemy go sklonować do pamięci JavaScriptu nawet przed zwrotem z 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;
}

Zobacz, jak w ramach jednej zmiany upewniamy się, że wynikowa tablica bajtów należy do JavaScriptu, a nie jest obsługiwana przez pamięć WebAssembly, oraz pozbywa się ujawnionego wcześniej otoki RawImage.

Teraz JavaScript nie musi już martwić się o uwalnianie danych i może korzystać z wyników tak samo jak w przypadku każdego innego obiektu „odgarniającego pamięć”:

  // …

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

W rezultacie nasz kod towarzyszący stał się jednocześnie bardziej przejrzysty i bezpieczniejszy.

Wprowadziłam dalsze drobne ulepszenia w kodzie otoki ImageQuant i powielałam podobne poprawki zarządzania pamięcią dla innych kodeków. Więcej informacji o wynikach PR znajdziesz tutaj: Poprawki pamięci dla kodeków C++.

Odebranie krążka

Jakie wnioski można wyciągnąć z tej refaktoryzacji i jakie można wykorzystać na innych bazach kodu?

  • Nie używaj widoków pamięci opartych na WebAssembly – niezależnie od języka, w którym zostały utworzone – oprócz pojedynczego wywołania. Nie mogą one przetrwać dłużej i nie da się wychwycić tych błędów za pomocą konwencjonalnych metod, więc jeśli chcesz zapisać dane na później, skopiuj je i zapisz w polu JavaScript.
  • W miarę możliwości używaj bezpiecznego języka zarządzania pamięcią lub, co najmniej bezpiecznych kodów, zamiast korzystać z bezpośredniego działania na nieprzetworzonych wskaźnikach. Nie uchroni Cię to przed błędami na granicy JavaScriptu ↔ WebAssembly, a przynajmniej zmniejszy powierzchnię dla błędów niezależnych przez statyczny kod języka.
  • Niezależnie od tego, którego języka używasz, w czasie programowania uruchom kod za pomocą narzędzi sanitizer. Pomogą one wykryć nie tylko problemy w statycznym kodzie języka, ale też pewne problemy na granicy języka JavaScript ✈ WebAssembly, np. zapominanie wywołania .delete() lub przekazywanie nieprawidłowych wskaźników po stronie JavaScriptu.
  • W miarę możliwości unikaj udostępniania niezarządzanych danych i obiektów z WebAssembly do JavaScriptu. JavaScript jest językiem zbierającym śmieci i rzadko zarządza się w nim ręcznie. Można to uznać za abstrakcyjny wyciek modelu pamięci języka, z którego zbudowano WebAssembly, a niewłaściwe zarządzanie łatwo przeoczyć w bazie kodu JavaScript.
  • Może to być oczywiste, ale tak jak w przypadku każdej innej bazy kodu, unikaj zapisywania zmiennych stanów w zmiennych globalnych. Nie warto debugować problemów z ich ponownym użyciem w różnych wywołaniach, a nawet w wątkach, więc staraj się, by były one jak najbardziej samodzielne.