การแก้ไขข้อบกพร่องหน่วยความจำรั่วไหลใน WebAssembly โดยใช้ Emscripten

แม้ว่า JavaScript จะค่อนข้างให้อภัยในการล้างข้อมูลหลังจากตัวเอง แต่ภาษาแบบคงที่นั้นไม่แน่นอน

Squoosh.app เป็น PWA ที่แสดงให้เห็นว่าโปรแกรมเปลี่ยนไฟล์รูปภาพและการตั้งค่าต่างๆ ช่วยปรับปรุงขนาดไฟล์รูปภาพได้มากเพียงใดโดยไม่ส่งผลต่อคุณภาพอย่างมีนัยสำคัญ แต่ก็ยังเป็นการสาธิตทางเทคนิคที่แสดงให้เห็นวิธีนําคลังเขียนด้วย C++ หรือ Rust ไปใช้ในเว็บ

การพอร์ตโค้ดจากระบบนิเวศที่มีอยู่มีประโยชน์อย่างยิ่ง แต่ภาษาแบบคงที่เหล่านั้นและ JavaScript มีความแตกต่างที่สำคัญบางอย่าง หนึ่งในนั้นคือแนวทางการจัดการหน่วยความจำที่แตกต่างกัน

แม้ว่า JavaScript จะค่อนข้างให้อภัยในการล้างข้อมูลหลังจากตัวเอง แต่ภาษาแบบคงที่ดังกล่าวจะไม่ให้อภัยอย่างแน่นอน คุณต้องขอหน่วยความจำใหม่อย่างชัดเจน และจะต้องคืนหน่วยความจำดังกล่าวหลังจากนั้นและจะไม่ใช้อีก หากไม่เกิดขึ้น คุณจะพบปัญหาการรั่วไหล ซึ่งเกิดขึ้นค่อนข้างบ่อย มาดูวิธีแก้ไขข้อบกพร่องหน่วยความจำที่รั่วไหลเหล่านั้น และวิธีออกแบบโค้ดเพื่อหลีกเลี่ยงข้อบกพร่องดังกล่าวในครั้งถัดไป

รูปแบบที่น่าสงสัย

เมื่อเร็วๆ นี้ขณะเริ่มทํางานกับ Squoosh เราสังเกตเห็นรูปแบบที่น่าสนใจใน Wrapper ตัวแปลงรหัส C++ มาดูตัวอย่าง Wrapper ของ ImageQuant (ย่อให้แสดงเฉพาะส่วนของการสร้างและการจัดสรรพื้นที่ใหม่ของออบเจ็กต์)

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

คุณพบปัญหาไหม เคล็ดลับ: use-after-free แต่อยู่ใน JavaScript

ใน Emscripten typed_memory_view จะแสดงผล JavaScript Uint8Array ที่รองรับโดยบัฟเฟอร์หน่วยความจำ WebAssembly (Wasm) โดยตั้งค่า byteOffset และ byteLength เป็นพอยน์เตอร์และความยาวที่ระบุ ประเด็นหลักคือนี่คือมุมมอง TypedArray ไปยังบัฟเฟอร์หน่วยความจำ WebAssembly ไม่ใช่สำเนาข้อมูลของ JavaScript

เมื่อเราเรียก free_result จาก JavaScript ฟังก์ชันดังกล่าวจะเรียกฟังก์ชัน C มาตรฐาน free เพื่อทําเครื่องหมายหน่วยความจํานี้ว่าพร้อมสําหรับการจัดสรรในอนาคต ซึ่งหมายความว่าข้อมูลที่เราชี้ไปยังมุมมอง Uint8Array สามารถเขียนทับด้วยข้อมูลใดก็ได้โดยการเรียกใช้ Wasm ในอนาคต

หรือการใช้งาน free บางรายการอาจตัดสินใจที่จะกรอกค่า 0 ลงในหน่วยความจำที่ว่างเปล่าทันที free ที่ Emscripten ใช้ไม่ได้ทำเช่นนั้น แต่เราอาศัยรายละเอียดการใช้งานที่นี่ซึ่งไม่สามารถรับประกันได้

หรือแม้ว่าหน่วยความจำที่อยู่หลังพอยน์เตอร์จะได้รับการสงวนไว้ แต่การจัดสรรใหม่อาจต้องเพิ่มหน่วยความจำ WebAssembly เมื่อเพิ่ม WebAssembly.Memory ผ่าน JavaScript API หรือคำสั่ง memory.grow ที่เกี่ยวข้อง ระบบจะทำให้ ArrayBuffer ที่มีอยู่ใช้งานไม่ได้ และทำให้มุมมองที่ ArrayBuffer รองรับใช้งานไม่ได้ด้วย

เราจะใช้คอนโซลเครื่องมือสำหรับนักพัฒนาซอฟต์แวร์ (หรือ 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

สุดท้ายนี้ แม้ว่าเราจะไม่ได้เรียกใช้ Wasm อย่างชัดเจนอีกในช่วงระหว่าง free_result ถึง new Uint8ClampedArray แต่เราก็อาจเพิ่มการรองรับการแยกหลายเธรดลงในโปรแกรมเปลี่ยนรหัสของเราในบางจุด ในกรณีนี้ อาจเป็นเธรดอื่นที่เขียนทับข้อมูลก่อนที่เราจะคัดลอกได้

มองหาข้อบกพร่องเกี่ยวกับหน่วยความจํา

เพื่อเป็นการป้องกันไว้ก่อน เราจึงตัดสินใจตรวจสอบเพิ่มเติมว่ารหัสนี้มีปัญหาเกิดขึ้นจริงหรือไม่ ดูเหมือนว่านี่จะเป็นโอกาสที่ดีในการลองใช้การรองรับโปรแกรมตรวจสอบ Emscripten เวอร์ชันใหม่(ish) ที่เพิ่มเข้ามาเมื่อปีที่แล้วและนำเสนอในการพูดคุยเรื่อง WebAssembly ที่ Chrome Dev Summit

ในกรณีนี้ เราสนใจ AddressSanitizer ซึ่งสามารถตรวจหาปัญหาต่างๆ เกี่ยวกับตัวชี้และหน่วยความจำ หากต้องการใช้ เราต้องคอมไพล์ตัวแปลงรหัสใหม่ด้วย -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

ซึ่งจะเปิดใช้การตรวจสอบความปลอดภัยของพอยน์เตอร์โดยอัตโนมัติ แต่เราต้องการค้นหาการรั่วไหลของหน่วยความจำที่อาจเกิดขึ้นด้วย เนื่องจากเราใช้ ImageQuant เป็นไลบรารีแทนโปรแกรม จึงไม่มี "จุดสิ้นสุด" ที่ Emscripten จะตรวจสอบได้อัตโนมัติว่ามีการคืนหน่วยความจำทั้งหมดแล้ว

ในกรณีเช่นนี้ LeakSanitizer (รวมอยู่ใน AddressSanitizer) จะมีฟังก์ชัน __lsan_do_leak_check และ __lsan_do_recoverable_leak_check ซึ่งเรียกใช้ด้วยตนเองได้เมื่อใดก็ตามที่เราคาดว่าจะมีการปลดปล่อยหน่วยความจำทั้งหมดและต้องการยืนยันสมมติฐานดังกล่าว __lsan_do_leak_check มีไว้เพื่อใช้เมื่อแอปพลิเคชันที่ทำงานอยู่สิ้นสุดลง เมื่อคุณต้องการหยุดกระบวนการในกรณีที่ตรวจพบการรั่วไหล ส่วน __lsan_do_recoverable_leak_check เหมาะสําหรับ Use Case ของไลบรารีมากกว่า เช่น เมื่อคุณต้องการพิมพ์การรั่วไหลไปยังคอนโซล แต่ให้แอปพลิเคชันทำงานต่อไป

มาแสดงตัวช่วยที่ 2 ผ่าน Embind เพื่อให้เราเรียกใช้จาก JavaScript ได้ทุกเมื่อ

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

และเรียกใช้จากฝั่ง JavaScript เมื่อเราจัดการกับรูปภาพเสร็จแล้ว การทำเช่นนี้จากฝั่ง JavaScript แทนที่จะเป็นฝั่ง C++ จะช่วยให้มั่นใจได้ว่าสโคปทั้งหมดจะออกและมีการคืนค่าออบเจ็กต์ C++ ชั่วคราวทั้งหมดแล้วเมื่อเราเรียกใช้การตรวจสอบเหล่านั้น

  // 

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

ซึ่งจะให้รายงานดังต่อไปนี้ในคอนโซล

ภาพหน้าจอของข้อความ

อ๊ะ มีการรั่วไหลเล็กน้อย แต่สแต็กเทรซไม่ค่อยมีประโยชน์เนื่องจากชื่อฟังก์ชันทั้งหมดถูกทำให้อ่านไม่ออก มาคอมไพล์อีกครั้งพร้อมข้อมูลการแก้ไขข้อบกพร่องพื้นฐานเพื่อเก็บข้อมูลไว้กัน

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

ข้อความนี้ดูดีกว่ามาก

ภาพหน้าจอของข้อความที่ระบุว่า &quot;การเปิดเผยข้อมูลโดยตรง 12 ไบต์&quot; ซึ่งมาจากฟังก์ชัน GenericBindingType RawImage ::toWireType

บางส่วนของสแต็กเทรซยังดูคลุมเครือเนื่องจากชี้ไปยังข้อมูลภายในของ Emscripten แต่เราสามารถบอกได้ว่าการรั่วไหลมาจากRawImageการเปลี่ยนเป็น "ประเภทการเดินสาย" (เป็นค่า JavaScript) โดย Embind เมื่อตรวจสอบโค้ดแล้ว เราพบว่าเราส่งคืนRawImageอินสแตนซ์ C++ ไปยัง JavaScript แต่ไม่เคยยกเลิกการจองอินสแตนซ์ดังกล่าวในฝั่งใดฝั่งหนึ่ง

โปรดทราบว่าขณะนี้ยังไม่มีการผสานรวมการเก็บขยะระหว่าง JavaScript กับ WebAssembly แม้ว่าจะอยู่ระหว่างการพัฒนา แต่คุณต้องเพิ่มหน่วยความจําและเรียกตัวทำลายจากฝั่ง JavaScript ด้วยตนเองเมื่อใช้ออบเจ็กต์เสร็จแล้ว สำหรับ Embind โดยเฉพาะ เอกสารอย่างเป็นทางการแนะนำให้เรียกใช้เมธอด .delete() ในคลาส C++ ที่เปิดเผย

โค้ด JavaScript ต้องลบตัวแฮนเดิลออบเจ็กต์ C++ ที่ได้รับอย่างชัดเจน มิฉะนั้นกอง Emscripten จะเติบโตอย่างไม่จำกัด

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

เมื่อเราทำเช่นนั้นใน JavaScript สำหรับชั้นเรียน

  // 

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

การรั่วไหลหายไปตามที่คาดไว้

พบปัญหาเพิ่มเติมเกี่ยวกับน้ำยาฆ่าเชื้อ

การสร้างตัวแปลงรหัส Squoosh อื่นๆ ด้วยโปรแกรมตรวจสอบพบปัญหาที่คล้ายกันและปัญหาใหม่ๆ ตัวอย่างเช่น ฉันได้รับข้อผิดพลาดนี้ในการเชื่อมโยง MozJPEG

ภาพหน้าจอของข้อความ

ในกรณีนี้ ไม่ได้เป็นการรั่วไหล แต่เป็นการเขียนไปยังหน่วยความจําที่อยู่นอกขอบเขตที่กําหนด 😱

เมื่อตรวจสอบโค้ดของ MozJPEG เราพบว่าปัญหาคือ jpeg_mem_dest ซึ่งเป็นฟังก์ชันที่เราใช้เพื่อจัดสรรปลายทางหน่วยความจำสำหรับ JPEG ใช้ค่าที่มีอยู่ของ outbuffer และ outsize ซ้ำเมื่อค่าเหล่านั้นไม่ใช่ 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;
}

อย่างไรก็ตาม เราเรียกใช้โดยไม่เริ่มต้นตัวแปรใดตัวแปรหนึ่ง ซึ่งหมายความว่า MozJPEG จะเขียนผลลัพธ์ไปยังที่อยู่หน่วยความจำแบบสุ่มที่อาจจัดเก็บไว้ในตัวแปรเหล่านั้น ณ เวลาเรียกใช้

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

การเริ่มต้นค่าทั้งสองตัวแปรเป็น 0 ก่อนการเรียกใช้จะช่วยแก้ปัญหานี้ได้ และตอนนี้โค้ดจะไปถึงการตรวจสอบการรั่วไหลของหน่วยความจำแทน โชคดีที่การตรวจสอบผ่านการตรวจสอบเรียบร้อยแล้ว ซึ่งหมายความว่าเราไม่มีการรั่วไหลในโค้ดนี้

ปัญหาเกี่ยวกับสถานะที่แชร์

…หรือว่าเราจะ

เราทราบดีว่าการเชื่อมโยงโค้ดของเราจัดเก็บสถานะบางส่วนและผลลัพธ์ไว้ในตัวแปรแบบคงที่ส่วนกลาง และ MozJPEG มีโครงสร้างที่ซับซ้อนมาก

uint8_t* last_result;
struct jpeg_compress_struct cinfo;

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

จะเกิดอะไรขึ้นหากมีการเริ่มต้นบางส่วนแบบเลื่อนเวลาในการเรียกใช้ครั้งแรก แล้วนํากลับมาใช้อย่างไม่เหมาะสมในการเรียกใช้ครั้งต่อๆ ไป ดังนั้นการโทรครั้งเดียวที่มีการใช้โปรแกรมฆ่าเชื้อจะไม่รายงานว่าเป็นปัญหา

ลองประมวลผลรูปภาพ 2-3 ครั้งโดยคลิกแบบสุ่มที่ระดับคุณภาพต่างๆ ใน UI ตอนนี้เราได้รับรายงานดังต่อไปนี้

ภาพหน้าจอของข้อความ

262,144 ไบต์ - ดูเหมือนว่ารูปภาพตัวอย่างทั้งหมดจะรั่วไหลจาก jpeg_finish_compress

หลังจากตรวจสอบเอกสารและตัวอย่างอย่างเป็นทางการ พบว่า jpeg_finish_compress ไม่ได้เพิ่มพื้นที่ว่างให้กับหน่วยความจำที่เรียกใช้โดย jpeg_mem_dest ก่อนหน้านี้ แต่จะเพิ่มพื้นที่ว่างให้กับโครงสร้างการบีบอัดเท่านั้น แม้ว่าโครงสร้างการบีบอัดนั้นจะทราบปลายทางหน่วยความจำของเราอยู่แล้วก็ตาม…

เราแก้ไขปัญหานี้ได้โดยการล้างข้อมูลด้วยตนเองในฟังก์ชัน 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);
}

เราอาจไล่ล่าข้อบกพร่องเกี่ยวกับหน่วยความจำทีละข้อ แต่ตอนนี้ก็ชัดเจนแล้วว่าแนวทางการจัดการหน่วยความจำในปัจจุบันทำให้เกิดปัญหาระบบที่น่ารำคาญ

สารฆ่าเชื้อสามารถจับไวรัสบางชนิดได้ทันที ส่วนบางรายต้องใช้กลโกงที่ซับซ้อนในการจับ สุดท้าย ยังมีปัญหาอื่นๆ เช่น ปัญหาในตอนต้นของโพสต์ ซึ่งเราเห็นว่าจากบันทึก Sanitizer ไม่ได้ตรวจพบปัญหาดังกล่าวเลย สาเหตุคือการใช้ในทางที่ผิดเกิดขึ้นที่ฝั่ง JavaScript ซึ่งตัวกรองไม่สามารถเข้าถึงได้ ปัญหาเหล่านี้จะปรากฏขึ้นเมื่ออยู่ในเวอร์ชันที่ใช้งานจริงเท่านั้น หรือหลังจากการเปลี่ยนแปลงโค้ดที่ดูเหมือนจะไม่เกี่ยวข้องในอนาคต

การสร้าง Wrapper ที่ปลอดภัย

เรามาย้อนกลับไป 2-3 ขั้นตอนและแก้ไขปัญหาทั้งหมดเหล่านี้ด้วยการปรับเปลี่ยนโครงสร้างโค้ดให้ปลอดภัยยิ่งขึ้นแทน เราจะใช้ ImageQuant Wrapper เป็นตัวอย่างอีกครั้ง แต่กฎการจัดระเบียบใหม่แบบเดียวกันนี้ใช้กับตัวแปลงรหัสทั้งหมด รวมถึงโค้ดเบสอื่นๆ ที่คล้ายกัน

ก่อนอื่นมาแก้ไขปัญหาการใช้หลังจากการปลดปล่อยตั้งแต่ต้นโพสต์กัน ด้วยเหตุนี้ เราจึงต้องโคลนข้อมูลจากมุมมองที่รองรับ WebAssembly ก่อนทำเครื่องหมายเป็น "ฟรี" ฝั่ง 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;
}

ตอนนี้มาตรวจสอบว่าเราไม่แชร์สถานะใดๆ ในตัวแปรส่วนกลางระหว่างการเรียกใช้ ซึ่งจะช่วยแก้ไขปัญหาบางอย่างที่เราพบแล้ว และทำให้การใช้ตัวแปลงรหัสในสภาพแวดล้อมแบบหลายเธรดในอนาคตง่ายขึ้น

ในการดำเนินการดังกล่าว เราจึงปรับโครงสร้างของ C++ wrapper เพื่อให้แน่ใจว่าการเรียกใช้ฟังก์ชันแต่ละครั้งจะจัดการข้อมูลของตนเองโดยใช้ตัวแปรภายใน จากนั้นเราจะเปลี่ยนลายเซ็นของฟังก์ชัน free_result ให้รับพอยน์เตอร์กลับได้ดังนี้

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

แต่เนื่องจากเราใช้ Embind ใน Emscripten เพื่อโต้ตอบกับ JavaScript อยู่แล้ว เราจึงอาจทำให้ API ปลอดภัยยิ่งขึ้นด้วยการซ่อนรายละเอียดการจัดการหน่วยความจำ C++ ทั้งหมด

ด้วยเหตุนี้ เรามาย้ายส่วน new Uint8ClampedArray(…) จาก JavaScript ไปไว้ฝั่ง C++ ด้วย Embind จากนั้นเราจะใช้เพื่อโคลนข้อมูลลงในหน่วยความจำ JavaScript ได้ก่อนที่ระบบจะแสดงผลจากฟังก์ชัน

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

โปรดสังเกตว่าการเปลี่ยนแปลงเพียงครั้งเดียวนี้ช่วยให้เรามั่นใจได้ว่า JavaScript จะเป็นเจ้าของอาร์เรย์ไบต์ที่ได้ และไม่ได้สำรองข้อมูลโดยหน่วยความจำ WebAssembly และกำจัด Wrapper RawImage ที่รั่วไหลก่อนหน้านี้ด้วย

ตอนนี้ JavaScript ไม่ต้องกังวลเกี่ยวกับการเพิ่มพื้นที่ว่างสำหรับข้อมูลอีกต่อไป และสามารถใช้ผลลัพธ์ได้เช่นเดียวกับออบเจ็กต์อื่นๆ ที่ระบบเก็บขยะ

  // 

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

ซึ่งหมายความว่าเราไม่จำเป็นต้องมี free_result Binding ที่กําหนดเองอีกต่อไปในฝั่ง 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());
}

โดยรวมแล้ว โค้ด Wrapper ของเราทั้งสะอาดขึ้นและปลอดภัยขึ้นในเวลาเดียวกัน

หลังจากนี้ เราได้ทำการปรับปรุงโค้ดของ Wrapper ของ ImageQuant เพิ่มเติมเล็กน้อยและจำลองการแก้ไขการจัดการหน่วยความจำที่คล้ายกันสำหรับโปรแกรมเปลี่ยนไฟล์อื่นๆ หากสนใจรายละเอียดเพิ่มเติม โปรดดู PR ที่ได้ในส่วนการแก้ไขหน่วยความจําสําหรับตัวแปลงรหัส C++

สรุปประเด็นสำคัญ

เราเรียนรู้และแชร์บทเรียนอะไรได้บ้างจากการปรับโครงสร้างนี้ซึ่งนำไปใช้กับโค้ดเบสอื่นๆ ได้

  • อย่าใช้มุมมองหน่วยความจําที่ WebAssembly รองรับ ไม่ว่าจะสร้างจากภาษาใดก็ตาม เกินกว่าการเรียกใช้ครั้งเดียว คุณไม่สามารถคาดหวังให้ข้อมูลเหล่านี้อยู่ได้นานกว่านั้น และคุณจะจับข้อบกพร่องเหล่านี้ด้วยวิธีเดิมๆ ไม่ได้ ดังนั้นหากต้องการจัดเก็บข้อมูลไว้ใช้ภายหลัง ให้คัดลอกข้อมูลดังกล่าวไปไว้ฝั่ง JavaScript แล้วจัดเก็บไว้ที่นั่น
  • หากเป็นไปได้ ให้ใช้ภาษาการจัดการหน่วยความจำที่ปลอดภัย หรืออย่างน้อยก็ใช้ตัวห่อประเภทที่ปลอดภัยแทนการดำเนินการกับพอยน์เตอร์ดิบโดยตรง วิธีนี้จะไม่ช่วยคุณกำจัดข้อบกพร่องในขอบเขต JavaScript ↔ WebAssembly แต่อย่างน้อยก็ช่วยลดโอกาสที่จะเกิดข้อบกพร่องที่เกิดจากโค้ดภาษาแบบคงที่
  • ไม่ว่าจะใช้ภาษาใดก็ตาม ให้เรียกใช้โค้ดด้วยโปรแกรมตรวจสอบระหว่างการพัฒนา ซึ่งจะช่วยตรวจจับปัญหาได้ไม่เพียงในโค้ดภาษาแบบคงที่เท่านั้น แต่ยังช่วยตรวจจับปัญหาบางอย่างในขอบเขต JavaScript ↔ WebAssembly ด้วย เช่น การลืมเรียก .delete() หรือการส่งผ่านพอยน์เตอร์ที่ไม่ถูกต้องจากฝั่ง JavaScript
  • หากเป็นไปได้ ให้หลีกเลี่ยงการแสดงข้อมูลและออบเจ็กต์ที่ไม่ได้รับการจัดการจาก WebAssembly ไปยัง JavaScript ทั้งหมด JavaScript เป็นภาษาที่มีการรวบรวมขยะ และการจัดการหน่วยความจําด้วยตนเองนั้นไม่พบบ่อยนัก กรณีนี้อาจถือเป็นการรั่วไหลของการแยกแยะรูปแบบหน่วยความจำของภาษาที่ใช้สร้าง WebAssembly และการจัดการที่ไม่ถูกต้องนั้นมองข้ามได้ง่ายในโค้ดฐาน JavaScript
  • เรื่องนี้อาจดูชัดเจน แต่เช่นเดียวกับโค้ดเบสอื่นๆ โปรดหลีกเลี่ยงการจัดเก็บสถานะที่เปลี่ยนแปลงได้ในตัวแปรส่วนกลาง คุณไม่ต้องการแก้ไขข้อบกพร่องเกี่ยวกับการใช้ซ้ำในคำเรียกใช้ต่างๆ หรือแม้แต่ในเธรดต่างๆ ดังนั้นคุณควรทำให้ฟังก์ชันนี้ทำงานได้ด้วยตัวเองมากที่สุด