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, farklı resim codec'lerinin ve ayarlarının, kaliteyi önemli ölçüde etkilemeden resim dosyası boyutunu ne kadar iyileştirebileceğini gösteren bir PWA'dır. Ancak bu, C++ veya Rust'ta yazılmış kitaplıkları web'e nasıl taşıyabileceğinizi gösteren teknik bir demodur.

Mevcut ekosistemlerden kod taşıyabilmek 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, kendinden sonra temizlik konusunda oldukça affedici olsa da bu tür statik diller kesinlikle öyle değildir. 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 (yani 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, WebAssembly (Wasm) bellek arabelleği tarafından desteklenen bir JavaScript Uint8Array döndürür. byteOffset ve byteLength, belirtilen işaretçiye ve uzunluğa ayarlanı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'den free_result işlevini çağırdığımızda bu işlev, bu belleği gelecekteki tüm atamalara uygun olarak işaretlemek için standart bir C işlevi free çağırır. Bu, Uint8Array görünümümüzün gösterdiği verilerin, Wasm'e yapılan gelecekteki bir çağrı tarafından rastgele verilerle ü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'yi 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 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şı, daha ileri gidip bu kodun uygulamada herhangi bir sorun gösterip göstermediğini 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 durumda, 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ız için Emscripten'in tüm belleğin boşaltı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 belgelerde, açık C++ sınıflarında .delete() yönteminin çağrılması önerilmektedir:

JavaScript kodu, aldığı tüm C++ nesne tutamaçlarını açıkça silmelidir. Aksi takdirde Emscripten yığını sınırsız 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 ortadan kalkar.

Dezenfektanlarla ilgili daha fazla sorun tespit etme

Diğer Squoosh codec'lerini temizleyicilerle oluşturmak hem benzer hem de bazı yeni sorunları ortaya çıkarır. Örneğin, MozJPEG bağlamalarında şu hatayı aldım:

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

Burada, sızıntı değil, ayrılan sınırların dışında bir belleğe yazma söz konusudur 😱

MozJPEG'in koduna baktığımızda, sorunun JPEG için bir bellek hedefi ayırmak üzere kullandığımız işlev olan jpeg_mem_dest'ün outbuffer ve outsize'in sıfırdan farklı olduğu durumlarda mevcut değerlerini yeniden kullanmasından kaynaklandığını görüyoruz:

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 değil 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? Bu durumda, tek bir dezenfektan araması, bu cihazları sorunlu olarak bildirmez.

Kullanıcı arayüzünde rastgele farklı kalite seviyelerini tıklayarak resmi birkaç kez işlemeyi deneyelim. Aslında şu anda aşağıdaki raporu alıyoruz:

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 hafıza hatalarını tek tek arayabilirim ancak hafıza yönetimine yönelik mevcut yaklaşımın bazı kötü sistematik sorunlara yol açtığı artık yeterince açık.

Bazıları hemen dezenfektan tarafından 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 da vardır. Bunun nedeni, gerçek kötüye kullanımın JavaScript tarafında gerçekleşmesidir. Bu tarafta, temizleyicinin görünürlük yoktur. 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 geriye gidelim ve kodu daha güvenli bir şekilde yeniden yapılandırarak tüm bu sorunları düzeltelim. Ö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ı için geçerlidir.

Ö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 kopyalamamı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 herhangi bir 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 ile new Uint8ClampedArray(…) bölümünü JavaScript'den 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 durum, C++ tarafında artık özel bir free_result bağlamaya 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 kodu temizleyicilerle çalıştırın. Bu temizleyiciler, yalnızca statik dil kodundaki sorunları değil, JavaScript ↔ WebAssembly sınırındaki bazı sorunları (ör. .delete() çağrısını unutarak veya JavaScript tarafından 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 toplayan bir dildir ve manuel bellek yönetimi bu dilde yaygın değildir. Bu, WebAssembly'inizin oluşturulduğu dilin bellek modelinde bir soyutlama sızıntısı olarak kabul edilebilir. JavaScript kod tabanında yanlış yönetimin gözden kaçırılması kolaydır.
  • Bu açık bir nokta olsa da diğer tüm kod tabanlarında olduğu gibi, değişken durumu genel değişkenlerde saklamaktan 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.