WebAssembly에서 비동기 웹 API 사용

웹의 I/O API는 비동기식이지만 대부분의 시스템 언어에서는 동기적입니다. 날짜 코드를 WebAssembly로 컴파일할 때는 한 종류의 API를 다른 종류로 연결해야 합니다. 이 브리지는 Asyncify로 전달하세요. 이 게시물에서는 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만 제공합니다.

이는 웹에서 코드를 실행하는 핵심 속성 중 하나입니다. 즉, 시간이 오래 걸리는 작업이기 때문에 비동기식이어야 합니다.

웹은 지금까지 단일 스레드였고 UI에 닿는 모든 사용자 코드가 UI와 동일한 스레드에서 실행되어야 합니다. 데이터 레이크와 같은 다른 중요한 작업과 CPU 시간별 레이아웃, 렌더링, 이벤트 처리 방법을 조합합니다. JavaScript나 '파일 읽기'를 시작할 수 있도록 WebAssembly 나머지는 모두 차단할 수 있습니다. 즉, 탭 전체, 또는 과거에는 끝날 때까지 밀리초에서 몇 초까지 전체 브라우저가 되었습니다.

대신 코드는 실행할 콜백과 함께 I/O 작업을 예약할 수만 있습니다. 표시됩니다. 이러한 콜백은 브라우저 이벤트 루프의 일부로 실행됩니다. 나는 자세히 설명하겠지만, 이벤트 루프가 내부적으로 어떻게 작동하는지 배우는 데 관심이 있다면 결제 태스크, 마이크로태스크, 큐, 일정 에서 이 주제를 자세히 설명합니다.

짧은 버전은 브라우저가 모든 코드 조각을 일종의 무한 루프로 실행한다는 것입니다. 큐에서 하나씩 가져와. 어떤 이벤트가 트리거되면 브라우저는 해당 핸들러에 전달되고, 다음 루프 반복에서 큐에서 추출되어 실행됩니다. 이 메커니즘을 사용하면 단일 스레드입니다.

이 메커니즘에 대해 기억해야 할 중요한 점은 맞춤 JavaScript (또는 WebAssembly) 코드가 실행되면 이벤트 루프가 차단되고, 차단되기는 하지만 외부 핸들러, 이벤트, I/O 등입니다. I/O 결과를 다시 가져오는 유일한 방법은 코드 실행을 종료한 다음, 브라우저에 컨트롤을 다시 제공하여 모든 대기업을 대상으로 합니다 I/O가 완료되면 핸들러는 이러한 작업 중 하나가 되며 실행됩니다

예를 들어, 위의 샘플을 최신 자바스크립트로 재작성하고 이름을 바꾸려면 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));
}

좀 더 명확해진 이 디슈가링된 예에서는 요청이 시작되고 첫 번째 콜백으로 응답이 구독됩니다. 브라우저가 초기 응답을 수신하면 헤더를 사용하여 이 콜백을 비동기식으로 호출합니다. 콜백은 response.text()를 호출하고 다른 콜백으로 결과를 구독합니다. 마지막으로 fetch가 모든 콘텐츠를 가져오면 마지막 콜백이 호출되어 'Hello, (username)!'가 (으)로 살펴보겠습니다

이러한 단계의 비동기식 특성 덕분에 원래 함수는 제어권을 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의 기본 구현에서 하는 일입니다. "수면", 이는 매우 비효율적이고 전체 UI를 차단하며 다른 이벤트를 처리하지 못하게 합니다. 그 동안은 말이죠. 일반적으로 프로덕션 코드에서는 이렇게 하지 마세요.

대신 더 자연스러운 버전의 'sleep' JavaScript에서는 setTimeout()를 호출해야 합니다. 다음과 같이 처리합니다.

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

이러한 모든 예시와 API의 공통점은 무엇인가요? 각각의 경우에 원본 시스템 언어는 I/O에 차단 API를 사용하는 반면에 동일한 웹 예제에서는 비동기 API를 사용하면 됩니다. 웹으로 컴파일할 때는 어떻게든 이 둘 사이에서 변환해야 합니다. WebAssembly에는 아직 그렇게 하는 기능이 내장되어 있지 않습니다.

Asyncify로 문제 해결하기

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

호출 그래프
자바스크립트 설명 -> WebAssembly -> 웹 API -> 비동기 작업 호출을 통해
비동기 작업의 결과를 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] \
    ...

이렇게 하면 이러한 함수를 호출할 때마다 상태이므로 컴파일러가 이러한 호출 주위에 지원 코드를 삽입합니다.

이제 브라우저에서 이 코드를 실행하면 예상대로 매끄러운 출력 로그가 표시됩니다. 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의 경우 Asyncify를 JavaScript의 async-await 기능을 사용하는 것이 좋습니다. 그렇게 하려면 Asyncify.handleSleep(), Asyncify.handleAsync()를 호출합니다. 그런 다음 일정 예약이나 wakeUp() 콜백에서 호출한 경우 async JavaScript 함수를 전달하고 awaitreturn를 사용할 수 있습니다. 코드가 훨씬 더 자연스럽고 동기식으로 보이면서 API의 이점을 그대로 누릴 수 있습니다. 비동기 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은 Embind는 JavaScript와 C++ 값 간의 변환을 처리할 수 있습니다. Asyncify도 지원하므로 외부 Promise에서 await()를 호출할 수 있으며 async-await의 await처럼 작동합니다. JavaScript 코드:

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 or 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();

위 예의 get_answer()와 같은 비동기 함수를 호출하려고 하면 라이브러리는 반환된 Promise를 감지하고, 현재 상태를 정지하고 프라미스 완료를 구독하고 나중에 해결되는 경우 호출 스택과 상태를 원활하게 복원하고 아무 일도 발생하지 않은 것처럼 실행을 계속할 수 있어야 합니다.

모듈의 모든 함수가 비동기 호출을 할 수 있으므로 모든 내보내기는 잠재적으로 비동기도 마찬가지로 래핑됩니다. 위의 예에서 실행이 실제로 언제인지 알 수 있도록 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'를 출력하지 않길 원함 두 번 왔다 갔다 하며 대신 "되감기" 브랜치. 도착하면 저장된 모든 로컬을 복원하고 모드를 다시 'normal' 처음부터 코드가 중지되지 않은 것처럼 실행을 계속합니다.

변환 비용

안타깝게도 Asyncify 변환은 많은 양의 변환을 주입해야 하기 때문에 완전히 무료는 아닙니다. 모든 로컬을 저장 및 복원하고 아래의 호출 스택을 탐색하는 실행할 수 있습니다. 명령어에서 비동기로 표시된 함수만 수정하려고 시도합니다. 행뿐만 아니라 잠재적 호출자도 모두 포함하지만 압축 전 코드 크기 오버헤드는 여전히 최대 약 50% 가 될 수 있습니다.

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

이는 이상적이지는 않지만 대부분의 경우 대안에 기능이 없는 경우 허용됩니다. 원본 코드를 상당 부분 다시 작성해야 할 수 있습니다.

최종 빌드에 항상 최적화를 사용 설정하여 점수가 더 높아지지 않도록 해야 합니다. 다음과 같은 작업을 할 수 있습니다. 비동기식 최적화 오버헤드를 줄일 수 있는 변환을 지정된 함수 또는 직접 함수 호출로만 제한합니다. 또한 약간의 비용이 발생할 수 있지만 비동기 호출 자체로 제한됩니다. 그러나 실제 작업 비용에 비해 일반적으로 무시할 수 있습니다.

실제 데모

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

이 글의 앞부분에서 언급했듯이 웹의 스토리지 옵션 중 하나는 비동기 File System Access API를 제공합니다. 이 서비스를 사용하면 실제 호스트 파일 시스템을 설치합니다.

반면에 WASI라는 사실상의 표준이 있습니다. 모두 살펴봤습니다 대규모 언어 모델(LLM)에 대한 컴파일 타겟으로 모든 종류의 파일 시스템과 다른 작업을 기존의 동기 양식입니다.

하나를 다른 것으로 매핑할 수 있다면 어떨까요? 그러면 어떤 소스 언어로든 애플리케이션을 컴파일할 수 있고 WASI 타겟을 지원하는 모든 툴체인을 사용하고, 웹의 샌드박스에서 실행하는 동시에 실제 사용자 파일에서 작동할 수 있게 해 줍니다. Asyncify를 사용하면 가능합니다.

이 데모에서는 Rust coreutils 크레이트를 WASI에 마이너 패치를 거의 사용하지 않고 Asyncify 변환을 통해 전달되고 비동기식으로 구현됨 WASI의 binding File System Access API에 대해 알아보겠습니다. 결합한 후에는 Xterm.js 터미널 구성요소로, 이는 Python에서 실행되는 실제 셸을 실제 사용자 파일에서 작동하는 것입니다. 실제 터미널과 같습니다.

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

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

예를 들어 Asyncify를 사용하면 libusb—아마도 USB 기기: WebUSB API로 이러한 기기에 비동기식 액세스 제공 있습니다. 매핑 및 컴파일한 후에는 선택한 표준 libusb 테스트와 예제를 실행했습니다. 웹페이지 샌드박스에서 바로 사용할 수 있습니다.

libusb의 스크린샷
연결된 Canon 카메라에 관한 정보가 표시된 웹페이지의 디버그 출력

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

이러한 예는 Asyncify가 격차를 해소하고 모든 애플리케이션을 포팅하는 데 얼마나 강력한 효과를 줄 수 있는지 보여줍니다. 크로스 플랫폼 액세스, 샌드박싱 및 더 나은 성능을 기능을 계속 사용할 수 있습니다.