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 również techniczna wersja demonstracyjna pokazująca, jak przenieść do sieci biblioteki napisane w języku C++ lub Rust.
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ą.
O ile JavaScript często czyści się po sobie, to jednak na pewno nie. 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. Zobaczmy, jak można debugować te wycieki pamięci, a jeszcze lepiej zaprojektujmy kod tak, aby następnym razem ich uniknąć.
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.
Część implementacji funkcji free
może nawet od razu wyzerować wolną pamięć. Zmienna free
, której używa Emscripten, nie spełnia tego warunku, ale w tym przypadku polegamy 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 rozszerzana za pomocą interfejsu JavaScript API lub odpowiedniej instrukcji memory.grow
, powoduje to unieważnienie istniejącego elementu ArrayBuffer
, a w drodze ukośnej również 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 LeakSanitizer (dostępny w usłudze AddressSanitizer) udostępnia funkcje __lsan_do_leak_check
i __lsan_do_recoverable_leak_check
, które można wywoływać ręcznie, gdy spodziewamy się zwolnić całą pamięć i chcemy zweryfikować 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:
O jej, są tu małe wycieki, ale śledzenie wywołań nie jest zbyt pomocne, ponieważ wszystkie 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. Rzeczywiście, gdy patrzymy na kod, widzimy, że zwracamy do JavaScriptu wystąpienia C++ (RawImage
), ale nigdy nie zwalniamy ich po obu stronach.
Przypominamy, że obecnie nie ma możliwości zintegrowania czyszczenia pamięci między JavaScriptem a WebAssembly, ale taka funkcja jest w trakcie opracowywania. Musisz ręcznie zwolnić pamięć i wywołać destrukcje po stronie JavaScriptu, gdy zakończysz pracę z obiektem. 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 robimy to w JavaScripcie na potrzeby 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 znika zgodnie z oczekiwaniami.
Wykrywanie kolejnych problemów ze środkami 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 kontrola przebiegła pomyślnie, co pokazuje, że kodek nie zawiera żadnych przecieków.
Problemy ze stanem „Współdzielone”
…Czy jednak?
Wiemy, że nasze wiązania kodeków przechowują część stanów oraz wyniki w globalnych zmiennych statycznych, a mozJPEG ma szczególnie skomplikowane 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ą 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. Rzeczywiście otrzymujemy następujący raport:
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ć przechwycone przez środek dezynfekcyjny od razu. Inne wymagają złapania skomplikowanych sztuczek. I wreszcie są problemy takie jak na początku tego posta, które, jak widać z logów, w cale nie są eliminowane przez funkcję Sanitizer. Dzieje się tak, ponieważ faktyczne 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 skopiować dane z widoku obsługiwanego przez WebAssembly, a następnie oznaczyć je jako bezpłatne 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;
}
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 zmodyfikowaliśmy 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 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ś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());
}
Podsumowując, nasz kod otoki stał się jednocześnie bardziej przejrzysty 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
Jakie wnioski można wyciągnąć z tej refaktoryzacji i jakie wnioski można wykorzystać w innych bazach kodu?
- Nie używaj widoków pamięci opartych na technologii WebAssembly – niezależnie od tego, w jakim języku został utworzony – poza jednym 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 bezpośredniej pracy z nieprzetworzonymi wskaźnikami, 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 oczyszczaczy. Mogą one pomóc w wykrywaniu 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. JavaScript to język z zbieraniem elementów z pamięci podręcznej, w którym ręczne zarządzanie pamięcią nie jest powszechne. Można to uznać za abstrakcyjny wyciek modelu pamięci języka, na którym zbudowano WebAssembly, a nieprawidłowe zarządzanie w bazie kodu JavaScriptu można łatwo przeoczyć.
- 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ść.