Emscripten'ı kullanarak WebAssembly'de bellek sızıntılarının hatalarını ayıklama

JavaScript, kendinden sonra temizlik konusunda oldukça affedici olsa da statik diller kesinlikle öyle değildir…

Ingvar Stepanyan
Ingvar Stepanyan

Squoosh.app, kaliteyi önemli ölçüde etkilemeden resim codec'lerinin ve ayarlarının ne kadar farklı resim dosyası boyutunu iyileştirebileceğini gösteren bir PWA'dır. Ancak bu aynı zamanda, C++ veya Rust'ta yazılmış kitaplıkları nasıl alıp web'e getirebileceğinizi gösteren teknik bir demodur.

Mevcut ekosistemlerden kod aktarabilmek son derece değerlidir ancak bu statik diller ile JavaScript arasında bazı önemli farklılıklar vardır. Bunlardan biri, bellek yönetimine yönelik farklı yaklaşımlarıdır.

JavaScript, kendi kendine temizlenmesinde oldukça hoşgörülü olsa da bu tür statik diller kesinlikle kabul edilmez. Yeni bir bellek alanı ayırtmak için açıkça talepte bulunmanız gerekir. Ayrıca, ayırttığınız bellek alanını daha sonra geri vermeniz ve bir daha kullanmamanız gerekir. Aksi takdirde sızıntılar olur. Bu durum aslında oldukça sık yaşanır. Bu bellek sızıntılarının hatalarını nasıl ayıklayabileceğinize ve daha da iyisi, kodunuzu bir dahaki sefere bu tür sızıntıları önlemek için nasıl tasarlayabileceğinize bakalım.

Şüpheli desen

Yakın zamanda Squoosh üzerinde çalışmaya başlarken C++ codec sarmalayıcılarında ilginç bir kalıp fark ettim. Örnek olarak bir ImageQuant sarmalayıcısına bakalım (yalnızca nesne oluşturma ve ayırma bölümlerini göstermek için azaltılmıştır):

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

Bir sorun mu tespit ettiniz? İpucu: Bu, serbest bırakıldıktan sonra kullanma hatası ancak JavaScript'te.

Emscripten'de typed_memory_view, byteOffset ve byteLength belirtilen işaretçi ve uzunluğa ayarlanmış, WebAssembly (Wasm) bellek arabelleği tarafından desteklenen bir JavaScript Uint8Array döndürür. Buradaki temel nokta, bunun verilerin JavaScript'e ait bir kopyası değil, WebAssembly bellek arabelleğine yönelik bir TypedArray görünümü olmasıdır.

JavaScript'ten free_result çağırdığımızda, bu belleği gelecekteki ayırmalar için kullanılabilir olarak işaretlemek için standart bir C işlevi free çağırır. Bu, Uint8Array görünümümüzün işaret ettiği verilerin, ileride Wasm'e yapılacak bir çağrıyla rastgele verilerin üzerine yazılabileceği anlamına gelir.

Hatta bazı free uygulamalarında, boşaltılan bellek hemen sıfırla doldurulmaya karar verilebilir. Emscripten'in kullandığı free bunu yapmaz ancak burada garanti edilemeyecek bir uygulama ayrıntısına güveniyoruz.

Ayrıca, işaretçinin arkasındaki bellek korunsa bile yeni ayırma işleminin WebAssembly belleğini büyütmesi gerekebilir. WebAssembly.Memory, JavaScript API veya ilgili memory.grow talimatı aracılığıyla büyütüldüğünde mevcut ArrayBuffer'i ve onun tarafından desteklenen tüm görünümleri geçersiz kılar.

Bu davranışı göstermek için DevTools (veya Node.js) konsolunu kullanacağım:

> 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

Son olarak, free_result ile new Uint8ClampedArray arasında tekrar Wasm'i açıkça çağırmasak bile bir noktada codec'lerimize çoklu iş parçacığı desteği ekleyebiliriz. Bu durumda, verileri klonlamayı başarmadan hemen önce verilerin üzerine yazan tamamen farklı bir iş parçacığı olabilir.

Bellek hatalarını arama

Her ihtimale karşı, bu kodda pratikte herhangi bir sorun olup olmadığını kontrol etmeye karar verdim. Bu, geçen yıl eklenen ve Chrome Dev Summit'teki WebAssembly konuşmamızda sunduğumuz yeni Emscripten temizleyici desteğini denemek için mükemmel bir fırsat:

Bu örnekte, işaretçi ve bellekle ilgili çeşitli sorunları algılayabilen AddressSanitizer ile ilgileniyoruz. Bunu kullanmak için codec'imizi -fsanitize=address ile yeniden derlememiz gerekir:

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

Bu işlem, işaretçi güvenlik kontrollerini otomatik olarak etkinleştirir ancak olası bellek sızıntılarını da bulmak isteriz. ImageQuant'ı program yerine kitaplık olarak kullandığımızdan, Emscripten'in tüm belleğin serbest bırakıldığını otomatik olarak doğrulayabileceği bir "çıkış noktası" yoktur.

Bunun yerine, bu tür durumlar için LeakSanitizer (AddressSanitizer'a dahildir) __lsan_do_leak_check ve __lsan_do_recoverable_leak_check işlevlerini sağlar. Bu işlevler, tüm belleğin serbest bırakılmasını beklediğimiz ve bu varsayımı doğrulamak istediğimizde manuel olarak çağrılabilir. __lsan_do_leak_check, herhangi bir sızıntı algılanması durumunda işlemi iptal etmek istediğiniz, çalışan bir uygulamanın sonunda kullanılmalıdır. __lsan_do_recoverable_leak_check ise sızıntıları konsola yazdırmak ancak uygulamayı çalışmaya devam ettirmek istediğiniz, bizimki gibi kitaplık kullanım alanları için daha uygundur.

Bu ikinci yardımcıyı Embind aracılığıyla gösterelim. Böylece JavaScript'den istediğimiz zaman çağırabiliriz:

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

Resim işleme işlemi tamamlandıktan sonra JavaScript tarafından çağrılır. Bunu C++ yerine JavaScript tarafından yapmak, bu kontrolleri çalıştırdığımızda tüm kapsamların kapatılmasının ve tüm geçici C++ nesnelerinin serbest bırakılmasının sağlanmasına yardımcı olur:

  // 

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

Bu işlem, konsolda aşağıdaki gibi bir rapor oluşturur:

Bir mesajın ekran görüntüsü

Oh, küçük bir kaç sızıntı var ancak tüm işlev adları bozuk olduğu için yığın izleme çok faydalı değil. Bu bilgileri korumak için temel bir hata ayıklama bilgisiyle yeniden derleyelim:

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

Bu çok daha iyi görünüyor:

GenericBindingType RawImage ::toWireType işlevinden gelen &quot;12 baytlık doğrudan sızıntı&quot; mesajının ekran görüntüsü

Emscripten'in dahili işlevlerine işaret ettikleri için yığın izlemenin bazı bölümleri hâlâ belirsiz görünüyor ancak sızıntının, Embind tarafından RawImage'den "bağlantı türüne" (JavaScript değerine) yapılan bir dönüşümden kaynaklandığını söyleyebiliriz. Gerçekten de koda baktığımızda, RawImage C++ örneklerini JavaScript'e döndürdüğümüzü ancak hiçbir zaman iki taraftan da bu örnekleri serbest bırakmadığımızı görebiliriz.

Şu anda JavaScript ile WebAssembly arasında çöp toplama entegrasyonu olmadığını hatırlatmak isteriz. Ancak entegrasyon geliştirilmektedir. Bunun yerine, nesneyle işiniz bittiğinde belleği manuel olarak boşaltmanız ve JavaScript tarafında yıkıcıları çağırmanız gerekir. Özellikle Embind için, resmi dokümanlarda, açık C++ sınıflarında bir .delete() yönteminin çağrılması öneriliyor:

JavaScript kodunun aldığı tüm C++ nesne işleyicilerini açıkça silmesi gerekir. Aksi takdirde Emscripten yığını süresiz olarak büyür.

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

Sınıfımız için JavaScript'te bunu yaptığımızda:

  // 

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

Sızıntı, beklendiği gibi kaybolur.

Dezenfektanlarla ilgili daha fazla sorun tespit etme

Dezenfektan içeren diğer Squoosh codec'lerini derlemek hem benzer hem de bazı yeni sorunları ortaya çıkarır. Örneğin, MozJPEG bağlamalarında şu hatayı alıyorum:

Bir mesajın ekran görüntüsü

Burada bu bir sızıntı değil, ayrılmış sınırların dışındaki bir anıya yazıyoruz 😅

MozJPEG kodunu incelediğimizde, JPEG için bir bellek hedefi ayırmak üzere kullandığımız işlev olan jpeg_mem_dest işlevinin sıfır olmayan outbuffer ve outsize değerlerini yeniden kullanmasıyla ilgili bir sorun olduğunu tespit ettik:

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

Ancak bu işlevi bu değişkenlerden hiçbirini başlatmadan çağırıyoruz. Bu da MozJPEG'in sonucu, çağrı sırasında bu değişkenlerde depolanmış olan rastgele bir bellek adresine yazdığı anlamına geliyor.

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

Çağrımdan önce her iki değişkeni de sıfırlamanız bu sorunu çözer ve kod artık bellek sızıntısı kontrolüne ulaşır. Kontrol başarılı bir şekilde tamamlandı. Bu da bu codec'te sızıntının olmadığını gösteriyor.

Ortak durumla ilgili sorunlar

…Yoksa öyle mi?

Kodek bağlamalarımızın, durumun bir kısmını ve sonuçları genel statik değişkenlerde depoladığını ve MozJPEG'in özellikle karmaşık yapılara sahip olduğunu biliyoruz.

uint8_t* last_result;
struct jpeg_compress_struct cinfo;

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

Bunlardan bazıları ilk çalıştırmada tembel şekilde başlatılırsa ve sonraki çalıştırmalarda yanlış şekilde yeniden kullanılırsa ne olur? Böylece dezenfektanla tek bir arama yapıldığında bu müşteri sorunlu olarak bildirilmez.

Kullanıcı arayüzünde rastgele farklı kalite seviyelerini tıklayarak resmi birkaç kez işlemeyi deneyelim. Gerçekten de artık aşağıdaki raporu elde ediyoruz:

Bir mesajın ekran görüntüsü

262.144 bayt: Örnek resmin tamamı jpeg_finish_compress'ten sızdırılmış gibi görünüyor.

Dokümanları ve resmi örnekleri inceledikten sonra, jpeg_finish_compress'ün önceki jpeg_mem_dest çağrımız tarafından ayrılan belleği serbest bırakmadığı, yalnızca sıkıştırma yapısının bellek hedefimizi zaten bildiği halde sıkıştırma yapısını serbest bıraktığı ortaya çıktı.

Bu sorunu, free_result işlevinde verileri manuel olarak serbest bırakarak düzeltebiliriz:

void free_result() {
  /* This is an important step since it will release a good deal of memory. */
  free(last_result);
  jpeg_destroy_compress(&cinfo);
}

Bu bellek hatalarını tek tek araştırmaya devam edebilirim ama şu ana kadar bellek yönetimine mevcut yaklaşımın bazı sistematik sorunlara yol açtığını anladım.

Bazıları dezenfektana hemen yakalanabilir. Bazılarının yakalanması için karmaşık numaralar gerekir. Son olarak, günlüklerden görebildiğimiz gibi, yayının başındaki gibi sorunların hiç dezenfekter tarafından yakalanmadığı durumlar vardır. Bunun nedeni, gerçek yanlış kullanımın, dezenfektanın görünmediği JavaScript tarafında gerçekleşmesidir. Bu sorunlar yalnızca üretimde veya gelecekte kodda alakasız görünen değişiklikler yapıldıktan sonra ortaya çıkar.

Güvenli bir sarmalayıcı oluşturma

Birkaç adım geri gidip kodu daha güvenli bir şekilde yeniden yapılandırarak bu sorunların tümünü giderelim. Örnek olarak tekrar ImageQuant sarmalayıcısını kullanacağım ancak benzer yeniden düzenleme kuralları tüm codec'ler ve diğer benzer kod tabanlarına uygulanır.

Öncelikle, ücretsiz kullanımdan sonra ücretli kullanım sorununu düzeltmek için gönderinin başına dönelim. Bunun için, JavaScript tarafında ücretsiz olarak işaretlemeden önce WebAssembly destekli görünümdeki verileri klonlamamız gerekir:

  // 

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

Şimdi, çağrılar arasında global değişkenlerdeki durumu paylaşmadığımızdan emin olalım. Bu, hem daha önce gördüğümüz bazı sorunları düzeltir hem de gelecekte codec'lerimizi çok iş parçacıklı bir ortamda kullanmayı kolaylaştırır.

Bunu yapmak için C++ sarmalayıcıyı, işleve yapılan her çağrının yerel değişkenleri kullanarak kendi verilerini yönettiğinden emin olmak üzere yeniden yapılandırırız. Ardından, free_result işlevimizin imzasını değiştirerek işaretçiyi tekrar kabul edebiliriz:

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

Ancak JavaScript ile etkileşim kurmak için Emscripten'de Embind'i zaten kullanıyoruz. Bu nedenle, C++ bellek yönetimi ayrıntılarını tamamen gizleyerek API'yi daha da güvenli hale getirebiliriz.

Bunun için Embind kullanarak new Uint8ClampedArray(…) kısmını JavaScript'ten C++ tarafına taşıyalım. Ardından, işlevden dönmeden önce verileri JavaScript belleğine kopyalamak için bu işlevi kullanabiliriz:

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

Tek bir değişiklikle, hem elde edilen bayt dizisinin JavaScript'e ait olmasını ve WebAssembly belleği tarafından desteklenmemesini sağladığımızı ve daha önce sızdırılan RawImage sarmalayıcısından da nasıl kurtulduğumuzu unutmayın.

Artık JavaScript'in verileri boşaltma konusunda endişelenmesi gerekmiyor ve sonucu diğer tüm çöp toplanan nesneler gibi kullanabilir:

  // 

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

Bu aynı zamanda, C++ tarafında artık özel bir free_result bağlamasına ihtiyaç duymadığımız anlamına da gelir:

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

Sonuç olarak sarmalayıcı kodumuz hem daha temiz hem de daha güvenli hale geldi.

Ardından, ImageQuant sarmalayıcısının kodunda bazı küçük iyileştirmeler daha yaptım ve diğer codec'ler için benzer bellek yönetimi düzeltmelerini uyguladım. Daha fazla bilgi edinmek istiyorsanız C++ codec'leri için bellek düzeltmeleri başlıklı makalede bu konuyla ilgili PR'yi bulabilirsiniz.

Çıkarımlar

Bu yeniden düzenlemeden, diğer kod tabanlarına uygulanabilecek hangi dersleri öğrenebilir ve paylaşabiliriz?

  • WebAssembly tarafından desteklenen bellek görünümlerini, hangi dilde oluşturulduğuna bakılmaksızın tek bir çağrıdan fazla kullanmayın. Bu öğelerin bundan daha uzun süre hayatta kalmasını bekleyemezsiniz ve bu hataları geleneksel yöntemlerle yakalayamazsınız. Bu nedenle, verileri daha sonra kullanmak üzere saklamanız gerekiyorsa JavaScript tarafına kopyalayıp orada saklayın.
  • Mümkünse doğrudan ham işaretçiler üzerinde işlem yapmak yerine güvenli bir bellek yönetimi dili veya en azından güvenli tür sarmalayıcıları kullanın. Bu, JavaScript ↔ WebAssembly sınırındaki hatalardan sizi kurtarmaz ancak en azından statik dil kodu tarafından kapsanan hataların yüzeyini azaltır.
  • Hangi dili kullanırsanız kullanın, geliştirme sırasında kodları temizleyicilerle çalıştırın. Bu temizleyiciler, yalnızca statik dil kodundaki sorunları değil, JavaScript ile WebAssembly sınırındaki bazı sorunları (ör. .delete() çağrısını unutarak veya JavaScript tarafında geçersiz işaretçiler göndererek) yakalamanıza yardımcı olabilir.
  • Mümkünse yönetilmeyen verileri ve nesneleri WebAssembly'den JavaScript'e tamamen göstermekten kaçının. JavaScript, çöp toplanan bir dildir ve manuel bellek yönetimi yaygın değildir. Bu durum, WebAssembly'nizin oluşturulduğu dilin bellek modelinin soyutlama sızıntısı olarak değerlendirilebilir. JavaScript kod tabanında yanlış yönetim, kolayca göz ardı edilebilir.
  • Bu, açıkça anlaşılabilir ancak diğer tüm kod tabanlarında olduğu gibi, genel değişkenlerde değişebilir durum depolamaktan kaçının. Çeşitli çağrılarda veya hatta iş parçalarında yeniden kullanılmasıyla ilgili sorunları gidermek istemezsiniz. Bu nedenle, mümkün olduğunca bağımsız tutmanız önerilir.