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_result
a new
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
i __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:
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:
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:
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 outbuffer
i outsize
, 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:
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ść.