다른 언어로 작성된 멀티스레드 애플리케이션을 WebAssembly로 가져오는 방법을 알아봅니다.
WebAssembly 스레드 지원은 WebAssembly에 추가된 가장 중요한 성능 기능 중 하나입니다. 이를 통해 코드의 일부를 별도의 코어에서 병렬로 실행하거나 입력 데이터의 독립적인 부분에서 동일한 코드를 실행하여 사용자의 코어 수만큼 확장하고 전체 실행 시간을 크게 줄일 수 있습니다.
이 도움말에서는 WebAssembly 스레드를 사용하여 C, C++, Rust와 같은 언어로 작성된 멀티스레드 애플리케이션을 웹에 가져오는 방법을 설명합니다.
WebAssembly 스레드의 작동 방식
WebAssembly 스레드는 별도의 기능이 아니라 WebAssembly 앱이 웹에서 기존 멀티스레딩 패러다임을 사용할 수 있도록 하는 여러 구성요소의 조합입니다.
웹 작업자
첫 번째 구성요소는 JavaScript에서 익히 알고 좋아하는 일반 Worker입니다. WebAssembly 스레드는 new Worker
생성자를 사용하여 새 기본 스레드를 만듭니다. 각 스레드는 JavaScript 글루를 로드한 다음 기본 스레드는 Worker#postMessage
메서드를 사용하여 컴파일된 WebAssembly.Module
및 공유된 WebAssembly.Memory
(아래 참고)를 다른 스레드와 공유합니다. 이렇게 하면 통신이 설정되고 모든 스레드가 JavaScript를 다시 거치지 않고도 동일한 공유 메모리에서 동일한 WebAssembly 코드를 실행할 수 있습니다.
웹 워커는 10년 넘게 사용되어 왔으며, 널리 지원되고 있으며, 특별한 플래그가 필요하지 않습니다.
SharedArrayBuffer
WebAssembly 메모리는 JavaScript API에서 WebAssembly.Memory
객체로 표현됩니다. 기본적으로 WebAssembly.Memory
는 단일 스레드에서만 액세스할 수 있는 원시 바이트 버퍼인 ArrayBuffer
를 래핑하는 래퍼입니다.
> new WebAssembly.Memory({ initial:1, maximum:10 }).buffer
ArrayBuffer { … }
멀티스레딩을 지원하기 위해 WebAssembly.Memory
에도 공유 변형이 추가되었습니다. JavaScript API를 통해 shared
플래그로 만들거나 WebAssembly 바이너리 자체에서 만들면 대신 SharedArrayBuffer
를 둘러싸는 래퍼가 됩니다. ArrayBuffer
의 변형으로, 다른 스레드와 공유할 수 있으며 양쪽에서 동시에 읽거나 수정할 수 있습니다.
> new WebAssembly.Memory({ initial:1, maximum:10, shared:true }).buffer
SharedArrayBuffer { … }
일반적으로 기본 스레드와 Web Worker 간의 통신에 사용되는 postMessage
와 달리 SharedArrayBuffer
는 데이터를 복사하거나 이벤트 루프가 메시지를 보내고 받을 때까지 기다릴 필요도 없습니다.
대신 모든 변경사항이 거의 즉시 모든 스레드에 표시되므로 기존 동기화 프리미티브에 훨씬 더 나은 컴파일 타겟이 됩니다.
SharedArrayBuffer
의 역사는 복잡합니다. 이 기능은 2017년 중반에 여러 브라우저에 처음 제공되었지만 Spectre 취약점이 발견되어 2018년 초에 사용 중지되었습니다. 스펙터의 데이터 추출이 특정 코드 조각의 실행 시간을 측정하는 타이밍 공격에 의존하기 때문입니다. 이러한 유형의 공격을 더 어렵게 만들기 위해 브라우저는 Date.now
및 performance.now
와 같은 표준 타이밍 API의 정밀도를 낮췄습니다. 그러나 공유 메모리는 별도의 스레드에서 실행되는 간단한 카운터 루프와 결합하여 정밀한 타이밍을 얻는 매우 안정적인 방법이기도 합니다. 런타임 성능을 크게 제한하지 않고는 완화하기가 훨씬 어렵습니다.
대신 Chrome 68(2018년 중반)에서는 사이트 격리를 활용하여 SharedArrayBuffer
를 다시 사용 설정했습니다. 사이트 격리는 여러 웹사이트를 서로 다른 프로세스로 분리하고 스펙터와 같은 측면 채널 공격을 훨씬 더 어렵게 만드는 기능입니다. 하지만 사이트 격리는 상당히 비용이 많이 드는 기능이므로 메모리가 부족한 휴대기기의 모든 사이트에 기본적으로 사용 설정할 수 없으며 다른 공급업체에서 아직 구현하지 않았기 때문에 이 완화 조치는 Chrome 데스크톱으로 제한되었습니다.
2020년으로 거슬러 올라가면 Chrome과 Firefox에는 모두 사이트 격리가 구현되어 있으며, 웹사이트에서 COOP 및 COEP 헤더를 사용하여 이 기능을 선택하는 표준 방법이 제공됩니다. 선택 메커니즘을 사용하면 모든 웹사이트에 사이트 격리를 사용 설정하는 것이 너무 비용이 많이 드는 저전력 기기에서도 사이트 격리를 사용할 수 있습니다. 선택하려면 서버 구성의 기본 문서에 다음 헤더를 추가합니다.
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin
선택하면 SharedArrayBuffer
(SharedArrayBuffer
로 지원되는 WebAssembly.Memory
포함), 정확한 타이머, 메모리 측정, 보안상의 이유로 격리된 출처가 필요한 기타 API에 액세스할 수 있습니다. 자세한 내용은 COOP 및 COEP를 사용하여 웹사이트 '교차 출처 격리'하기를 참고하세요.
WebAssembly 원자 연산
SharedArrayBuffer
를 사용하면 각 스레드가 동일한 메모리에 읽고 쓸 수 있지만, 올바른 통신을 위해서는 스레드가 충돌하는 작업을 동시에 실행하지 않도록 하는 것이 좋습니다. 예를 들어 한 스레드가 공유 주소에서 데이터 읽기를 시작하고 다른 스레드는 여기에 데이터를 쓸 수 있으므로 이제 첫 번째 스레드가 손상된 결과를 얻게 됩니다. 이러한 버그 카테고리를 경합 상태라고 합니다. 경합 상태를 방지하려면 이러한 액세스를 어떻게든 동기화해야 합니다.
여기에서 원자 연산이 사용됩니다.
WebAssembly Atoms는 WebAssembly 명령 집합에 대한 확장 프로그램으로, 데이터의 작은 셀(일반적으로 32비트 및 64비트 정수)을 '원자적으로' 읽고 쓸 수 있습니다. 즉, 두 스레드가 동시에 동일한 셀을 읽거나 쓰지 않도록 보장하여 낮은 수준에서 이러한 충돌을 방지합니다. 또한 WebAssembly 원자에는 두 가지 더 많은 명령어 유형('wait' 및 'notify')이 포함되어 있습니다. 이 두 유형은 한 스레드가 다른 스레드가 'notify'를 통해 이를 깨울 때까지 공유 메모리의 지정된 주소에서 절전 모드('wait')로 전환할 수 있도록 합니다.
채널, 뮤텍스, 읽기-쓰기 잠금을 비롯한 모든 상위 수준의 동기화 프리미티브는 이러한 명령을 기반으로 합니다.
WebAssembly 스레드 사용 방법
기능 감지
WebAssembly 원자 및 SharedArrayBuffer
는 비교적 새로운 기능이며 아직 WebAssembly를 지원하는 모든 브라우저에서 사용할 수 없습니다. webassembly.org 로드맵에서 새로운 WebAssembly 기능을 지원하는 브라우저를 확인할 수 있습니다.
모든 사용자가 애플리케이션을 로드할 수 있도록 하려면 멀티스레딩 지원이 있는 버전과 지원이 없는 버전의 두 가지 Wasm 버전을 빌드하여 점진적 개선을 구현해야 합니다. 그런 다음 특성 감지 결과에 따라 지원되는 버전을 로드합니다. 런타임 시 WebAssembly 스레드 지원을 감지하려면 wasm-feature-detect 라이브러리를 사용하고 다음과 같이 모듈을 로드합니다.
import { threads } from 'wasm-feature-detect';
const hasThreads = await threads();
const module = await (
hasThreads
? import('./module-with-threads.js')
: import('./module-without-threads.js')
);
// …now use `module` as you normally would
이제 WebAssembly 모듈의 멀티스레드 버전을 빌드하는 방법을 살펴보겠습니다.
C
C에서는 특히 Unix와 유사한 시스템에서 스레드를 사용하는 일반적인 방법은 pthread
라이브러리에서 제공하는 POSIX 스레드를 사용하는 것입니다. Emscripten은 웹 워커, 공유 메모리, 원자 위에 빌드된 pthread
라이브러리의 API 호환 구현을 제공하므로 동일한 코드가 변경 없이 웹에서 작동할 수 있습니다.
예를 살펴보겠습니다.
example.c:
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
void *thread_callback(void *arg)
{
sleep(1);
printf("Inside the thread: %d\n", *(int *)arg);
return NULL;
}
int main()
{
puts("Before the thread");
pthread_t thread_id;
int arg = 42;
pthread_create(&thread_id, NULL, thread_callback, &arg);
pthread_join(thread_id, NULL);
puts("After the thread");
return 0;
}
여기서 pthread
라이브러리의 헤더는 pthread.h
를 통해 포함됩니다. 스레드를 처리하는 데 필요한 몇 가지 중요한 함수도 볼 수 있습니다.
pthread_create
는 백그라운드 스레드를 만듭니다. 스레드 핸들을 저장할 대상, 일부 스레드 생성 속성(여기서는 전달하지 않으므로 NULL
), 새 스레드에서 실행할 콜백(여기서는 thread_callback
), 기본 스레드의 일부 데이터를 공유하려는 경우 해당 콜백에 전달할 선택적 인수 포인터(이 예에서는 변수 arg
에 대한 포인터)를 사용합니다.
pthread_join
는 나중에 언제든지 호출하여 스레드가 실행을 완료할 때까지 기다리고 콜백에서 반환된 결과를 가져올 수 있습니다. 이전에 할당된 스레드 핸들과 결과를 저장하기 위한 포인터를 받습니다. 이 경우 결과가 없으므로 함수는 NULL
를 인수로 사용합니다.
Emscripten이 포함된 스레드를 사용하여 코드를 컴파일하려면 다른 플랫폼에서 Clang 또는 GCC로 동일한 코드를 컴파일할 때와 같이 emcc
를 호출하고 -pthread
매개변수를 전달해야 합니다.
emcc -pthread example.c -o example.js
하지만 브라우저나 Node.js에서 실행하려고 하면 경고가 표시되고 프로그램이 중단됩니다.
Before the thread
Tried to spawn a new thread, but the thread pool is exhausted.
This might result in a deadlock unless some threads eventually exit or the code
explicitly breaks out to the event loop.
If you want to increase the pool size, use setting `-s PTHREAD_POOL_SIZE=...`.
If you want to throw an explicit error instead of the risk of deadlocking in those
cases, use setting `-s PTHREAD_POOL_SIZE_STRICT=2`.
[…hangs here…]
어떻게 된 것일까요? 문제는 웹에서 시간이 많이 걸리는 대부분의 API가 비동기식이며 실행하려면 이벤트 루프를 사용해야 한다는 점입니다. 이 제한은 애플리케이션이 일반적으로 동기식 차단 방식으로 I/O를 실행하는 기존 환경과 비교할 때 중요한 차이점입니다. 자세한 내용은 WebAssembly에서 비동기 웹 API 사용에 관한 블로그 게시물을 참고하세요.
이 경우 코드는 동기적으로 pthread_create
를 호출하여 백그라운드 스레드를 만들고 백그라운드 스레드의 실행이 완료될 때까지 기다리는 pthread_join
의 또 다른 동기 호출을 따릅니다. 그러나 이 코드가 Emscripten으로 컴파일될 때 백그라운드에서 사용되는 웹 워커는 비동기식입니다. 따라서 pthread_create
는 다음 이벤트 루프 실행 시 생성될 새 Worker 스레드만 예약하지만 pthread_join
는 즉시 이벤트 루프를 차단하여 해당 Worker를 기다리며, 이렇게 하면 Worker가 생성되지 않습니다. 이는 교착 상태의 전형적인 예입니다.
이 문제를 해결하는 한 가지 방법은 프로그램이 시작되기도 전에 작업자 풀을 미리 만드는 것입니다. pthread_create
가 호출되면 풀에서 즉시 사용할 수 있는 Worker를 가져와 백그라운드 스레드에서 제공된 콜백을 실행하고 Worker를 풀에 다시 반환할 수 있습니다. 이 모든 작업은 동기식으로 실행될 수 있으므로 풀이 충분히 큰 한 교착 상태가 발생하지 않습니다.
이것이 바로 Emscripten이 -s
PTHREAD_POOL_SIZE=...
옵션에서 허용하는 방식입니다. 이를 통해 고정된 수 또는 navigator.hardwareConcurrency
와 같은 JavaScript 표현식으로 스레드 수를 지정하여 CPU의 코어 수만큼 스레드를 만들 수 있습니다. 후자의 옵션은 코드가 임의의 스레드 수로 확장될 수 있는 경우에 유용합니다.
위 예시에서는 생성되는 스레드가 하나뿐이므로 모든 코어를 예약하는 대신 -s PTHREAD_POOL_SIZE=1
를 사용하면 충분합니다.
emcc -pthread -s PTHREAD_POOL_SIZE=1 example.c -o example.js
이번에는 실행하면 제대로 작동합니다.
Before the thread
Inside the thread: 42
After the thread
Pthread 0x701510 exited.
하지만 다른 문제가 있습니다. 코드 예시에서 sleep(1)
를 확인해 보세요. 스레드 콜백에서 실행되므로 기본 스레드 외부에서 실행되므로 괜찮을 것입니다. 아니요.
pthread_join
가 호출되면 스레드 실행이 완료될 때까지 기다려야 합니다. 즉, 생성된 스레드가 장기 실행 작업(이 경우 1초 절전 모드)을 실행하는 경우 기본 스레드도 결과가 반환될 때까지 동일한 시간 동안 차단되어야 합니다. 이 JS가 브라우저에서 실행되면 스레드 콜백이 반환될 때까지 1초 동안 UI 스레드를 차단합니다. 이로 인해 사용자 환경이 저하됩니다.
이 문제의 해결 방법은 다음과 같습니다.
pthread_detach
-s PROXY_TO_PTHREAD
- 커스텀 작업자 및 Comlink
pthread_detach
첫째, 기본 스레드에서 일부 작업만 실행하고 결과를 기다릴 필요가 없는 경우 pthread_join
대신 pthread_detach
를 사용할 수 있습니다. 이렇게 하면 스레드 콜백이 백그라운드에서 실행됩니다. 이 옵션을 사용하는 경우 -s
PTHREAD_POOL_SIZE_STRICT=0
를 사용하여 경고를 사용 중지할 수 있습니다.
PROXY_TO_PTHREAD
두 번째로, 라이브러리가 아닌 C 애플리케이션을 컴파일하는 경우 -s
PROXY_TO_PTHREAD
옵션을 사용할 수 있습니다. 이 옵션을 사용하면 애플리케이션 자체에서 만든 중첩된 스레드 외에도 기본 애플리케이션 코드가 별도의 스레드로 오프로드됩니다. 이렇게 하면 기본 코드가 UI를 정지시키지 않고도 언제든지 안전하게 차단할 수 있습니다.
이 옵션을 사용할 때는 스레드 풀을 미리 만들 필요도 없습니다. 대신 Emscripten은 기본 스레드를 활용하여 새 기본 Worker를 만든 다음 교착 상태 없이 pthread_join
에서 도우미 스레드를 차단할 수 있습니다.
Comlink
세 번째로, 라이브러리에서 작업 중인데도 차단해야 하는 경우 자체 Worker를 만들고 Emscripten에서 생성된 코드를 가져와 Comlink를 사용하여 기본 스레드에 노출할 수 있습니다. 기본 스레드는 내보낸 메서드를 비동기 함수로 호출할 수 있으며, 이렇게 하면 UI가 차단되는 것도 방지할 수 있습니다.
이전 예와 같은 간단한 애플리케이션에서는 -s PROXY_TO_PTHREAD
가 가장 좋은 옵션입니다.
emcc -pthread -s PROXY_TO_PTHREAD example.c -o example.js
C++
C++에도 동일한 주의사항과 로직이 동일하게 적용됩니다. 유일하게 새로운 점은 이전에 설명한 pthread
라이브러리를 내부적으로 사용하는 std::thread
및 std::async
와 같은 상위 수준 API에 액세스할 수 있다는 점입니다.
따라서 위의 예는 다음과 같이 더 관용적인 C++로 다시 작성할 수 있습니다.
example.cpp:
#include <iostream>
#include <thread>
#include <chrono>
int main()
{
puts("Before the thread");
int arg = 42;
std::thread thread([&]() {
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "Inside the thread: " << arg << std::endl;
});
thread.join();
std::cout << "After the thread" << std::endl;
return 0;
}
유사한 매개변수로 컴파일 및 실행하면 C 예와 동일한 방식으로 작동합니다.
emcc -std=c++11 -pthread -s PROXY_TO_PTHREAD example.cpp -o example.js
출력:
Before the thread
Inside the thread: 42
Pthread 0xc06190 exited.
After the thread
Proxied main thread 0xa05c18 finished with return code 0. EXIT_RUNTIME=0 set, so
keeping main thread alive for asynchronous event operations.
Pthread 0xa05c18 exited.
Rust
Emscripten과 달리 Rust에는 특별한 엔드 투 엔드 웹 타겟이 없지만 대신 일반 WebAssembly 출력을 위한 일반적인 wasm32-unknown-unknown
타겟을 제공합니다.
Wasm을 웹 환경에서 사용하려는 경우 JavaScript API와의 모든 상호작용은 wasm-bindgen 및 wasm-pack과 같은 외부 라이브러리 및 도구에 맡깁니다. 안타깝게도 이는 표준 라이브러리가 웹 워커를 인식하지 못한다는 것을 의미하며 std::thread
와 같은 표준 API는 WebAssembly로 컴파일될 때 작동하지 않습니다.
다행히 대부분의 생태계는 상위 수준 라이브러리를 사용하여 멀티스레딩을 처리합니다. 이 수준에서는 모든 플랫폼 차이를 훨씬 더 쉽게 추상화할 수 있습니다.
특히 Rayon은 Rust에서 데이터 병렬 처리에 가장 많이 사용됩니다. 이를 통해 일반 반복자의 메서드 체이닝을 수행하고 일반적으로 한 줄만 변경하여 사용 가능한 모든 스레드에서 순차적으로 실행되는 대신 동시에 실행되도록 변환할 수 있습니다. 예를 들면 다음과 같습니다.
pub fn sum_of_squares(numbers: &[i32]) -> i32 {
numbers
.iter()
.par_iter()
.map(|x| x * x)
.sum()
}
이렇게 약간만 변경하면 코드가 입력 데이터를 분할하고, 동시 실행 스레드에서 x * x
및 부분 합계를 계산하고, 마지막에 이러한 부분 결과를 모두 더합니다.
std::thread
를 작동하지 않는 플랫폼을 수용하기 위해 Rayon은 스레드 생성 및 종료를 위한 맞춤 로직을 정의할 수 있는 후크를 제공합니다.
wasm-bindgen-rayon은 이러한 후크를 활용하여 WebAssembly 스레드를 Web Worker로 스패닝합니다. 이를 사용하려면 종속 항목으로 추가하고 문서에 설명된 구성 단계를 따라야 합니다. 위의 예는 다음과 같이 표시됩니다.
pub use wasm_bindgen_rayon::init_thread_pool;
#[wasm_bindgen]
pub fn sum_of_squares(numbers: &[i32]) -> i32 {
numbers
.par_iter()
.map(|x| x * x)
.sum()
}
완료되면 생성된 JavaScript는 추가 initThreadPool
함수를 내보냅니다. 이 함수는 작업자 풀을 만들어 Rayon이 실행하는 모든 멀티스레드 작업을 위해 프로그램의 전체 기간 동안 재사용합니다.
이 풀 메커니즘은 앞에서 설명한 Emscripten의 -s PTHREAD_POOL_SIZE=...
옵션과 유사하며 교착 상태를 방지하려면 기본 코드 전에 초기화해야 합니다.
import init, { initThreadPool, sum_of_squares } from './pkg/index.js';
// Regular wasm-bindgen initialization.
await init();
// Thread pool initialization with the given number of threads
// (pass `navigator.hardwareConcurrency` if you want to use all cores).
await initThreadPool(navigator.hardwareConcurrency);
// ...now you can invoke any exported functions as you normally would
console.log(sum_of_squares(new Int32Array([1, 2, 3]))); // 14
기본 스레드 차단에 관한 동일한 주의사항이 여기에도 적용됩니다. sum_of_squares
예시에서도 여전히 기본 스레드를 차단하여 다른 스레드의 부분 결과를 기다려야 합니다.
이는 반복자의 복잡도와 사용 가능한 스레드 수에 따라 매우 짧거나 길 수 있지만, 안전을 위해 브라우저 엔진은 기본 스레드의 전체 차단을 적극적으로 방지하며 이러한 코드는 오류를 발생시킵니다. 대신 Worker를 만들고 wasm-bindgen
생성 코드를 가져와 Comlink와 같은 라이브러리를 사용하여 API를 기본 스레드에 노출해야 합니다.
wasm-bindgen-rayon 예에서 다음을 보여주는 엔드 투 엔드 데모를 확인하세요.
- 스레드의 기능 감지.
- 동일한 Rust 앱의 단일 스레드 및 멀티스레드 버전을 빌드합니다.
- Worker에서 wasm-bindgen으로 생성된 JS+Wasm을 로드합니다.
- wasm-bindgen-rayon을 사용하여 스레드 풀을 초기화합니다.
- Comlink를 사용하여 기본 스레드에 작업자 API를 노출합니다.
실제 사용 사례
Google에서는 클라이언트 측 이미지 압축을 위해 특히 AVIF(C++), JPEG-XL(C++), OxiPNG(Rust), WebP v2(C++)와 같은 형식에 Squoosh.app에서 WebAssembly 스레드를 적극적으로 사용합니다. 멀티스레딩만으로도 일관된 1.5~3배의 속도 향상(정확한 비율은 코덱마다 다름)을 확인했으며 WebAssembly 스레드를 WebAssembly SIMD와 결합하여 이러한 수치를 더욱 높일 수 있었습니다.
Google 어스는 웹 버전에 WebAssembly 스레드를 사용하는 또 다른 주목할 만한 서비스입니다.
FFMPEG.WASM은 WebAssembly 스레드를 사용하여 브라우저에서 직접 동영상을 효율적으로 인코딩하는 인기 있는 FFmpeg 멀티미디어 도구 모음의 WebAssembly 버전입니다.
WebAssembly 스레드를 사용하는 더 많은 흥미로운 예가 있습니다. 데모를 확인하고 자체 멀티스레드 애플리케이션과 라이브러리를 웹으로 가져오세요.