WebAssembly에서 비동기 웹 API 사용

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

시스템 언어의 I/O

C의 간단한 예부터 시작하겠습니다. 파일에서 사용자 이름을 읽고 'Hello, (username)!' 메시지로 인사한다고 가정해 보겠습니다.

#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에서 이름을 읽으려면 두 개 이상의 중요한 I/O 호출(파일을 열기 위한 fopen와 파일에서 데이터를 읽는 fread)이 필요합니다. 데이터를 검색한 후에는 다른 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 작업이 완료된 후 실행될 콜백과 함께 I/O 작업만 예약할 수 있습니다. 이러한 콜백은 브라우저 이벤트 루프의 일부로 실행됩니다. 여기서는 자세히 다루지 않겠지만 이벤트 루프가 내부적으로 어떻게 작동하는지 알아보려면 이 주제를 자세히 설명하는 태스크, 마이크로태스크, 큐, 일정을 확인하세요.

짧은 버전은 브라우저가 대기열에서 하나씩 가져와 모든 코드를 무한 루프로 실행하는 것입니다. 일부 이벤트가 트리거되면 브라우저는 해당 핸들러를 큐에 추가하고 다음 루프 반복 시 큐에서 추출되어 실행됩니다. 이 메커니즘을 사용하면 단일 스레드만 사용하는 동안 동시 실행을 시뮬레이션하고 많은 수의 병렬 작업을 실행할 수 있습니다.

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

예를 들어 최신 자바스크립트로 위의 샘플을 재작성하고 원격 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를 차단하고 그 동안 다른 이벤트를 처리하지 못하게 합니다. 일반적으로 프로덕션 코드에서는 그렇게 하지 마세요.

대신 자바스크립트에서 좀 더 관용적인 버전의 '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()를 호출할 수 있습니다.

이러한 코드를 컴파일할 때는 Asyncify 기능을 활성화하도록 Emscripten에 지시해야 합니다. 배열과 유사한 비동기 함수일 수 있는 함수 목록과 함께 -s ASYNCIFY뿐만 아니라 -s ASYNCIFY_IMPORTS=[func1, func2]도 전달하면 됩니다.

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

이렇게 하면 Emscripten은 이러한 함수 호출에 상태 저장 및 복원이 필요할 수 있다는 것을 인식하므로 컴파일러가 이러한 호출 주위에 지원 코드를 삽입합니다.

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

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()와 같은 프로미스 기반 API의 경우 콜백 기반 API를 사용하는 대신 Asyncify를 자바스크립트의 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 자바스크립트 코드의 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에서 잘 작동합니다. 다른 도구 모음과 언어는 어떻게 되나요?

다른 언어에서의 사용

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

먼저 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) 또는 asyncify-wasm 이름 아래에서 npm에서 찾을 수 있습니다.

표준 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 애플리케이션의 상태를 정지 및 저장하고, 프로미스 완성을 구독하고, 나중에 문제가 해결되면 호출 스택과 상태를 원활하게 복원하고 아무 일도 일어나지 않았던 것처럼 실행을 계속합니다.

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

이 모든 것은 어떻게 작동합니까?

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

이는 앞서 보여드린 자바스크립트의 async-await 기능과 매우 비슷하지만 자바스크립트 기능과는 달리 언어의 특수 구문이나 런타임 지원이 필요하지 않으며 대신 컴파일 시간에 일반 동기 함수를 변환하는 방식으로 작동합니다.

이전에 표시된 비동기 수면 예를 컴파일하는 경우:

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'가 두 번 출력되지 않도록 하고 대신 바로 '되감기' 브랜치로 이동합니다. 이 수준에 도달하면 저장된 모든 로컬을 복원하고, 모드를 다시 '일반'으로 변경하고, 애초에 코드가 중지된 적이 없는 것처럼 실행을 계속합니다.

변환 비용

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

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

이는 이상적이지는 않지만 대체 기능에서 기능을 완전히 포함하지 않거나 원본 코드를 상당히 재작성해야 하는 경우 허용되는 경우가 많습니다.

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

실제 데모

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

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

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

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

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

https://wasi.rreverser.com/에서 실시간으로 확인할 수 있습니다.

Asyncify 사용 사례는 타이머와 파일 시스템에만 국한되지 않습니다. 웹에서 더 많은 틈새 API를 사용할 수 있습니다.

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

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

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

이러한 예를 통해 Asyncify가 간극을 메우고 모든 종류의 애플리케이션을 웹에 포팅하여 기능 손실 없이 크로스 플랫폼 액세스, 샌드박스, 보안 강화를 제공하는 데 얼마나 강력한 성능을 발휘할 수 있는지 알 수 있습니다.