Korzystanie z asynchronicznych internetowych interfejsów API z WebAssembly

Interfejsy API wejścia-wyjścia w internecie są asynchroniczne, ale w większości języków systemowych są synchroniczne. Kiedy gdy kompiluje się kod do WebAssembly, trzeba połączyć jeden rodzaj interfejsów API z drugim. Asynchronizuj. Z tego posta dowiesz się, kiedy i jak używać narzędzia Asyncify oraz jak działa ta funkcja.

I/O w językach systemu

Zacznę od prostego przykładu w tonacji C. Powiedz, że chcesz odczytać nazwę użytkownika z pliku i przywitaj się za pomocą polecenia „Cześć, (nazwa użytkownika)!”, wiadomość:

#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;
}

Przykład niewiele daje, ale już pokazuje coś, co można znaleźć w aplikacji. dowolnej wielkości: odczytuje niektóre dane wejściowe ze świata zewnętrznego, przetwarza je wewnętrznie i zapisuje z powrotem do świata zewnętrznego. Wszystkie takie interakcje ze światem zewnętrznym są zależne od nazywane funkcjami wejścia-wyjścia, skróconymi do I/O.

Aby odczytać nazwę z C, potrzebujesz co najmniej dwóch ważnych wywołań wejścia-wyjścia: fopen, by otworzyć plik, oraz fread, aby odczytać z niego dane. Po pobraniu danych możesz użyć innej funkcji wejścia-wyjścia printf aby wydrukować wynik w konsoli.

Na pierwszy rzut oka te funkcje wyglądają dość prosto. z urządzeń używanych do odczytu lub zapisu danych. Jednak w zależności od środowiska może wystąpić wiele się dzieje w środku:

  • Jeśli plik wejściowy znajduje się na dysku lokalnym, aplikacja musi wykonać dostępu do pamięci i dysku, aby zlokalizować plik, sprawdzić uprawnienia, otworzyć go do odczytu, a następnie blok po blokowaniu do odczytu do momentu pobrania żądanej liczby bajtów. Może to być dość wolne, w zależności od szybkości dysku i żądanego rozmiaru.
  • Plik wejściowy może też znajdować się w podłączonej lokalizacji sieciowej – w tym przypadku na poziomie sieci również staje się bardziej złożony, co zwiększa złożoność, czas oczekiwania i liczbę potencjalnych ponowienia próby w przypadku każdej operacji.
  • Nawet printf nie ma gwarancji, że wydrukuje coś w konsoli, i może zostać przekierowany. do pliku lub lokalizacji sieciowej. W takim przypadku trzeba wykonać te same czynności.

Krótko mówiąc, operacje wejścia-wyjścia mogą przebiegać powoli i nie można przewidzieć, ile czasu zajmie konkretne połączenie przez szybki rzut oka na kod. Podczas wykonywania tej operacji cała aplikacja będzie wyglądała na zablokowana i brak reakcji.

Dotyczy to również języka C ani C++. W większości języków systemu wszystkie I/O są przedstawiane w formie synchronicznych interfejsów API. Jeśli na przykład przetłumaczysz przykład na język Rust, interfejs API może wyglądać prostszy, ale obowiązują te same zasady. Dzwonisz tylko synchronicznie i czekasz na zwrócenie wyniku, chociaż wykonuje wszystkie kosztowne operacje i ostatecznie zwraca wynik w jednym wywołanie:

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

Ale co się stanie, gdy spróbujesz skompilować któryś z tych przykładów do WebAssembly i przetłumaczyć go na w internecie? Albo, aby podać konkretny przykład, jakiego typu „odczytywane są pliki” operacji tłumaczenia na? Będzie odczytują dane z pewnej pamięci.

Asynchroniczny model sieci

W internecie dostępne są różne opcje pamięci masowej, na które można zmapować, na przykład pamięć w pamięci (JS) obiekty), localStorage, IndexedDB, pamięć po stronie serwera, oraz nowy interfejs File System Access API.

Można jednak używać tylko 2 z tych interfejsów API – pamięci w pamięci oraz localStorage synchronicznie i oba te tryby najbardziej ograniczają możliwości przechowywania danych i czas ich przechowywania. Wszystkie inne opcje udostępniają tylko asynchroniczne interfejsy API.

To jedna z podstawowych właściwości wykonywania kodu w sieci: każda czasochłonna operacja, zawiera wszystkie operacje wejścia-wyjścia, musi być asynchroniczny.

Dzieje się tak, ponieważ internet był wcześniej jednowątkowy, a każdy kod użytkownika, który dotyka interfejsu użytkownika, musi działać w tym samym wątku co interfejs użytkownika. Musi konkurować z innymi ważnymi zadaniami, układ, renderowanie i obsługę zdarzeń dla czasu pracy procesora. Nie wystarczy fragment JavaScriptu, WebAssembly, aby móc rozpocząć „odczyt pliku” i blokuj wszystko inne, czyli całą kartę, a w przeszłości – w całej przeglądarce – od milisekund do kilku sekund.

Zamiast tego kod może zaplanować tylko operację wejścia-wyjścia z wywołaniem zwrotnym po jej zakończeniu. Takie wywołania zwrotne są wykonywane w ramach pętli zdarzeń przeglądarki. Nie będę w dalszej części artykułu, a jeśli chcecie dowiedzieć się, jak działa pętla zdarzeń, zapłacić Zadania, mikrozadania, kolejki i harmonogramy który szczegółowo wyjaśnia ten temat.

W skrócie: przeglądarka uruchamia wszystkie fragmenty kodu w formie nieskończonej pętli, czyli ich odebranie. Po wywołaniu zdarzenia przeglądarka dodaje odpowiedni moduł obsługi, a w następnej iteracji w pętli jest on usuwany z kolejki i wykonywany. Ten mechanizm umożliwia symulowanie równoczesności i uruchamianie wielu równoległych operacji przy jednoczesnym wykorzystaniu jeden wątek.

Ważne, aby pamiętać o tym mechanizmie, że niestandardowy kod JavaScript (lub WebAssembly) pętla zdarzeń jest blokowana i chociaż nie ma sposobu, aby zareagować na zewnętrznych modułów obsługi, zdarzeń, operacji wejścia-wyjścia itp. Jedynym sposobem na odzyskanie wyników I/O jest zarejestrowanie wywołanie zwrotne, dokończyć wykonywanie kodu i przekazać kontrolę z powrotem do przeglądarki, aby mogła zachować przetwarzania oczekujących zadań. Po zakończeniu operacji wejścia-wyjścia, moduł obsługi stanie się jednym z tych zadań, zostanie wykonany.

Jeśli chcesz na przykład zmodyfikować te przykłady, używając nowoczesnego JavaScriptu, i chcesz przeczytać ze zdalnego adresu URL, użyj interfejsu Fetch API i składni async-await:

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

Choć wygląda to synchronicznie, każdy element await jest w zasadzie cukrem składniowym, wywołania zwrotne:

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

W tym przykładzie bez zawartości tłuszczu, który jest nieco bardziej zrozumiały, żądanie jest uruchamiane, a odpowiedzi są subskrybowane przy pierwszym wywołaniu zwrotnym. Gdy przeglądarka otrzyma wstępną odpowiedź – tylko żądanie HTTP nagłówki – asynchronicznie wywołuje to wywołanie zwrotne. Wywołanie zwrotne rozpoczyna odczytywanie treści jako tekstu za pomocą funkcji response.text() i subskrybuje wynik z kolejnym wywołaniem zwrotnym. Gdy fetch pobrał całą zawartość, wywołuje ostatnie wywołanie zwrotne i wyświetla treść „Hello, (username)!”. do konsoli.

Dzięki asynchronicznemu charakterowi tych kroków pierwotna funkcja może zwrócić kontrolę do od razu po zaplanowaniu wejścia/wyjścia, a cały interfejs użytkownika pozostanie elastyczny wykonywać inne zadania, w tym renderowanie, przewijanie itd., podczas gdy I/O jest wykonywane w tle.

Ostatnim przykładem mogą być nawet proste interfejsy API, takie jak „sleep”, które powodują, że aplikacja czeka określony są również swoją formą operacji wejścia-wyjścia:

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

Oczywiście, możesz to przetłumaczyć w bardzo prosty sposób, który zablokuje bieżący wątek. do określonej daty:

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

Właśnie to robi Emscripten w swojej domyślnej implementacji „sen”, ale to bardzo niewydajne, zablokuje cały interfejs użytkownika i nie będzie zezwalać na obsługę żadnych innych zdarzeń tymczasem. Zwykle nie rób tego w kodzie produkcyjnym.

Bardziej idiomatyczna wersja słowa „sen” w JavaScripcie wymagałoby wywołania funkcji setTimeout(), a przy tym subskrybowanie za pomocą modułu obsługi:

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

Co jest wspólnego dla wszystkich tych przykładów i interfejsów API? W obu przypadkach kod idiomatyczny w argumencie pierwotnym język systemów używa blokującego interfejsu API podczas I/O, natomiast odpowiedni przykład witryny używa asynchronicznego interfejsu API. Podczas kompilowania danych do internetu trzeba w jakiś sposób przekształcić te dwie wartości. modeli wykonywania aplikacji, a WebAssembly nie ma jeszcze do tego wbudowanej funkcji.

Tu się pomaga Asyncify

Dlatego do akcji wkracza funkcja Asyncify. Asyncify to funkcja kompilowania obsługiwana przez Emscripten, która umożliwia wstrzymywanie całego programu asynchronicznie wznawiając go później.

Wykres wywołań
opisanie kodu JavaScript -> WebAssembly -> web API -> asynchroniczne wywołanie zadań, z którym łączy się Asyncify
wynik zadania asynchronicznego z powrotem do WebAssembly

Użycie w języku C / C++ z użyciem funkcji Emscripten

Jeśli w ostatnim przykładzie chcesz użyć funkcji Asyncify, możesz zrobić to w następujący sposób:

#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 to które pozwala definiować fragmenty kodu JavaScript w taki sposób, jakby były one funkcjami C. W środku użyj funkcji Asyncify.handleSleep() który informuje Emscripten o zawieszeniu programu i udostępnia moduł obsługi wakeUp(), który powinien jest wywoływana po zakończeniu operacji asynchronicznej. W powyższym przykładzie moduł obsługi jest przekazywany do setTimeout(), ale można go używać w innych kontekstach, które akceptują wywołania zwrotne. Możesz także Wywołuj funkcję async_sleep() w dowolnym miejscu – tak jak zwykłej funkcji sleep() lub dowolnego innego synchronicznego interfejsu API.

Podczas kompilowania takiego kodu musisz wskazać aplikacji Emscripten włączenie funkcji Asyncify. Zrób to do lepsze wyniki w zakresie -s ASYNCIFY oraz -s ASYNCIFY_IMPORTS=[func1, func2] z tablicową listę funkcji, które mogą być asynchroniczne.

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

Dzięki temu Emscripten będzie wiedzieć, że wywołania tych funkcji mogą wymagać zapisania i przywrócenia więc kompilator wstrzykuje kod pomocniczy wokół takich wywołań.

Teraz gdy wykonasz ten kod w przeglądarce, zobaczysz płynny log wyjściowy, zgodnie z oczekiwaniami. a B jest następne po krótkim opóźnieniu za A.

A
B

Możesz zwrócić wartości z asyncify. Co musisz zwrócić wynik funkcji handleSleep() i przekazać go funkcji wakeUp() oddzwanianie. Jeśli na przykład zamiast odczytywać z pliku, chcesz pobrać numer z pilota zasobu, możesz użyć fragmentu takiego jak ten poniżej, aby wysłać żądanie, zawiesić kod C oraz wznawiane po pobraniu treści odpowiedzi – przebiegają bezproblemowo, tak jakby wywołanie było synchroniczne.

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

W przypadku interfejsów API opartych na obietnicach, takich jak fetch(), możesz nawet połączyć Asyncify z JavaScriptem async-await zamiast używać interfejsu API opartego na wywołaniu zwrotnym. Zamiast Asyncify.handleSleep(), zadzwoń pod numer Asyncify.handleAsync(). Dzięki temu zamiast planować wakeUp(), możesz przekazać funkcję JavaScriptu async oraz używać await i return sprawia, że kod wygląda jeszcze bardziej naturalnie i synchroniczny, nie tracąc przy tym żadnych zalet asynchronicznego wejścia/wyjścia.

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

Oczekiwanie na wartości złożone

Jednak w tym przykładzie nadal ograniczamy się do liczb. Co zrobić, jeśli chcesz zastosować oryginał W którym przypadku próbuję uzyskać z pliku nazwę użytkownika w postaci ciągu znaków? To możesz też zrobić.

Emscripten udostępnia funkcję Embind, który umożliwia do obsługi konwersji między wartościami w JavaScripcie i C++. Obsługuje także program Asyncify, więc możesz wywołać funkcję await() na zewnętrznych urządzeniach Promise – będzie ona działać jak await w trybie asynchronicznym Kod 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>();

Korzystając z tej metody, nie trzeba nawet przekazywać atrybutu ASYNCIFY_IMPORTS jako flagi kompilacji, ponieważ już domyślnie uwzględnione.

Aplikacja Emscripten działa doskonale. Co z innymi łańcuchami narzędzi i językami?

Użycie z innych języków

Załóżmy, że w kodzie Rust masz podobne wywołanie synchroniczne, które chcesz zmapować na async API w internecie. Okazuje się, że to też jest możliwe.

Najpierw musisz zdefiniować taką funkcję jako zwykły import za pomocą bloku extern (lub wybranego składni języka dla funkcji obcych).

extern {
    fn get_answer() -> i32;
}

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

I skompiluj kod do WebAssembly:

cargo build --target wasm32-unknown-unknown

Teraz musisz dodać do pliku WebAssembly kod do przechowywania lub przywrócenia stosu. Dla C / C++ i Esscripten robi to za nas, ale nie są tu używane, dlatego ten proces jest nieco bardziej ręczny.

Na szczęście samo przekształcenie Asyncify jest całkowicie niezależne od łańcucha narzędzi. Może przekształcać dowolne niezależnie od tego, jaki kompilator został wyprodukowany, lub WebAssembly. Przekształcenie jest dostarczane oddzielnie jako część optymalizatora wasm-opt firmy Binaryen łańcuch narzędzi i można go wywołać w ten sposób:

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

Przekazuj --asyncify, aby włączyć przekształcenie, a następnie użyj wartości --pass-arg=…, aby podać dane rozdzielone przecinkami. lista funkcji asynchronicznych, których stan programu ma zostać zawieszony i później wznowiony.

Teraz wystarczy tylko podać pomocniczy kod środowiska wykonawczego, który to zrobi – zawiesić i wznowić kod WebAssembly. W C / C++ kod znajduje się w Emscripten, ale teraz musisz który obsługuje niestandardowe pliki WebAssembly. Utworzyliśmy bibliotekę tylko do tego.

Znajdziesz go w GitHubie na https://github.com/GoogleChromeLabs/asyncify or npm pod nazwą asyncify-wasm.

Symuluje standardową instancję WebAssembly API, ale w ramach własnej przestrzeni nazw. Jedyna jest to, że w zwykłym interfejsie API WebAssembly można udostępniać tylko funkcje synchroniczne jako importowanych danych, a w ramach otoki Asyncify możesz też skorzystać z importów asynchronicznych:

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

Gdy spróbujesz wywołać taką funkcję asynchroniczną – taką jak get_answer() w powyższym przykładzie – z po stronie WebAssembly, biblioteka wykryje zwrócone Promise, zawiesi się i zapisze stan w aplikacji WebAssembly, zasubskrybować usługę zgodną z obietnicą, a później, gdy zostanie zakończona, płynnie przywraca stos i stan wywołań oraz kontynuuje wykonywanie, tak jakby nic się nie stało.

Ponieważ każda funkcja w module może wywołać asynchroniczne, wszystkie eksporty mogą asynchronicznie, więc również zostaną zawijane. W przykładzie powyżej można zauważyć, musi await wynik funkcji instance.exports.main(), aby określić, czy wykonanie jest faktycznie .

Jak to wszystko działa?

Gdy Asyncify wykryje wywołanie jednej z funkcji ASYNCIFY_IMPORTS, uruchomi asynchroniczną operację. zapisuje cały stan aplikacji, w tym stos wywołań lokalnych, a później, po zakończeniu tej operacji, przywraca całą pamięć i cały stos wywołań. wznawia się z tego samego miejsca i z takim samym stanem, jakby program nigdy się nie zatrzymał.

Jest to bardzo podobne do funkcji asynchronicznej oczekiwania w JavaScripcie, którą pokazałem wcześniej, ale w przeciwieństwie do JavaScript, nie wymaga żadnej specjalnej składni ani obsługi środowiska wykonawczego. Zamiast tego polega na przekształcaniu prostych funkcji synchronicznych w czasie kompilacji.

Podczas kompilacji pokazany wcześniej przykład snu asynchronicznego:

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

Asyncify przekształca go w kod podobny do tego poniżej (pseudokod, prawdziwy wymaga znacznie więcej pracy).

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

Początkowo ustawienie mode ma wartość NORMAL_EXECUTION. Odpowiednio przy pierwszym przekształceniu kodu jest wykonywany, ale oceniana jest tylko część prowadząca do async_sleep(). Gdy tylko zaplanowana jest operacja asynchroniczna, funkcja Asyncify zapisuje wszystkie lokalne zasoby i rozwija stos o wróciłby z każdej funkcji na górę, dzięki czemu przeglądarka mogła ponownie uzyskać kontrolę w pętli zdarzeń.

Następnie po rozwiązaniu problemu async_sleep() kod pomocy Asyncify zmieni mode na REWINDING, a ponownie wywołać tę funkcję. Tym razem „normalne wykonanie” gałąź jest pomijana – ponieważ już to zrobiła ostatnim razem i nie chcę drukować litery „A” dwa razy – i zamiast tego spoglądamy prosto do „przewijanie do tyłu” gałąź. Po osiągnięciu tej wartości przywraca wszystkie zapisane lokalne dane i zmienia tryb z powrotem na „normalne” i kontynuuje wykonywanie kodu tak, jakby kod nigdy nie został zatrzymany.

Koszty przekształcenia

Niestety, przekształcenie asyncify nie jest całkowicie bezpłatne, ponieważ musi wstrzyknąć znaczną część kod pomocniczy do przechowywania i przywracania tych wszystkich elementów lokalnych, używając stosu wywołań w różnych trybach i tak dalej. Próbuje zmienić tylko funkcje oznaczone jako asynchroniczne jak i wszystkich potencjalnych elementów wywołujących, ale narzut związany z rozmiarem kodu może i tak dodać do około 50% przed kompresją.

Wykres przedstawiający kod
narzut wielkości w różnych testach porównawczych, od niemal 0% przy dostrojonych warunkach do ponad 100% w najgorszym przypadku
przypadki

Nie jest to idealne rozwiązanie, ale w wielu przypadkach akceptowalne, gdy alternatywa nie zapewnia funkcjonalności lub dokonywania znaczących zmian w oryginalnym kodzie.

Pamiętaj, aby zawsze włączać optymalizacje w ostatecznych kompilacjach, aby uniknąć dalszych wzrostów. Dostępne opcje zaznacz też opcję Optymalizacja dotycząca Asyncify , aby zmniejszyć narzut ograniczenie przekształceń tylko do określonych funkcji i/lub tylko bezpośrednich wywołań funkcji. Dostępna jest też ale jest on ograniczony do samych wywołań asynchronicznych. Jednak w porównaniu w kosztach rzeczywistej pracy, są zazwyczaj nieistotne.

Praktyczne wersje demonstracyjne

Po zapoznaniu się z prostymi przykładami zajmę się teraz bardziej skomplikowanymi scenariuszami.

Jak wspomnieliśmy na początku artykułu, jedną z opcji miejsca na dane w internecie jest asynchronicznego File System Access API. Zapewnia dostęp do systemu plików hosta z aplikacji internetowej.

Z drugiej strony mamy standard defacto o nazwie WASI. dla I/O WebAssembly w konsoli i po stronie serwera. Został zaprojektowany jako cel kompilacji dla w językach systemu, a także udostępnia wszelkiego rodzaju system plików i inne operacje w tradycyjnym synchroniczną.

A gdybyście mogli sobie je zmapować? Następnie można skompilować dowolną aplikację w dowolnym języku źródłowym z dowolnym łańcuchem narzędzi obsługującym cel WASI i uruchamiać go w piaskownicy w internecie, co pozwala na opracowanie prawdziwych plików użytkownika. Asyncify Ci to umożliwi.

W tej prezentacji skompiluję platformę Rust coreutils z kilka drobnych poprawek do WASI, przekazywanych przez przekształcenie Asyncify i wdrożyć asynchroniczne powiązania z WASI do interfejsu File System Access API po stronie JavaScript. Po połączeniu z Xterm.js. Tworzy realistyczną powłokę przeglądarki i działa na plikach prawdziwych użytkowników – zupełnie jak na prawdziwym terminalu.

Sprawdź go na żywo na https://wasi.rreverser.com/.

Asynchronizacja przypadków użycia nie ogranicza się tylko do liczników czasu i systemów plików. Możesz zrobić krok dalej korzystają z bardziej niszowych interfejsów API w internecie.

Na przykład Asyncify umożliwia mapowanie libusb – prawdopodobnie najpopularniejsza biblioteka natywna do pracy z urządzeń USB – do interfejsu WebUSB API, który zapewnia asynchroniczny dostęp do takich urządzeń; w internecie. Po zmapowaniu i skompilowaniu otrzymałam standardowe testy libusb i przykłady do przetestowania w wybranych na urządzeniach mobilnych w piaskownicy strony internetowej.

Zrzut ekranu z libusb
dane wyjściowe debugowania na stronie internetowej z informacjami o połączonym aparacie Canon

To chyba artykuł na inny post na blogu.

Te przykłady pokazują, jak skuteczna może być funkcja Asyncify w wypełnianiu luki i przenoszeniu wszystkich z mnóstwem aplikacji w internecie, co zapewnia dostęp do wielu platform, piaskownicę zabezpieczeń, a wszystko to bez utraty funkcjonalności.