Emscripten을 사용하여 WebAssembly의 메모리 누수 디버깅

JavaScript는 자체 정리 시 상당히 관대하지만 정적 언어는 그렇지 않습니다.

Squoosh.app은 다양한 이미지 코덱과 설정이 품질에 큰 영향을 주지 않으면서 이미지 파일 크기를 얼마나 개선할 수 있는지 보여주는 PWA입니다. 하지만 C++ 또는 Rust로 작성된 라이브러리를 웹으로 가져오는 방법을 보여주는 기술 데모이기도 합니다.

기존 생태계에서 코드를 포팅할 수 있다는 것은 매우 중요한 일이지만 이러한 정적 언어와 JavaScript 사이에는 몇 가지 주요 차이점이 있습니다. 그중 하나는 메모리 관리에 대한 접근 방식이 다르다는 점입니다.

JavaScript는 자체 정리 시 상당히 관대하지만 이러한 정적 언어는 그렇지 않습니다. 새 할당 메모리를 명시적으로 요청해야 하며 나중에 메모리를 반환하고 다시는 사용하지 않아야 합니다. 그렇지 않으면 누수가 발생하며, 실제로는 상당히 자주 발생합니다. 이러한 메모리 누수를 디버그하는 방법과 다음에 메모리 누수가 발생하지 않도록 코드를 설계하는 방법을 살펴보겠습니다.

의심스러운 패턴

최근 Squoosh 작업을 시작하면서 C++ 코덱 래퍼에서 흥미로운 패턴을 발견했습니다. 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는 WebAssembly(Wasm) 메모리 버퍼에 의해 지원되는 JavaScript Uint8Array를 반환하며, byteOffsetbyteLength는 지정된 포인터 및 길이로 설정됩니다. 요점은 이것이 JavaScript 소유의 데이터 사본이 아니라 WebAssembly 메모리 버퍼의 TypedArray 라는 것입니다.

JavaScript에서 free_result를 호출하면 free_result가 표준 C 함수 free를 호출하여 이 메모리를 향후 할당에 사용할 수 있는 것으로 표시합니다. 즉, Uint8Array 뷰가 가리키는 데이터는 향후 Wasm을 호출할 때 임의의 데이터로 덮어쓸 수 있습니다.

또는 free의 일부 구현에서 확보한 메모리를 즉시 0으로 채우기로 결정할 수도 있습니다. Emscripten에서 사용하는 free는 그렇게 하지 않지만 여기서는 보장할 수 없는 구현 세부정보를 사용하고 있습니다.

또는 포인터 뒤의 메모리가 보존되더라도 새 할당으로 WebAssembly 메모리를 늘려야 할 수 있습니다. WebAssembly.Memory가 JavaScript API 또는 상응하는 memory.grow 명령어를 통해 확장되면 기존 ArrayBuffer가 무효화되고 이에 의해 지원되는 모든 뷰도 전이적으로 무효화됩니다.

DevTools(또는 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

마지막으로 free_resultnew Uint8ClampedArray 사이에 Wasm을 다시 명시적으로 호출하지 않더라도 어느 시점에는 코덱에 멀티스레딩 지원을 추가할 수 있습니다. 이 경우 클론하기 직전에 데이터를 덮어쓰는 완전히 다른 스레드일 수 있습니다.

메모리 버그 찾기

만약을 위해 이 코드가 실제로 문제가 있는지 확인해 보겠습니다. 작년에 추가되었으며 Chrome Dev Summit의 WebAssembly 강연에서 소개된 새로운 Emscripten 정리기 지원을 사용해 볼 좋은 기회입니다.

여기서는 다양한 포인터 및 메모리 관련 문제를 감지할 수 있는 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는 누수를 콘솔에 출력하지만 애플리케이션은 계속 실행하려는 경우와 같이 라이브러리 사용 사례에 더 적합합니다.

언제든지 JavaScript에서 호출할 수 있도록 Embind를 통해 두 번째 도우미를 노출해 보겠습니다.

#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 측에서 호출합니다. C++가 아닌 JavaScript 측에서 이를 실행하면 이러한 검사를 실행할 때까지 모든 범위가 종료되고 모든 임시 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

훨씬 나아졌습니다.

GenericBindingType RawImage ::toWireType 함수에서 &#39;Direct leak of 12 bytes&#39;라는 메시지가 표시되는 메시지의 스크린샷

스택 트레이스의 일부는 Emscripten 내부로 연결되므로 여전히 모호해 보이지만, Embind의 RawImage 변환이 'wire type'(JavaScript 값)으로 이루어지면서 누수가 발생한 것으로 알 수 있습니다. 실제로 코드를 살펴보면 RawImage C++ 인스턴스를 JavaScript로 반환하지만 어느 쪽에서도 해제하지 않는 것을 확인할 수 있습니다.

참고로 현재 JavaScript와 WebAssembly 간에 가비지 컬렉션 통합은 없지만 개발 중입니다. 대신 객체를 사용한 후에는 메모리를 수동으로 해제하고 JavaScript 측에서 소멸자를 호출해야 합니다. 특히 Embind의 경우 공식 문서에서는 노출된 C++ 클래스에서 .delete() 메서드를 호출하도록 제안합니다.

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의 메모리 대상을 할당하는 데 사용하는 함수)가 0이 아닌 경우 기존 outbufferoutsize 값을 재사용한다는 것을 알 수 있습니다.

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) {
  // …
}

그중 일부가 첫 실행에서 지연 초기화된 후 이후 실행에서 부적절하게 재사용되면 어떻게 될까요? 그러면 정리 도구를 한 번만 호출해도 문제가 있는 것으로 보고되지 않습니다.

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

이러한 메모리 버그를 하나씩 계속 찾아낼 수 있지만, 이제는 메모리 관리에 대한 현재의 접근 방식이 몇 가지 불쾌한 체계적 문제로 이어진다는 것이 충분히 명확해졌습니다.

일부는 소독제에 즉시 잡힐 수 있습니다. 다른 경우에는 복잡한 트릭을 사용해야 잡을 수 있습니다. 마지막으로 게시물 시작 부분과 같이 로그에서 볼 수 있듯이 정리 도구에 전혀 포착되지 않는 문제가 있습니다. 이는 실제 오용은 정리 도구가 볼 수 없는 JavaScript 측에서 발생하기 때문입니다. 이러한 문제는 프로덕션 단계에서 또는 향후 코드에 관련이 없어 보이는 변경사항을 적용해야만 드러납니다.

안전한 래퍼 빌드

한 걸음 물러나서 더 안전한 방식으로 코드를 재구성하여 이러한 문제를 모두 해결해 보겠습니다. ImageQuant 래퍼를 다시 예로 사용하겠지만, 유사한 리팩터링 규칙이 모든 코덱은 물론 기타 유사한 코드베이스에도 적용됩니다.

먼저 게시물의 시작 부분에서 use-after-free 문제를 해결하겠습니다. 이를 위해서는 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++ 래퍼를 리팩터링합니다. 그런 다음 포인터를 다시 수락하도록 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);
}

하지만 이미 Emscripten에서 Embind를 사용하여 JavaScript와 상호작용하고 있으므로 C++ 메모리 관리 세부정보를 완전히 숨겨 API를 더욱 안전하게 만들 수도 있습니다.

이를 위해 Embind를 사용하여 new Uint8ClampedArray(…) 부분을 JavaScript에서 C++ 측으로 이동해 보겠습니다. 그런 다음 이를 사용하여 함수에서 반환하기 전에 데이터를 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 메모리에 의해 지원되지 않도록 하고 또한 이전에 유출된 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);
}

또한 C++ 측에서 더 이상 맞춤 free_result 바인딩이 필요하지 않습니다.

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

전반적으로 래퍼 코드가 더 깔끔해지고 더 안전해졌습니다.

그런 다음 ImageQuant 래퍼의 코드를 약간 더 개선하고 다른 코덱에 유사한 메모리 관리 수정사항을 적용했습니다. 자세한 내용은 C++ 코덱의 메모리 수정에서 결과 PR을 확인하세요.

요약

이 리팩터링에서 얻은 교훈을 다른 코드베이스에 적용할 수 있는 방법은 무엇인가요?

  • 단일 호출을 넘어서는 경우 WebAssembly로 지원되는 메모리 뷰를 사용하지 마세요. 빌드된 언어와 관계가 없습니다. 그보다 오래 지속된다고 기대할 수 없으며 기존 수단으로는 이러한 버그를 포착할 수 없으므로 나중에 사용할 데이터를 저장해야 하는 경우 JavaScript 측으로 복사하여 저장하세요.
  • 가능하면 원시 포인터를 직접 사용하는 대신 안전한 메모리 관리 언어 또는 적어도 안전한 유형 래퍼를 사용하세요. 이렇게 해도 JavaScript ↔ WebAssembly 경계의 버그를 방지할 수는 없지만, 적어도 정적 언어 코드로 자체 포함된 버그의 노출 영역은 줄어듭니다.
  • 사용하는 언어와 관계없이 개발 중에 정리 도구를 사용하여 코드를 실행합니다. 정리 도구는 정적 언어 코드의 문제뿐만 아니라 JavaScript ↔ WebAssembly 경계에서 발생하는 일부 문제(예: .delete() 호출을 잊어버리거나 JavaScript 측에서 잘못된 포인터를 전달하는 경우)도 포착하는 데 도움이 됩니다.
  • 가능하면 WebAssembly에서 JavaScript로 관리되지 않는 데이터와 객체를 노출하지 마세요. JavaScript는 가비지 컬렉션 언어이며 수동 메모리 관리는 일반적이지 않습니다. 이는 WebAssembly가 빌드된 언어의 메모리 모델의 추상화 누출로 간주될 수 있으며, 잘못된 관리는 JavaScript 코드베이스에서 간과하기 쉽습니다.
  • 이 점은 분명하지만 다른 코드베이스에서와 마찬가지로 전역 변수에 변경 가능한 상태를 저장하지 마세요. 여러 호출 또는 스레드에서 재사용 시 발생하는 문제를 디버그하고 싶지 않으므로 최대한 독립형으로 유지하는 것이 좋습니다.