요약
- WASM 포인터는 전역에서 유일한 객체 ID가 아니라 특정 linear memory 안의 숫자 오프셋이다.
- WebCanvas의 오래된 래퍼가 출신 runtime을 기억하지 못하면, 지연된 cleanup이 새 runtime의 같은 숫자 포인터에 전달될 수 있다.
- 최소 C++/WASM 실험과 실제 WebCanvas 번들에서 이 경로를 재현했고, 사용자 호출 없이 GC만으로 현재 객체의 참조 카운트가 감소하는 것도 확인했다.
- 반복적인
term()/init()은 권장 사용 패턴이 아니고 대응 API도 있다.
시작은 React Strict Mode에서 만난 한 번의 오류
해커톤에서 ThorBridge라는 React 기반 게임을 만들며 WebCanvas를 사용했다. 외부 라이브러리를 정리한다는 생각으로 useEffect의 cleanup에서 term()을 호출했고, 개발 모드의 StrictMode가 setup과 cleanup을 추가로 실행하는 과정에서 다음 오류를 한 번 만났다.
RuntimeError: table index is out of bounds
at .../thorvg.wasm:wasm-function[634]
...
at Canvas.update (.../webcanvas.esm.js)
처음에는 단순히 term()을 너무 자주 호출한 탓이라고 생각했다. 실제로 이 사용법은 뒤에 유진의 멘토님 대화하며 권장 패턴이 아니라는 것을 알게 됐다. 하지만 오류를 추적하는 과정에서 그와 별개로 설명할 가치가 있는 구조를 발견했다.
WebCanvas의 오래된 JavaScript 래퍼가 숫자 포인터만 가진 채 남아 있다면, term() 후 새로 초기화된 WASM 런타임에 그 포인터가 전달될 수 있었다. 이 문장을 이해하려면 먼저 WASM의 linear memory와 포인터가 무엇인지 살펴봐야 한다.
WASM linear memory에서 포인터는 무엇인가
WebAssembly의 linear memory는 0번부터 시작하는 연속된 바이트 배열이다. Emscripten으로 C/C++ 코드를 빌드하면 스택과 힙을 포함한 네이티브 데이터가 이 메모리 위에 놓이고, JavaScript에서는 HEAPU8 같은 TypedArray를 통해 같은 메모리를 볼 수 있다.
C++에서 객체를 생성하고 JavaScript에 포인터를 반환할 때 전달되는 값은 이 메모리 안의 숫자 오프셋이다. 예를 들어 721848이라는 값은 그 자체로 전역에서 유일한 객체 ID가 아니다. “어느 WASM 메모리의 721848번 위치인가”가 함께 있어야 의미가 생긴다.
WASM runtime A WASM runtime B
linear memory A linear memory B
offset 721848 -> Shape A offset 721848 -> Shape B
^ ^
서로 다른 객체지만 JavaScript에 보이는 숫자는 같다
동일한 Emscripten factory를 두 번 실행해 별도의 module instance를 만들면, 일반적으로 각 인스턴스는 독립된 linear memory와 C++ 정적 상태를 가진다. 두 메모리에서 같은 오프셋이 발급되는 것은 충돌이 아니라 자연스러운 일이다.
같은 런타임 안에서도 객체를 delete한 뒤 allocator가 그 자리를 새 객체에 재사용할 수 있다. 따라서 WASM에서 포인터는 객체를 완벽하게 구분짓는 것이 아니다.
이 실험을 통해 포인터를 다음처럼 이해하게 됐다.
native handle의 최소 문맥 = (originating runtime, numeric pointer)
여기서 originating runtime은 해당 포인터를 생성한 WASM 인스턴스다. stale handle까지 안전하게 구분하려면 객체의 생존 상태나 세대 정보도 필요할 수 있다.
JavaScript 객체와 WASM 객체의 수명은 서로 다르다
WebCanvas의 Shape 같은 객체는 두 세계에 걸쳐 있다.
JavaScript heap WASM linear memory
Shape wrapper ----------------------> C++ Paint/Shape
- ptr: number - 실제 렌더링 상태
- disposed 상태 - 참조 카운트
JavaScript의 가비지 컬렉터는 JavaScript 래퍼가 더 이상 도달 가능하지 않은지는 알 수 있지만, 숫자 포인터가 가리키는 C++ 객체의 소유권까지 자동으로 이해하지는 못한다. 그래서 WebCanvas는 두 정리 경로를 제공한다.
- 사용자가
dispose()를 호출하는 명시적 정리 FinalizationRegistry가 래퍼 수거 후 cleanup을 호출하는 자동 정리
명시적 정리는 실행 시점을 예측할 수 있다. 반면 FinalizationRegistry의 callback은 언제 실행될지, 프로그램 종료 전까지 실행되기는 할지 보장되지 않는다. 이 시간 차이가 이번 현상의 중요한 조건이었다.
WebCanvas에서 런타임 정보가 사라지는 지점
@thorvg/webcanvas 1.1.0의 WasmObject는 finalizer에 포인터와 cleanup 함수를 등록한다.
this.#registryToken = { ptr, cleanup: this._cleanup };
registry.register(this, this.#registryToken, this);
토큰에는 이 포인터를 발급한 WASM module instance가 들어 있지 않다. 실제 cleanup을 수행하는 Paint._cleanup()은 실행되는 순간의 전역 모듈을 조회한다.
protected _cleanup(ptr: number): void {
const Module = getModule();
Module._tvg_paint_unref(ptr, 1);
}
getModule()과 hasModule()은 globalThis.__ThorVGModule을 기준으로 동작한다. term()은 이 전역 참조를 지우고, 이후 init()은 새 module instance를 저장한다.
이 구조는 WASM 모듈을 세션 동안 하나만 유지할 때 단순하고 자연스럽다. 그러나 오래된 래퍼나 finalizer가 term() 경계를 넘어 살아남으면, cleanup의 대상은 “포인터를 만든 런타임”이 아니라 “cleanup이 실행된 순간의 런타임”이 된다.
문제가 만들어지는 순서
아래 순서에서 1세대와 2세대가 같은 숫자 오프셋을 발급했다고 가정한다.
1. runtime A를 init()
2. runtime A에서 old Shape 생성 old.ptr = 721848
3. old 래퍼의 JavaScript 참조가 사라짐 finalizer는 아직 실행되지 않음
4. runtime A를 term() 전역 Module = undefined
5. runtime B를 init() 전역 Module = runtime B
6. runtime B에서 fresh Shape 생성 fresh.ptr = 721848
7. old 래퍼의 finalizer가 뒤늦게 실행
8. getModule()이 runtime B를 반환
9. runtime B에서 unref(721848) 실행 fresh Shape의 참조가 감소
숫자 721848은 runtime A에서는 old Shape였지만 runtime B에서는 fresh Shape다. cleanup 함수가 출신 런타임을 잃어버렸기 때문에, 이미 종료된 세대의 정리가 현재 살아 있는 객체에 적용된다.
포인터 숫자가 우연히 같지 않아도 안전하다고 할 수는 없다. 새 메모리의 해당 위치가 다른 데이터이거나 유효하지 않은 영역일 수 있기 때문이다. 같은 숫자가 재발급되는 조건은 단지 문제를 관찰하기 쉽게 만들어준다.
검증하기
wasm 모듈을 만들어보며 직접 pointer 값이 어떻게 반환되는지 확인해보면 된다.
struct NativeObject {
int value;
inline static int destroyCount = 0;
};
extern "C" NativeObject* create_object (int value) {
NativeObject * obj = new NativeObject();
obj->value = value;
return obj;
}
extern "C" void destroy_object (NativeObject* object) {
delete object;
NativeObject::destroyCount += 1;
return;
}
extern "C" int read_value (const NativeObject* object) {
return object->value;
}
extern "C" int get_destroy_count () {
return NativeObject::destroyCount;
}
위 runtime.cpp를
em++ .\runtime.cpp -o .\runtime.mjs `
--no-entry `
-O0 `
-sASSERTIONS=2 `
-sMODULARIZE=1 `
-sEXPORT_ES6=1 `
-sENVIRONMENT=node `
'-sEXPORTED_FUNCTIONS=_create_object,_destroy_object,_read_value,_get_destroy_count'
emscripten으로 컴파일 한 후에 아래 스크립트를 실행해보면 된다.
import createRuntime from "./runtime.mjs";
let currentRuntime;
const initialize = async () => {
currentRuntime = await createRuntime();
return currentRuntime;
};
class NativeWrapper {
runtime;
pointer;
disposed = false;
constructor(value) {
this.runtime = currentRuntime;
this.pointer = this.runtime._create_object(value);
}
dispose () {
if (this.disposed) return;
this.runtime._destroy_object(this.pointer);
this.disposed = true;
this.pointer = 0;
}
read() {
if (this.disposed) {
throw Error("this class is already disposed.");
}
return this.runtime._read_value(this.pointer);
}
};
// 이하 문제 상황 재현
const firstRuntime = await initialize();
const oldObject = new NativeWrapper(111);
const secondRuntime = await initialize();
const newObject = new NativeWrapper(222);
console.log(`oldObject.read() === 111: ${oldObject.read() === 111}`);
const oldPointer = oldObject.pointer;
const newPointer = newObject.pointer;
console.log(`oldPointer === newPointer: ${oldPointer === newPointer}`);
oldObject.dispose();
oldObject.dispose();
console.log(`firstRuntime's destroy count: ${firstRuntime._get_destroy_count()}`);
console.log(`secondRuntime's destroy count: ${secondRuntime._get_destroy_count()}`);
if (firstRuntime._get_destroy_count() !== 1 || secondRuntime._get_destroy_count() !== 0) {
throw new Error("old cleanup targeted the wrong runtime");
}
console.log(`newObject.read() === 222: ${newObject.read() === 222}`);
newObject.dispose();
newObject.dispose();
console.log(`firstRuntime's destroy count: ${firstRuntime._get_destroy_count()}`);
console.log(`secondRuntime's destroy count: ${secondRuntime._get_destroy_count()}`);
try {
newObject.read();
} catch (err) {
console.log(err);
}
currentRuntime이 webcanvas의 전역 모듈 참조, NativeWrapper가 shape 객체라고 간주하면 런타임 모듈이 달라도 포인터가 동일해서 두번째 런타임의 객체의 메모리 해제를 시도한다는 것을 알 수 있다.
dispose를 잘못 호출한거 아닌지?
위 예제는 이미 term()한 runtime의 래퍼에 사용자가 직접 dispose()를 호출하는 잘못된 케이스라는 약점이 존재한다. 유진의 멘토님은 이를 “종료한 엔진의 객체를 다시 접근한 잘못된 사용”으로 볼 수 있으므로, 이 코드만으로 라이브러리 문제인지 판단하기 어렵다고 지적했다.
내가 겪었던 상황은 사용자가 직접 stale 객체에 접근하는 상황이 아니라, gc로 인해 사용자가 실행 시점을 통제할 수 없을 때 FinalizationRegistry로직으로 인해 shape 객체 메모리 해제가 일어나는 경로였다.
유진의 멘토님이 Chrome을 --expose-gc 옵션으로 실행해 GC를 수동으로 유도할 수 있다고 알려주셔서 이 조언을 바탕으로 dispose()가 한 번도 등장하지 않는 두 번째 재현을 만들었다.
async function generationOne() {
const first = await ThorVG.init({ renderer: "sw" });
const stale = new first.Shape();
const ptr = stale.ptr;
first.term();
// 함수가 끝나면 stale 래퍼는 도달 불가능해진다.
return ptr;
}
const stalePtr = await generationOne();
const second = await ThorVG.init({ renderer: "sw" });
const fresh = new second.Shape();
fresh.appendRect(10, 10, 100, 100).fill(0, 128, 255, 255);
const canvas = new second.Canvas("#repro", {
width: 120,
height: 120,
enableDevicePixelRatio: false,
});
canvas.add(fresh).render(); // 관찰 시 refCnt === 2
await forceFinalizers(); // globalThis.gc() 반복
잘못된 시점에 dispose를 호출하지 않고 gc로 인해 shape 메모리 해제가 일어나는 것을 확인할 수 있었다. 그러니까 사용자가 stale 래퍼의 cleanup을 직접 호출하지 않아도 동일한 현상이 발생했다.
그래서 이걸 지원해야하나..?
GC 경로까지 전달한 뒤 논의는 “이 메커니즘이 가능한가” 보다 “이 수명주기를 라이브러리가 지원해야 하는가”에 집중됐다. 대화에서 이해한 WebCanvas의 설계 전제는 다음과 같다.
WASM 모듈은 세션 안에서 하나를 유지한다
사용자가 원하는 것은 보통 여러 개의 module instance가 아니라 여러 Canvas와 Shape다. WebCanvas는 하나의 전역 모듈을 여러 객체가 공유하는 모델을 전제로 한다. WebGPU의 device와 adapter처럼 공유되는 자원과 최적화도 이 전제에 연결돼 있다.
MODULARIZE=1의 기능과 라이브러리의 지원 정책은 다르다
Emscripten의 MODULARIZE=1 결과물은 펙토리 함수 형태이고, 기술적으로 여러 module instance를 만들 수 있다. 하지만 WebCanvas에서의 주된 사용 목적은 Emscripten/WASM 관련 심벌이 window 전역을 오염시키지 않도록 감싸는 것이었다. 여러 인스턴스를 만들 수 있다는 툴체인의 기능이 곧 WebCanvas가 다중 runtime 사용을 지원한다는 뜻은 아니었다.
term()은 반복적인 컴포넌트 cleanup 훅으로 상정되지 않았다
term() 뒤 다시 init()하는 것은 코드상 가능하지만 term()은 동일 세션에서 컴포넌트가 mount/unmount될 때마다 호출하는 API가 아니라 ThorVG를 더 이상 사용하지 않을 때 관련 자원을 정리하기 위한 API에 가깝다고 했다.
나중에 다시 사용할 가능성이 있다면 Canvas와 객체를 정리하되 WASM 모듈은 세션 동안 유지하는 것이 권장 방향이었다.
예측 가능한 정리가 필요하면 dispose()를 사용
FinalizationRegistry는 보조적인 자동 정리 수단이고 실행 시점을 보장하지 않는다. 특정 시점 전에 네이티브 객체가 반드시 해제돼야 한다면, 현재 라이브러리의 지침은 명시적으로 dispose()를 호출하는 것이다.
결론
유진의 멘토님은 위의 시나리오와 문제 요소를 파악했으나 당장 라이브러리에서 처리할지는 판단하기 이르다고 봤다.
- 한 세션에서
term()과init()을 반복하는 상황이 일반적인 사용 사례로 보이지 않음 - 예측 가능한 메모리 관리를 위한
dispose()API가 이미 있음 - ThorVG는 simple is the best 를 지향하며, 드문 사례를 위한 방어 코드도 복잡도 대비 가치가 있는지 검토해야 함
- 같은 문제를 겪는 사용자가 늘어나면 라이브러리 차원의 처리를 다시 검토할 수 있음
WebCanvas 수명주기 원칙
- WebCanvas module은 가능하면 페이지 세션 동안 하나만 초기화해 재사용한다.
- 다시 사용할 가능성이 있는 React 컴포넌트 cleanup에서는 곧바로
term()하지 않고 Canvas와 소유 객체의 정리를 우선한다. - 종료 순서를 결정적으로 제어해야 한다면 GC에만 의존하지 말고 살아 있는 객체에
dispose()를 호출한 뒤term()한다. - 숫자 WASM 포인터를 오래 보관해야 하는 바인딩을 설계한다면, 포인터를 만든 runtime과 객체의 생존 상태를 함께 관리한다.
FinalizationRegistry는 편리한 안전망일 수 있지만 명시적 자원 수명주기를 대신하는 결정적 destructor는 아니다.
마치며
이번 검증을 통해 숫자 포인터는 주소 공간과 수명주기 없이 해석할 수 없다는 점, JavaScript GC와 C++ 자원 정리의 경계에서는 실행 시점뿐 아니라 어느 runtime의 자원을 정리하는가가 중요하다는 점을 배웠다. 또한 오픈소스 기여는 코드를 제출하는 일만이 아니라, 재현 가능한 근거를 만들고 프로젝트의 설계 원칙과 지원 범위를 이해하는 과정이라는 것도 경험했다.
댓글
Discussion 원문