Web Worker에서 현재 thorvg.web이 지원이 어렵다는 이슈 내용을 보고, 우선 WebWorker에 대해 알아보는 것이 좋다고 판단하여 작성하게 되었다.
목차
1. Web Worker
Web Worker는 js 코드가 웹 어플리케이션의 메인 스레드와 분리된 별도의 백그라운드 스레드에서 실행되게 하는 Web API다. 이를 통해 이미지 처리, 소프트웨어 렌더링처럼 계산량이 큰 작업을 Web Worker로 옮겨서 처리하면 무거운 작업이 메인 스레드를 점유하는 시간을 줄여서 메인 스레드의 부담을 줄일 수 있다.
다만 Worker 생성, 스크립트와 WASM 로딩, 메세지 전달 등의 추가 비용이 발생하기 때문에 작업 자체는 짧지만 큰 데이터를 계속 복제할 경우 Worker 사용으로 얻는 이점보다 추가 비용이 더 커질 수 있으므로 해당 부분을 고려하여 Web Worker 적용을 결정해야 한다.
1.1. Worker types
Worker은 Worker를 누가 사용할 수 있는지 여부에 따라 Dedicated worker, Shared Worker로 나뉘고, 두 Worker과 기능이 다른 Service Worker로 나뉜다.
Dedicated Worker은 하나의 스크립트에서 생성되고, 해당 worker를 생성한 스크립트에서만 접근 가능한 Worker이다. 통신할 때는 port를 명시적으로 다루는 것이 아닌 postMessage()와 onmessage를 이용해 생성한 스크립트와 통신한다. (Shared Worker과 다르게 port 객체를 명시적으로 다루지 않는다.)
Shared Worker은 여러 스크립트에서 하나의 Worker을 공유 가능하며, 서로 다른 window, iframe, 다른 worker에서도 같은 Shared Worker에 접근 가능하다. 다만 여러 browsing context가 공유하려면 같은 프로토콜, 호스트, 포트를 갖고 있어야 가능하다. 또한 port 객체를 통해 통신하는데, 이 때 사용되는 port는 명시적으로 열리게 된다.
Service Worker은 웹 애플리케이션과 브라우저/네트워크 사이에서 proxy 역할을 한다. 네트워크 요청을 가로채서 네트워크 연결 상태나 서버에 업데이트된 리소스가 있는지 여부를 확인하여 백그라운드 동기화 API, 푸시 알림을 사용할 수 있게 지원한다.
그 외에 Web Audio 파이프라인에서 오디오 처리를 하기 위한 Audio Worklet 등이 있다.
또한 Worker()의 options.type으로 classic과 module로 나뉘는데, 이 기준은 Worker 안에서 js 코드를 어떤 모듈체계로 불러오고 실행하는지에 따라 나뉜다.
Classic Worker은 importScripts()로 스크립트를 가져오며 동기적으로 no-cors 모드에서 로드된다. 또한 필요한 다른 스크립트를 Worker 코드가 실행되는 중간에 importScripts()를 호출해 즉시 불러올 수 있다.
Module Worker은 import로 스크립트를 가져오며 비동기적으로 CORS 모드에서 로드된다. 또한 실행 전에 import를 따라가며 module graph를 먼저 해석 후 연결한 뒤, ES Module 규칙에 따라 실행한다.
여기서 두 가지 구별 방식은 서로 독립적이어서 Dedicated Worker이면서 Module Worker일 수도 있다.
2. 메세지 전달과 자원 소유권
2.1. Worker의 데이터 전달 방법
메인 스레드와 Worker은 postMessage()로 메세지를 보내고, message 이벤트로 받는다. 일반 데이터 전달에는 structured clone 알고리즘이 사용된다. 이 알고리즘은 전달 가능한 js값을 수신 측 실행 환경에 복제하기 위해 사용하는 방법으로, 객체의 값을 복사하여 Worker에 보내기 때문에 송신 측 값과 별개이다. 여기서 일반 객체, 배열, ArrayBuffer 등은 복제되지만 함수와 DOM 노드는 복제할 수 없다.
2.2. 복제와 transfer의 차이
복제가 아닌 transfer이 가능한 객체의 경우, 일반 ArrayBuffer를 transfer하면 버퍼의 메모리 자원 사용 권한이 Worker로 이동한다. 그래서 송신 측 버퍼는 detached 상태가 되어 원래 데이터를 사용할 수 없게 된다. 아래 코드에서 두 번째 인수가 transfer list로 어떤 자원을 transfer할지만 지정한다. 그래서 수신 측에서 전달받은 객체에 접근하기 위해 메세지 데이터에도 포함시켜야 한다. 즉, transfer은 복제와 다르게 자원의 사용 권한을 수신 측으로 이동시켜 송신 측이 전송 후에도 사용할 수 없게 설정하며, 버퍼의 경우 복사 비용을 줄일 수 있게 되는 강점이 있다.
// ArrayBuffer을 Worker로 transfer하는 예시
const buffer = new ArrayBuffer(1024);
worker.postMessage({ type: "data", buffer }, [buffer]);
#225 이슈에서 요구하는 OffscreenCanvas도 transferable 객체이다. 그러므로 transfer 후에는 송신 측에서 해당 자원을 이용할 수 없으며, 실제 자원 이전 방식은 객체 종류에 따라 다를 수 있다.
2.3. 메세지 흐름 제어
Worker에서 js 코드 작업은 run-to-completion으로 실행되지만, 메세지 핸들러가 await에서 실행권을 반환할 수도 있다. 그러면 초기화 Promise가 완료되기 전 다른 message 이벤트가 처리될 수 있어서 ready 이후에 tick을 보내거나, Worker에서 초기화 완료를 기다리도록 처리해야 한다.
또한 postMessage()는 전달할 데이터를 준비한 뒤, 수신 측 MessagePort에 대응하는 이벤트 루프에 message 작업을 등록하는 함수이다. 메세지 전송 속도가 Worker의 처리 속도보다 빠르면 오래된 tick이 대기열에 쌓여 대기가 길어질 수 있다. 그래서 렌더링 완료 응답을 받은 뒤 다음 프레임을 보내거나, 처리되지 않은 경우 최신 tick만 유지하는 흐름 제어를 고려해야 한다.
3. 현재 WebCanvas의 초기화 및 SW 출력 경로
우선 WebCanvas가 초기화부터 SW 결과 출력까지 어떤 경로로 실행되는지 정리하면 다음과 같다.
- WebCanvas 모듈을 가져온다.
ThorVG.init()이 WASM 모듈을 생성하고 renderer을 설정한다.Canvas생성자가 네이티브 Canvas와 HTML 출력 대상을 준비한다.- 애니메이션 상태와 Canvas를 갱신한다.
render()에서 draw, sync를 수행한다.- SW 출력을 읽어 2D context에 표시한다.
- 사용 종료 시 관련 리소스들을 정리한다.
여기서 SW 엔진의 타겟은 메모리에 할당한 픽셀 버퍼이다.
// tvgWasmWebCanvas.cpp
buffer = (uint8_t*)std::malloc(w * h * sizeof(uint32_t));
static_cast<SwCanvas*>(canvas)->target((uint32_t*)buffer, w, w, h, ColorSpace::ABGR8888S);
js에서는 엔진 출력으로 ImageData를 만들어서 표시한다.
// Canvas.ts
const buffer = this.#engine.render();
const size = this.#engine.size();
const ctx = this.#htmlCanvas.getContext('2d') as CanvasRenderingContext2D;
const imageData = new ImageData(
new Uint8ClampedArray(buffer),
size.width,
size.height
);
4. DOM, 브라우저 API의 의존성 조사
위에서 WebCanvas의 실행 과정을 알아보았으므로, 어느 지점에서 DOM과 브라우저 전역 API에 의존하는지 정리해볼 것이다.
현재 Canvas 생성자는 selector 문자열을 받고, 이후 DOM 조회와 CSS 설정을 수행한다.
// canvas.ts
constructor(selector: string, options: CanvasOptions = {}
this.#htmlCanvas = document.querySelector(selector);
this.#htmlCanvas.style.width = `${width}px`;
this.#htmlCanvas.style.height = `${height}px`;
DPR 계산은 다음과 같은 공식을 사용한다. 그래서 원본 DPR과 실제 렌더링 배율에 차이가 생기기 때문에 Worker에서 배율을 외부에서 제공하도록 처리한다면 원본 DPR을 아래 배율을 적용할 것인지 원본을 전달할 것인지에 대해서도 결정해야 한다.
// canvas.ts
return 1 + (window.devicePixelRatio - 1) * 0.75;
4.1. 가드
registry.ts에 브라우저 여부를 확인하는 가드가 존재한다. 이 코드로 인해 브라우저 메인 스레드에서만 사용하는 정리를 다른 환경에서 실행하지 않도록 막는다.
// registry.ts
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => {
if (hasModule()) {
const Module = getModule();
Module.term();
}
});
}
하지만 Worker의 경우 beforeunload를 이용한 자동 Module.term()이 실행되지 않는다. 현재 코드에서 WasmObject.ts에서 dispose(), Canvas.ts에서 destroy(), index.ts에서 전체 ThorVG 모듈의 term()이 존재하므로 Worker 종료 전에 해당 명시적 정리 순서 역시 같이 호출 가능해야 할 것이다.
registry.ts처럼 window 존재 여부를 확인하는 가드가 있지만, 현재 Canvas의 생성 및 렌더링 경로에는 Worker/DOM-less 환경에서 문제가 될 수 있는 window, HTML Canvas 의존성이 아직 남아 있다.
생성자는 DPR을 계산하기 위해 window.devicePixelRatio를 참조하고, 이후 document.querySelector(selector)로 HTML Canvas를 찾은 뒤 style.width와 style.height를 설정하는데 이 부분 역시 Canvas 의존성이 함께 남아 있는 부분이다. 또한 render()는 #htmlCanvas가 없으면 실제 draw와 sync를 수행하기 전에 반환하므로 현재 구조에서는 HTML Canvas의 존재 여부가 렌더링 코어의 실행 조건에도 포함되어 있다.
또한 SW 렌더링 결과를 표시하는 경로도 HTMLCanvas에 역시 결합되어 있다. _updateHTMLCanvas()는 WASM에서 픽셀 버퍼와 크기를 가져온 뒤, #htmlCanvas.getContext('2d')로 얻은 context에 ImageData`를 표시한다.
해당 코드들을 확인해 보면 selector 해석과 DOM 조회, DPR 처리, SW 버퍼 출력 등이 하나의 Canvas 구현에 결합되어 있다고 판단한다. 그래서 공통 Canvas는 엔진의 렌더링 흐름을 유지하고, 환경에 따라 달라지는 동작은 별도의 surface가 담당하도록 책임을 분리하는 방향을 고민할 수 있다. 해당 부분은 #368에서 확인할 수 있다.
댓글
Discussion 원문