WebAssembly에서 비동기 웹 API 사용

웹의 I/O API는 비동기식이지만 대부분의 시스템 언어에서는 동기적입니다. 코드를 WebAssembly로 컴파일할 때는 한 종류의 API를 다른 종류의 API로 브리징해야 합니다. 이 브리징이 Asyncify입니다. 이 게시물에서는 Asyncify를 사용하는 경우와 방법, 내부 작동 방식을 알아봅니다.

시스템 언어의 I/O

C의 간단한 예로 시작해 보겠습니다. 파일에서 사용자 이름을 읽고 '(사용자 이름)님, 안녕하세요!'라는 메시지로 사용자를 맞이하려고 한다고 가정해 보겠습니다.

#include <stdio.h>

int main() {
    FILE *stream = fopen("name.txt", "r");
    char name[20+1];
    size_t len = fread(&name, 1, 20, stream);
    name[len] = '\0';
    fclose(stream);
    printf("Hello, %s!\n", name);
    return 0;
}

이 예는 별로 할 일이 없지만 모든 크기의 애플리케이션에서 볼 수 있는 것을 이미 보여줍니다. 외부에서 일부 입력을 읽고 내부에서 처리한 후 출력을 외부에 다시 씁니다. 외부 세계와의 이러한 모든 상호작용은 흔히 입출력 함수라고 부르는 몇 가지 함수를 통해 발생합니다. 이러한 함수를 I/O로 축약합니다.

C에서 이름을 읽으려면 파일을 여는 fopen와 파일에서 데이터를 읽는 fread라는 두 가지 중요한 I/O 호출이 필요합니다. 데이터를 검색한 후 다른 I/O 함수 printf를 사용하여 결과를 콘솔에 출력할 수 있습니다.

이러한 함수는 언뜻 보기에 매우 간단해 보이고 데이터를 읽거나 쓰는 데 관련된 메커니즘에 대해 다시 생각할 필요가 없습니다. 그러나 환경에 따라 내부에서 상당히 많은 작업이 진행될 수 있습니다.

  • 입력 파일이 로컬 드라이브에 있는 경우 애플리케이션은 일련의 메모리 및 디스크 액세스를 수행하여 파일을 찾고, 권한을 확인하고, 읽기 위해 파일을 연 다음, 요청된 바이트 수가 검색될 때까지 블록별로 읽어야 합니다. 디스크 속도와 요청된 크기에 따라 속도가 매우 느릴 수 있습니다.
  • 또는 입력 파일이 마운트된 네트워크 위치에 있을 수 있습니다. 이 경우 네트워크 스택도 관여하게 되어 각 작업의 복잡성, 지연 시간, 잠재적 재시도 횟수가 증가합니다.
  • 마지막으로 printf도 콘솔에 항목을 출력하지 않을 수 있으며 파일이나 네트워크 위치로 리디렉션될 수 있습니다. 이 경우 위의 동일한 단계를 거쳐야 합니다.

요약하자면 I/O가 느릴 수 있으며 코드를 빠르게 살펴보아도 특정 호출이 얼마나 걸릴지 예측할 수 없습니다. 이 작업이 실행되는 동안 전체 애플리케이션이 정지된 것처럼 보이고 사용자에게 응답하지 않습니다.

이는 C 또는 C++로 제한되지 않습니다. 대부분의 시스템 언어는 모든 I/O를 동기식 API 형식으로 표시합니다. 예를 들어 이 예시를 Rust로 변환하면 API가 더 간단해 보일 수 있지만 동일한 원칙이 적용됩니다. 호출하고 결과가 반환될 때까지 동기식으로 기다리면 됩니다. 이때 비용이 많이 드는 모든 작업이 실행되고 결국 단일 호출에서 결과가 반환됩니다.

fn main() {
    let s = std::fs::read_to_string("name.txt");
    println!("Hello, {}!", s);
}

하지만 이러한 샘플을 WebAssembly로 컴파일하고 웹으로 변환하려고 하면 어떻게 될까요? 구체적인 예를 들어 '파일 읽기' 작업은 무엇으로 변환될 수 있나요? 일부 저장소에서 데이터를 읽어야 합니다.

웹의 비동기 모델

웹에는 메모리 내 저장소(JS 객체), localStorage, IndexedDB, 서버 측 저장소, 새로운 File System Access API와 같이 매핑할 수 있는 다양한 저장소 옵션이 있습니다.

그러나 이러한 API 중 메모리 내 저장소와 localStorage만 동기식으로 사용할 수 있으며, 둘 다 저장할 수 있는 항목과 저장 기간에 가장 제한적인 옵션입니다. 다른 모든 옵션은 비동기 API만 제공합니다.

이는 웹에서 코드를 실행하는 핵심 속성 중 하나입니다. I/O를 포함하여 시간이 많이 소요되는 작업은 비동기식이어야 합니다.

그 이유는 웹이 이전부터 단일 스레드였으며 UI를 터치하는 모든 사용자 코드는 UI와 동일한 스레드에서 실행되어야 하기 때문입니다. 레이아웃, 렌더링, 이벤트 처리와 같은 다른 중요한 작업과 CPU 시간을 놓고 경쟁해야 합니다. JavaScript나 WebAssembly가 '파일 읽기' 작업을 시작하고 끝날 때까지 밀리초에서 수 초에 이르는 기간 동안 전체 탭(또는 과거에는 전체 브라우저)을 모두 차단하는 것은 바람직하지 않습니다.

대신 코드는 I/O 작업을 완료된 후에 실행할 콜백과 함께만 예약할 수 있습니다. 이러한 콜백은 브라우저 이벤트 루프의 일부로 실행됩니다. 여기서는 세부정보를 다루지 않지만 이벤트 루프가 작동하는 방식을 자세히 알아보려면 이 주제를 심층적으로 설명하는 작업, 마이크로작업, 큐, 일정을 확인하세요.

간단히 말해 브라우저가 모든 코드 조각을 대기열에서 하나씩 가져와 일종의 무한 루프로 실행한다는 것입니다. 이벤트가 트리거되면 브라우저는 상응하는 핸들러를 큐에 추가하고 다음 루프 반복에서 큐에서 가져와 실행합니다. 이 메커니즘을 사용하면 단일 스레드만 사용하여 동시 실행을 시뮬레이션하고 여러 개의 병렬 작업을 실행할 수 있습니다.

이 메커니즘에 관해 기억해야 할 중요한 점은 맞춤 JavaScript(또는 WebAssembly) 코드가 실행되는 동안 이벤트 루프가 차단되며, 차단된 동안에는 외부 핸들러, 이벤트, I/O 등에 반응할 방법이 없다는 것입니다. I/O 결과를 다시 가져오는 유일한 방법은 콜백을 등록하고, 코드 실행을 완료하고, 브라우저에 제어를 다시 제공하여 대기 중인 작업을 계속 처리할 수 있도록 하는 것입니다. I/O가 완료되면 핸들러가 이러한 작업 중 하나가 되어 실행됩니다.

예를 들어 최신 JavaScript로 위 샘플을 다시 작성하고 원격 URL에서 이름을 읽기로 결정한 경우 Fetch API 및 async-await 구문을 사용합니다.

async function main() {
  let response = await fetch("name.txt");
  let name = await response.text();
  console.log("Hello, %s!", name);
}

동기식으로 보이지만 실제로는 각 await가 기본적으로 콜백의 문법 설탕입니다.

function main() {
  return fetch("name.txt")
    .then(response => response.text())
    .then(name => console.log("Hello, %s!", name));
}

약간 더 명확한 이 디슈가링된 예시에서는 요청이 시작되고 첫 번째 콜백으로 응답이 구독됩니다. 브라우저가 HTTP 헤더만 포함된 초기 응답을 수신하면 이 콜백을 비동기식으로 호출합니다. 콜백은 response.text()를 사용하여 본문을 텍스트로 읽기 시작하고 다른 콜백으로 결과를 구독합니다. 마지막으로 fetch가 모든 콘텐츠를 검색하면 마지막 콜백이 호출되어 콘솔에 'Hello, (username)!'를 출력합니다.

이러한 단계의 비동기적 특성 덕분에 원래 함수는 I/O가 예약되는 즉시 브라우저에 제어를 반환할 수 있으며, I/O가 백그라운드에서 실행되는 동안 전체 UI가 반응하고 렌더링, 스크롤 등 다른 작업에 사용할 수 있도록 할 수 있습니다.

마지막 예로 애플리케이션이 지정된 시간(초) 동안 대기하도록 하는 'sleep'과 같은 단순한 API도 I/O 작업의 한 형태입니다.

#include <stdio.h>
#include <unistd.h>
// ...
printf("A\n");
sleep(1);
printf("B\n");

물론, 시간이 만료될 때까지 현재 스레드를 차단하는 매우 간단한 방식으로 번역할 수 있습니다.

console.log("A");
for (let start = Date.now(); Date.now() - start < 1000;);
console.log("B");

사실 Emscripten은 'sleep'의 기본 구현에서 정확히 이렇게 합니다. 하지만 이는 매우 비효율적이며 전체 UI를 차단하고 그동안 다른 이벤트를 처리할 수 없습니다. 일반적으로 프로덕션 코드에서는 이렇게 하지 않습니다.

대신 JavaScript에서 더 관용적인 버전의 'sleep'은 setTimeout()를 호출하고 핸들러로 구독하는 것입니다.

console.log("A");
setTimeout(() => {
    console.log("B");
}, 1000);

이러한 모든 예시와 API의 공통점은 무엇인가요? 각 경우 원래 시스템 언어의 관용적 코드는 I/O에 차단 API를 사용하는 반면, 웹의 상응하는 예에서는 비동기 API를 대신 사용합니다. 웹으로 컴파일할 때는 두 실행 모델 간에 어떻게든 변환해야 하며 WebAssembly에는 아직 이를 위한 내장 기능이 없습니다.

Asyncify를 통한 격차 해소

이때 필요한 것이 Asyncify입니다. Asyncify는 Emscripten에서 지원하는 컴파일 시간 기능으로, 전체 프로그램을 일시중지하고 나중에 비동기식으로 재개할 수 있습니다.

JavaScript -> WebAssembly -> 웹 API -> 비동기 작업 호출을 설명하는 호출 그래프. 여기서 Asyncify는 비동기 작업의 결과를 WebAssembly에 다시 연결합니다.

Emscripten을 사용한 C/C++ 사용

Asyncify를 사용하여 마지막 예시에서 비동기 절전 모드를 구현하려면 다음과 같이 하면 됩니다.

#include <stdio.h>
#include <emscripten.h>

EM_JS(void, async_sleep, (int seconds), {
    Asyncify.handleSleep(wakeUp => {
        setTimeout(wakeUp, seconds * 1000);
    });
});

puts("A");
async_sleep(1);
puts("B");

EM_JS는 JavaScript 스니펫을 C 함수인 것처럼 정의할 수 있는 매크로입니다. 내부에서 Asyncify.handleSleep() 함수를 사용합니다. 이 함수는 Emscripten에 프로그램을 정지하도록 지시하고 비동기 작업이 완료되면 호출해야 하는 wakeUp() 핸들러를 제공합니다. 위의 예에서 핸들러는 setTimeout()에 전달되지만 콜백을 허용하는 다른 컨텍스트에서 사용할 수 있습니다. 마지막으로 일반 sleep() 또는 다른 동기식 API와 마찬가지로 원하는 위치에서 async_sleep()를 호출할 수 있습니다.

이러한 코드를 컴파일할 때는 Emscripten에 Asyncify 기능을 활성화하도록 지시해야 합니다. 비동기식일 수 있는 함수의 배열과 같은 목록과 함께 -s ASYNCIFY-s ASYNCIFY_IMPORTS=[func1, func2]를 전달하여 이를 실행합니다.

emcc -O2 \
    -s ASYNCIFY \
    -s ASYNCIFY_IMPORTS=[async_sleep] \
    ...

이렇게 하면 Emscripten이 이러한 함수 호출 시 상태를 저장하고 복원해야 할 수 있음을 알 수 있으므로 컴파일러는 이러한 호출 주위에 지원 코드를 삽입합니다.

이제 브라우저에서 이 코드를 실행하면 예상대로 A가 실행된 후 잠시 지연된 후에 B가 실행되는 원활한 출력 로그가 표시됩니다.

A
B

Asyncify 함수에서 값을 반환할 수도 있습니다. handleSleep()의 결과를 반환하고 결과를 wakeUp() 콜백에 전달해야 합니다. 예를 들어 파일에서 읽는 대신 원격 리소스에서 번호를 가져오려면 아래와 같은 스니펫을 사용하여 요청을 실행하고, C 코드를 정지하고, 응답 본문이 검색되면 재개할 수 있습니다. 이 모든 작업이 동기식 호출인 것처럼 원활하게 수행됩니다.

EM_JS(int, get_answer, (), {
     return Asyncify.handleSleep(wakeUp => {
        fetch("answer.txt")
            .then(response => response.text())
            .then(text => wakeUp(Number(text)));
    });
});
puts("Getting answer...");
int answer = get_answer();
printf("Answer is %d\n", answer);

실제로 fetch()와 같은 Promise 기반 API의 경우 콜백 기반 API를 사용하는 대신 Asyncify를 JavaScript의 async-await 기능과 결합할 수도 있습니다. 이렇게 하려면 Asyncify.handleSleep() 대신 Asyncify.handleAsync()를 호출합니다. 그런 다음 wakeUp() 콜백을 예약하는 대신 async JavaScript 함수를 전달하고 내부에서 awaitreturn를 사용하여 비동기 I/O의 이점을 모두 누리면서 코드를 더욱 자연스럽고 동기식으로 보이게 할 수 있습니다.

EM_JS(int, get_answer, (), {
     return Asyncify.handleAsync(async () => {
        let response = await fetch("answer.txt");
        let text = await response.text();
        return Number(text);
    });
});

int answer = get_answer();

복잡한 값 대기 중

그러나 이 예에서는 여전히 숫자로만 제한됩니다. 파일에서 사용자 이름을 문자열로 가져오려고 시도한 원래 예시를 구현하려면 어떻게 해야 하나요? 자, 당신도 할 수 있어요!

Emscripten은 JavaScript와 C++ 값 간의 변환을 처리할 수 있게 해주는 Embind라는 기능을 제공합니다. Asyncify도 지원하므로 외부 Promise에서 await()를 호출할 수 있으며, 이는 async-await JavaScript 코드에서 await와 똑같이 작동합니다.

val fetch = val::global("fetch");
val response = fetch(std::string("answer.txt")).await();
val text = response.call<val>("text").await();
auto answer = text.as<std::string>();

이 메서드를 사용하면 ASYNCIFY_IMPORTS가 이미 기본적으로 포함되어 있으므로 컴파일 플래그로 전달할 필요조차 없습니다.

좋습니다. Emscripten에서 모두 잘 작동합니다. 다른 도구 모음 및 언어는 어떨까요?

다른 언어의 사용

Rust 코드 어딘가에 웹의 비동기 API에 매핑하려는 유사한 동기 호출이 있다고 가정해 보겠습니다. 여러분도 할 수 있습니다.

먼저 이러한 함수를 extern 블록(또는 선택한 언어의 외부 함수 문법)을 통해 일반 가져오기로 정의해야 합니다.

extern {
    fn get_answer() -> i32;
}

println!("Getting answer...");
let answer = get_answer();
println!("Answer is {}", answer);

코드를 WebAssembly로 컴파일합니다.

cargo build --target wasm32-unknown-unknown

이제 스택을 저장/복원하는 코드로 WebAssembly 파일을 계측해야 합니다. C/C++의 경우 Emscripten이 이를 대신 처리하지만 여기서는 사용되지 않으므로 프로세스가 약간 더 수동적입니다.

다행히 Asyncify 변환 자체는 도구 모음과 무관합니다. 생성된 컴파일러에 관계없이 임의의 WebAssembly 파일을 변환할 수 있습니다. 변환은 Binaryen 도구 모음wasm-opt 최적화 도구의 일부로 별도로 제공되며 다음과 같이 호출할 수 있습니다.

wasm-opt -O2 --asyncify \
      --pass-arg=asyncify-imports@env.get_answer \
      [...]

--asyncify를 전달하여 변환을 사용 설정한 다음 --pass-arg=…를 사용하여 프로그램 상태를 일시중지했다가 나중에 다시 시작해야 하는 비동기 함수의 쉼표로 구분된 목록을 제공합니다.

이제 남은 작업은 실제로 이를 실행하는 지원 런타임 코드(WebAssembly 코드 일시중지 및 재개)를 제공하는 것입니다. 다시 말하지만 C/C++의 경우 Emscripten에서 이를 포함하지만 이제 임의의 WebAssembly 파일을 처리할 맞춤 JavaScript 글루 코드가 필요합니다. 이를 위해 라이브러리를 만들었습니다.

GitHub(https://github.com/GoogleChromeLabs/asyncify) 또는 npm(asyncify-wasm)에서 찾을 수 있습니다.

자체 네임스페이스에서 표준 WebAssembly 인스턴스화 API를 시뮬레이션합니다. 유일한 차이점은 일반 WebAssembly API에서는 동기 함수만 가져오기로 제공할 수 있지만 Asyncify 래퍼에서는 비동기 가져오기도 제공할 수 있다는 점입니다.

const { instance } = await Asyncify.instantiateStreaming(fetch('app.wasm'), {
    env: {
        async get_answer() {
            let response = await fetch("answer.txt");
            let text = await response.text();
            return Number(text);
        }
    }
});

await instance.exports.main();

WebAssembly 측에서 이러한 비동기 함수(예: 위 예시의 get_answer())를 호출하려고 하면 라이브러리는 반환된 Promise를 감지하고, WebAssembly 애플리케이션의 상태를 일시중지하고 저장하고, Promise 완료를 구독하고, 나중에 해결되면 호출 스택과 상태를 원활하게 복원하고 아무 일도 일어나지 않은 것처럼 실행을 계속합니다.

모듈의 모든 함수가 비동기 호출을 실행할 수 있으므로 모든 내보내기도 비동기식이 될 수 있으므로 래핑됩니다. 위의 예에서 실행이 실제로 완료된 시점을 알기 위해서는 instance.exports.main()의 결과를 await해야 한다는 것을 알 수 있습니다.

이 기능은 내부적으로 어떻게 작동하나요?

Asyncify는 ASYNCIFY_IMPORTS 함수 중 하나의 호출을 감지하면 비동기 작업을 시작하고 호출 스택과 임시 로컬을 비롯한 애플리케이션의 전체 상태를 저장한 후 나중에 작업이 완료되면 모든 메모리와 호출 스택을 복원하고 프로그램이 중지된 적이 없는 것처럼 동일한 위치에서 동일한 상태로 재개합니다.

이는 앞에서 보여준 JavaScript의 async-await 기능과 매우 유사하지만 JavaScript와 달리 언어의 특수한 문법이나 런타임 지원이 필요하지 않으며 대신 컴파일 시 일반 동기 함수를 변환하여 작동합니다.

앞에서 표시된 비동기 수면 예시를 컴파일하면 다음과 같이 표시됩니다.

puts("A");
async_sleep(1);
puts("B");

Asyncify는 이 코드를 사용하여 다음과 같이 대략 변환합니다(유사 코드, 실제 변환은 이보다 더 복잡함).

if (mode == NORMAL_EXECUTION) {
    puts("A");
    async_sleep(1);
    saveLocals();
    mode = UNWINDING;
    return;
}
if (mode == REWINDING) {
    restoreLocals();
    mode = NORMAL_EXECUTION;
}
puts("B");

처음에는 modeNORMAL_EXECUTION로 설정됩니다. 따라서 이러한 변환된 코드가 처음 실행될 때는 async_sleep()까지의 부분만 평가됩니다. 비동기 작업이 예약되는 즉시 Asyncify는 모든 로컬을 저장하고 각 함수에서 맨 위까지 반환하여 스택을 롤백하여 브라우저 이벤트 루프에 다시 제어를 넘깁니다.

그런 다음 async_sleep()가 해결되면 Asyncify 지원 코드가 modeREWINDING로 변경하고 함수를 다시 호출합니다. 이번에는 '일반 실행' 브랜치가 건너뜁니다. 이전에 이미 작업을 실행했고 'A'를 두 번 출력하는 것을 피하고 싶기 때문입니다. 대신 '되감기' 브랜치로 바로 이동합니다. 이 지점에 도달하면 저장된 모든 로컬을 복원하고 모드를 'normal'로 다시 변경한 다음 코드가 처음에 중지되지 않은 것처럼 실행을 계속합니다.

변환 비용

안타깝게도 Asyncify 변환은 완전히 무료가 아닙니다. 이러한 모든 로컬을 저장하고 복원하고, 다양한 모드에서 호출 스택을 탐색하는 등의 작업을 위한 상당한 양의 지원 코드를 삽입해야 하기 때문입니다. 명령줄에서 비동기로 표시된 함수와 잠재적 호출자를 수정하려고 하지만 압축 전에 코드 크기 오버헤드를 합산하면 최대 약 50% 가 될 수 있습니다.

미세 조정된 조건에서 거의 0%에서 최악의 경우 100%를 초과하는 다양한 벤치마크의 코드 크기 오버헤드를 보여주는 그래프

이 방법은 이상적이지는 않지만 대체로 기능이 모두 없는 경우 또는 원본 코드를 상당 부분 다시 작성해야 하는 경우에는 허용될 수 있습니다.

더 이상 증가하지 않도록 최종 빌드에는 항상 최적화를 사용 설정해야 합니다. Asyncify 관련 최적화 옵션을 선택하여 변환을 지정된 함수 또는 직접 함수 호출로만 제한하여 오버헤드를 줄일 수도 있습니다. 런타임 성능에도 약간의 비용이 들지만 비동기 호출 자체로만 비용이 제한됩니다. 그러나 실제 작업 비용에 비하면 일반적으로 무시할 수 있습니다.

실제 데모

간단한 예시를 살펴봤으니 이제 더 복잡한 시나리오로 넘어가겠습니다.

이 문서의 시작 부분에서 언급했듯이 웹의 저장소 옵션 중 하나는 비동기 File System Access API입니다. 웹 애플리케이션에서 실제 호스트 파일 시스템에 대한 액세스를 제공합니다.

반면 콘솔과 서버 측의 WebAssembly I/O에는 WASI라는 사실상의 표준이 있습니다. 시스템 언어의 컴파일 타겟으로 설계되었으며 모든 종류의 파일 시스템 및 기타 작업을 기존의 동기식 형식으로 노출합니다.

하나를 다른 하나에 매핑할 수 있다면 어떨까요? 그러면 WASI 타겟을 지원하는 도구 모음을 사용하여 모든 소스 언어로 애플리케이션을 컴파일하고 웹의 샌드박스에서 실행하면서도 실제 사용자 파일에서 작동하도록 허용할 수 있습니다. Asyncify를 사용하면 바로 이 작업을 할 수 있습니다.

이 데모에서는 WASI에 대한 몇 가지 사소한 패치가 포함된 Rust coreutils 크레이트를 컴파일하고, Asyncify 변환을 통해 전달하고, JavaScript 측에서 WASI에서 File System Access API로의 비동기 바인딩을 구현했습니다. Xterm.js 터미널 구성요소와 결합하면 실제 터미널과 마찬가지로 브라우저 탭에서 실행되고 실제 사용자 파일에서 작동하는 사실적인 셸이 제공됩니다.

https://wasi.rreverser.com/에서 실시간으로 확인해 보세요.

비동기화 사용 사례는 타이머와 파일 시스템으로 제한되지 않습니다. 더 나아가 웹에서 더 많은 틈새 API를 사용할 수 있습니다.

예를 들어 Asyncify를 사용하면 USB 기기 작업에 가장 많이 사용되는 네이티브 라이브러리인 libusbWebUSB API에 매핑할 수 있습니다. WebUSB API는 웹에서 이러한 기기에 비동기식으로 액세스할 수 있습니다. 매핑 및 컴파일한 후에는 표준 libusb 테스트 및 예제를 가져와 웹페이지의 샌드박스에서 선택한 기기에 대해 실행할 수 있습니다.

연결된 Canon 카메라에 관한 정보를 보여주는 웹페이지의 libusb 디버그 출력 스크린샷

아마도 다른 블로그 게시물의 이야기일 것입니다.

이러한 예는 Asyncify가 격차를 해소하고 모든 종류의 애플리케이션을 웹으로 포팅하는 데 얼마나 강력한지 보여줍니다. 이를 통해 기능을 손실하지 않고도 크로스 플랫폼 액세스, 샌드박스, 향상된 보안을 얻을 수 있습니다.