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

JavaScript, kendi kendine temizlik yapmayı affeder ancak statik diller kesinlikle kolay değildir...

Ingvar Stepanyan
Ingvar Stepanyan

Squoosh.app, ne kadar farklı resim codec'lerinin kullanıldığını gösteren bir PWA'dır ve ayarlar, kaliteyi önemli ölçüde etkilemeden resim dosyasının boyutunu iyileştirebilir. Ancak, C++ veya Rust ile yazılmış kitaplıkları nasıl alıp erişebileceğinizi gösteren teknik bir demo yardımcı olur.

Mevcut ekosistemlerden kod taşımak son derece değerlidir ancak bununla birlikte, JavaScript ile JavaScript arasındaki farkları azaltır. Bunlardan biri farklı yaklaşımlarını anlatacağım.

JavaScript, kendi kendine temizlik yapmayı affedebilecek olsa da bu tür statik diller kesinlikle hayır. Açıkça yeni bir ayrılmış anı istemeniz gerekir ve bunu yaparken geri vermeniz ve bir daha kullanmamanız gerekir. Aksi halde sızıntılar yaşanır... oldukça düzenli bir şekilde gerçekleşir. Bu bellek sızıntılarını nasıl ayıklayabileceğinize ve hatalardan kaçınmak için kodunuzu nasıl tasarlayacağınızı

Şüpheli desen

Son zamanlarda, Squoosh üzerinde çalışmaya başlarken, Google C++ codec sarmalayıcıları. Şimdi ImageQuant sarmalayıcısına örnek (yalnızca nesne oluşturma ve dağıtım kısımlarını gösterecek şekilde küçültülmüş):

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 fark ettiniz mi? İpucu: ücretsiz sonrası kullanım, JavaScript!

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

JavaScript'ten free_result işlevini çağırdığımızda, aynı zamanda tablodaki bir standart C işlevini free sonraki ayırmalar için kullanılabilir olarak ayarlanmış olacaktır. Bu, Uint8Array için rastgele veriler, ileride Wasm'e yapılacak bir çağrıyla rastgele verilerin üzerine yazılabilir.

Veya bir free uygulanması, boşta kalan belleğin hemen sıfır olarak doldurulmasına bile karar verebilir. İlgili içeriği oluşturmak için kullanılan Emscripten'in kullandığı free bunu yapmaz ancak burada bir uygulama ayrıntısından yararlanıyoruz garanti edilemez.

İşaretçinin arkasındaki bellek korunsa bile, yeni ayırmanın WebAssembly belleği. WebAssembly.Memory, JavaScript API aracılığıyla veya buna karşılık gelen bir şekilde büyütüldüğünde memory.grow talimatı, mevcut ArrayBuffer öğesini ve geçişli olarak tüm görünümleri geçersiz kılar yardımcı oluyor.

Bu davranışı göstermek için Geliştirici Araçları (veya Node.js) konsolunu kullanmama izin verin:

> 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'ı açıkça aramasak bile bir noktada codec'lerimize çoklu iş parçacığı desteği ekleyebiliriz. Bu durumda, biz klonlamayı başamadan hemen önce verilerin üzerine yazan tamamen farklı bir ileti dizisi olabilir.

Bellek hataları aranıyor

Her ihtimale karşı, bu kodda pratikte herhangi bir sorun olup olmadığını kontrol etmeye karar verdim. Bu yeni, Emscripten dezenfektanlarını denemek için mükemmel bir fırsat gibi görünüyor. desteği ve Chrome Geliştirici Zirvesi'ndeki WebAssembly konuşmasında sunuldu:

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

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 potansiyel hafızayı da bulmak istiyoruz. sızdır. ImageQuant'ı program yerine kitaplık olarak kullandığımız için "çıkış noktası" yok. ile Emscripten, tüm belleğin boşaltıldığını otomatik olarak doğrulayabilir.

Bunun yerine, bu gibi durumlarda LeakSanitizer (AddressSanitizer'da yer alır) içine __lsan_do_leak_check ve __lsan_do_recoverable_leak_check, Bu komut, tüm belleğin serbest bırakılmasını beklediğimiz ve bunu doğrulamak istediğimizde manuel olarak varsayımı. __lsan_do_leak_check, çalışan bir uygulamanın sonunda kullanılmak üzere tasarlanmıştır. bir sızıntı tespit edilirse işlemi iptal etmek isteyebilir, __lsan_do_recoverable_leak_check gibi kütüphane kullanım alanları, sızıntıları konsola yazdırmak istediğinizde daha uygundur. uygulamanın çalışmaya devam etmesini sağlar.

İkinci yardımcıyı Embind aracılığıyla gösterelim. Böylece istediğimiz zaman JavaScript'ten ç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);
}

Ve resimle işiniz bittiğinde, bunu JavaScript tarafından çağırın. Bu işlem, C++ yerine JavaScript tarafı, tüm kapsamların doğru şekilde çıktı ve tüm geçici C++ nesneleri serbest bırakıldı.

  // 

  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ğıdakine benzer bir rapor sunar:

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

Maalesef bazı küçük eksiklikler var ancak yığın izleme, bu fonksiyonların tüm adlarında saplanmışlar. Bu bilgileri korumak için temel hata ayıklama bilgileriyle 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:

&quot;Doğrudan sızıntı 12 bayt&quot; yazan mesajın ekran görüntüsü bir GenelBindingType RawImage ::toWireType işlevinden gelir

Yığın izlemenin bazı bölümleri Emscripten dahili öğelerini işaret ettiği için hâlâ belirsiz görünüyor ancak sızıntıların RawImage dönüşümünden "kablo türü"ne geldiğini söyle (bir JavaScript değerine) Embind. Gerçekten de koda baktığımızda, RawImage C++ örneğini döndürdüğümüzü ama hiçbir zaman iki tarafta da serbest bırakmıyoruz.

Hatırlatalım, şu anda JavaScript ile Google arasında çöp toplama entegrasyonu WebAssembly geliştirilmekte olan olsa da. Bunun yerine manuel olarak boşaltmak ve nesnesini tanımlayın. Özellikle Embind için resmi dokümanlar açığa çıkan C++ sınıflarında bir .delete() yöntemi çağrılmasını önerin:

JavaScript kodunun, aldığı tüm C++ nesne tanıtıcılarını veya sonsuza kadar büyüyecektir.

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

Aslında bunu sınıfımız için JavaScript'te 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 diğer sorunları keşfetme

Dezenfektan içeren diğer Squoosh codec'lerini derlemek hem benzer hem de bazı yeni sorunları ortaya çıkarır. Örneğin, ö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, buradaki sorunun jpeg_mem_dest olduğunu tespit ettik. JPEG için bir bellek hedefi ayırmak üzere kullandığımız işlev—JPEG'nin mevcut değerlerini outbuffer ve outsize aynı sıfır olmayan bir sayı:

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 değişkenlerden hiçbirini başlatmadan çağırırız. Bu, MozJPEG tarafından bu değişkenlerde depolanan, rastgele bir bellek adresine dönüşür zamanı geldi.

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

Çağrı bu sorunu çözmeden önce her iki değişkeni sıfır başlatma işlemi ve artık kod bellek sızıntısı kontrolü yapın. Neyse ki kontrol başarıyla geçti. Bu da şu anda olabilir.

Paylaşılan durumla ilgili sorunlar

...yoksa biz mi?

codec bağlamalarımızın durumun bir kısmını depoladığını ve bu işlem sırasında global statik değişkenlerini ifade eder ve MozJPEG özellikle karmaşık yapılara sahiptir.

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 gecikmeli olarak başlatılır ve daha sonra ileride düzgün bir şekilde tekrar kullanılırsa ne olur? çalışır mı? Böylece dezenfektanla tek bir arama yapıldığında bu müşteri sorunlu olarak bildirilmez.

Şimdi, farklı kalite seviyelerinde rastgele tıklayarak resmi birkaç kez işlemeyi deneyelim görüntülenir. 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 kaynağından sızdırılmış gibi görünüyor.

Dokümanları ve resmi örnekleri incelediğimizde jpeg_finish_compress önceki jpeg_mem_dest çağrımız tarafından ayrılan hafızada yer açmaz. Bu işlem yalnızca bu sıkıştırma yapısı, belleğimizi zaten bilse bile hedef... Ah.

Bunu, free_result işlevindeki 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 araştırmaya devam edebilirim ama şimdiye kadar hafıza yönetimine mevcut bir yaklaşımla birlikte bazı kötü sistematik sorunlar ortaya çıkabilir.

Bazıları dezenfektana hemen yakalanabilir. Bazıları ise karmaşık numaraların yakalanabilmesini gerektirir. Son olarak, gönderinin başındaki günlüklerden gördüğümüz gibi dezenfektana hiç yakalanmıyor. Bunun nedeni, asıl hatalı kullanımın Dezenfektanın görünürlüğü olmadığı JavaScript tarafı. Bu sorunlar, projenin .

Güvenli bir sarmalayıcı oluşturma

Birkaç adım geri gidip kodu yeniden yapılandırarak tüm bu sorunları giderelim. sağlayabilir. Yine örnek olarak ImageQuant sarmalayıcısını kullanacağım, ancak benzer yeniden düzenleme kuralları geçerlidir yanı sıra diğer benzer kod tabanları için de kolayca yükleyebilirsiniz.

Öncelikle, ücretsiz sonrası kullanım sorununu yayının başlangıcından itibaren düzeltelim. Bunun için kullanarak verileri JavaScript tarafında serbest olarak işaretlemeden önce WebAssembly destekli görünümden klonlayın:

  // 

  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 genel değişkenlerde herhangi bir durum paylaşmayacağımızdan emin olalım. Bu hem de önceden gördüğümüz sorunların bazılarını giderecek hem de iş parçacıklı bir ortamda codec'lerini destekler.

Bunu yapmak için C++ sarmalayıcısını yeniden düzenleyerek işleve yapılan her çağrının kendi çağrısını yönettiğinden emin oluruz. verileri nasıl kullanacağınızı göstereceğim. Sonra, free_result fonksiyonumuzun imzasını geri işaretçiyi kabul edin:

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 zaten Emscripten'de Embind kullandığımızdan C++ bellek yönetimi ayrıntılarını tamamen gizleyerek API'yi daha da güvenli hale getirin!

Bunun için, new Uint8ClampedArray(…) kısmını JavaScript'ten C++ tarafına, Embind. Sonra, geri dönmeden önce bile verileri JavaScript belleğine klonlamak için bunu kullanabiliriz işlevini kullanın:

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 her ikimizin de sonuçtaki bayt dizisinin JavaScript'e ait olmasını nasıl sağlayacağımızı unutmayın ve WebAssembly belleği tarafından desteklenmez ve önceden sızdırılan RawImage sarmalayıcıdan kurtulabilirsiniz. çok önemlidir.

JavaScript'in artık verileri serbest bırakma konusunda endişe duymasına gerek kalmaz ve sonuçları çöp toplanan diğer nesneler:

  // 

  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 aynı zamanda daha temiz ve daha güvenli hale geldi.

Bunun ardından, ImageQuant sarmalayıcı kodunda ve diğer codec'ler için çoğaltılmış benzer bellek yönetimi düzeltmeleri. Daha fazla bilgi almak isterseniz oluşturulan PR'yi burada görebilirsiniz: C++ için bellek düzeltmeleri codec'ler ile uyumludur.

Çıkarımlar

Bu yeniden düzenlemeden diğer kod tabanlarına uygulanabilecek hangi dersleri çıkarıp paylaşabiliriz?

  • WebAssembly tarafından desteklenen bellek görünümlerini, kullandığı dilden bağımsız olarak olduğunu unutmayın. Bundan daha uzun süre hayatta kalacaklarına güvenemezsiniz. geleneksel yöntemlerle yakalamaktır. Dolayısıyla, verileri daha sonra kullanmak üzere saklamanız gerekiyorsa kopyalayıp orada depolamaya çalışın.
  • Mümkünse doğrudan ham işaretçilerle çalışmayı sağlar. Bu işlem, sizi JavaScript Override WebAssembly üzerindeki hatalardan kurtarmaz ancak en azından statik dil kodu tarafından yetiştirilen hataların yüzeyini azaltır.
  • Hangi dili kullanıyor olursanız olun, geliştirme sırasında temizleyicilerle kod çalıştırın. Temizleyiciler, Sadece statik dil kodundaki sorunları değil, aynı zamanda JavaScript kodu ile ilgili bazı sorunları da yakalayabilirsiniz. WebAssembly sınırı; .delete() çağrısı yapmayı unutma veya şuradan geçersiz işaretçileri iletme: çok önemli.
  • Mümkünse yönetilmeyen verileri ve nesneleri WebAssembly'den JavaScript'e göstermekten kaçının. JavaScript, çöp toplanan bir dildir ve manuel bellek yönetimi yaygın değildir. Bu durum, WebAssembly dilinizin bellek modelinin soyutlama sızıntısı olarak değerlendirilebilir ve yanlış yönetim JavaScript kod tabanında kolayca gözden kaçabilir.
  • Bu, açıkça görülse de diğer tüm kod tabanlarında olduğu gibi, değişken durumu global biçimde depolamaktan kaçının. değişkenlerine karşılık gelir. Ya da çeşitli çağrılarda yeniden kullanımıyla ilgili sorunlarda hata ayıklamak veya ileti dizileri olduğundan, olabildiğince bağımsız tutmak en iyisidir.