Interfejsy API I/O w internecie są asynchroniczne, ale w większości języków systemowych są synchroniczne. Podczas kompilowania kodu na potrzeby WebAssembly musisz połączyć jeden typ interfejsu API z innym. Do tego służy Asyncify. Z tego artykułu dowiesz się, kiedy i jak używać Asyncify oraz jak działa to narzędzie.
Wejście-wyjście w językach systemowych
Zacznę od prostego przykładu w tonacji C. Załóżmy, że chcesz odczytać imię i nazwisko użytkownika z pliku i przywitać go wiadomością „Cześć, (nazwa użytkownika)!”:
#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 nie robi zbyt wiele, ale pokazuje coś, co znajdziesz w aplikacji o dowolnej wielkości: aplikacja odczytuje dane wejściowe ze świata zewnętrznego, przetworzy je wewnętrznie i zapisze dane wyjściowe z powrotem na zewnątrz. Cała interakcja tego typu ze światem zewnętrznym odbywa się za pomocą kilku funkcji, nazywanych potocznie funkcjami wejścia-wyjścia, a ich skrótami używa się w skrócie I/O.
Aby odczytać nazwę z C, musisz wykonać co najmniej 2 kluczowe wywołania I/O: fopen
, aby 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 na konsoli.
Na pierwszy rzut oka te funkcje wyglądają dość prosto i nie musisz się zastanawiać nad mechanizmami, które są potrzebne do odczytu lub zapisu danych. W zależności od środowiska może się jednak działo sporo:
- Jeśli plik wejściowy znajduje się na dysku lokalnym, aplikacja musi wykonać serię operacji dostępu do pamięci i dysku, aby zlokalizować plik, sprawdzić uprawnienia, otworzyć go do odczytu, a następnie odczytać blok po bloku, aż do uzyskania żądanej liczby bajtów. Może to być dość powolne, w zależności od szybkości dysku i żądanego rozmiaru.
- Plik wejściowy może też znajdować się w lokalizacji podłączonej do sieci, co oznacza, że problem dotyczy także stosu sieciowego, co zwiększa złożoność, czas oczekiwania i liczbę potencjalnych ponownych prób każdej operacji.
- Nie ma gwarancji, że nawet
printf
wydrukuje coś w konsoli i może zostać przekierowany do pliku lub lokalizacji sieciowej. W takim przypadku trzeba będzie wykonać te same czynności co powyżej.
Krótko mówiąc, operacje wejścia-wyjścia mogą przebiegać wolno i nie można przewidzieć, ile czasu zajmie konkretne połączenie, patrząc na kod. Podczas wykonywania tej operacji cała aplikacja będzie wyglądać na zablokowaną i nie będzie reagować na działania użytkownika.
Nie dotyczy to tylko języków C i C++. Większość systemowych języków programowania udostępnia wszystkie dane wejściowe i wyjściowe w formie synchronicznych interfejsów API. Jeśli na przykład przetłumaczysz przykład na Rust, interfejs API może wyglądać na prostszy, ale obowiązują te same zasady. Wystarczy, że wykonasz wywołanie i sychronicznie poczekasz na wynik, podczas gdy funkcja wykonuje wszystkie kosztowne operacje i ostatecznie zwraca wynik w jednym wywołaniu:
fn main() {
let s = std::fs::read_to_string("name.txt");
println!("Hello, {}!", s);
}
Co się jednak stanie, gdy spróbujesz skompilować dowolny z tych przykładów na WebAssembly i przetłumaczyć go na potrzeby internetu? Na przykład, co może oznaczać operacja „odczyt pliku”? Musi odczytać dane z niektórego miejsca do przechowywania.
Asynchroniczny model sieci
W internecie jest wiele różnych opcji przechowywania, które możesz zmapować, np. pamięć podręczną (obiekty JS), localStorage
, IndexedDB, pamięć po stronie serwera i nowy interfejs File System Access API.
Jednak tylko 2 z tych interfejsów API – pamięć w pamięci i localStorage
– mogą być używane synchronicznie. Oba te interfejsy najbardziej ograniczają możliwości przechowywania danych i czasu ich przechowywania. Wszystkie pozostałe opcje
udostępniają tylko asynchroniczne interfejsy API.
Jest to jedna z podstawowych właściwości wykonywania kodu w internecie: każda czasochłonna operacja, która obejmuje operacje wejścia-wyjścia, musi być asynchroniczna.
Dzieje się tak dlatego, że internet był wcześniej jednowątkowy, a każdy kod użytkownika, który dotyka interfejsu użytkownika, musi być uruchamiany w tym samym wątku co interfejs użytkownika. Musi ona konkurować o czas procesora z innymi ważnymi zadaniami, takimi jak układanie, renderowanie i obsługa zdarzeń. Nikt nie chciałby, żeby kod JavaScript lub WebAssembly mógł uruchamiać operację odczytu pliku i blokować wszystko inne – całą kartę lub w przeszłości całą przeglądarkę – w czasie od milisekund do kilku sekund, aż do zakończenia.
Zamiast tego kod może tylko zaplanować operację wejścia/wyjścia wraz z wywołaniem zwrotnym, które zostanie wykonane po jej zakończeniu. Takie wywołania zwrotne są wykonywane w ramach pętli zdarzeń przeglądarki. Nie będę tutaj wchodzić w szczegóły, ale jeśli chcesz dowiedzieć się więcej o tym, jak działa pętla zdarzeń, przeczytaj artykuł Task, microtask, queue and schedules, w którym szczegółowo omawiamy ten temat.
W skrócie: przeglądarka uruchamia wszystkie elementy kodu w nieskończonej pętli, pobierając je z kolejki pojedynczo. Po wywołaniu zdarzenia przeglądarka dodaje do kolejki odpowiedni moduł obsługi, a w kolejnej iteracji jest pobierany z kolejki i uruchamiany. Ten mechanizm umożliwia symulowanie współbieżności i wykonywanie wielu operacji równoległych przy użyciu tylko jednego wątku.
Ważne, aby pamiętać o tym mechanizmie, to podczas wykonywania niestandardowego kodu JavaScript (lub WebAssembly) pętla zdarzeń jest blokowana i mimo że nie ma możliwości reagowania na zewnętrzne moduły obsługi, zdarzenia, wejścia-wyjścia itp. Jedynym sposobem na odzyskanie wyników wejścia/wyjścia jest zarejestrowanie wykonania wywołania zwrotnego, zakończenie kodu i przekazanie kontroli w taki sposób, aby przeglądarka mogła zachować kontrolę. Gdy operacja wejścia/wyjścia się zakończy, twój handler stanie się jednym z tych zadań i zostanie wykonany.
Jeśli np. chcesz przepisać powyższe przykłady w nowoczesnej wersji JavaScriptu i zamiast tego odczytać nazwę z zewnętrznego adresu URL, użyjesz 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);
}
Mimo że wygląda to na działanie synchroniczne, pod maską każdy element await
jest w istocie skrótem dla wywołań zwrotnych:
function main() {
return fetch("name.txt")
.then(response => response.text())
.then(name => console.log("Hello, %s!", name));
}
W tym przykładzie bez cukru, który jest nieco bardziej przejrzysty, żądanie jest uruchamiane, a odpowiedzi są subskrybowane w ramach pierwszego wywołania zwrotnego. Gdy przeglądarka otrzyma początkową odpowiedź (tylko nagłówki HTTP), wywołuje tę funkcję odwoływania asinkronicznie. Wywołanie zwrotne zaczyna odczytywać treść jako tekst za pomocą funkcji response.text()
i subskrybuje wynik za pomocą kolejnego wywołania zwrotnego. Gdy fetch
pobierze całą zawartość, wywoła ostatnią funkcję z powrotu, która wypisuje na konsoli „Cześć, (username)!”.
Dzięki asynchronicznemu charakterowi tych kroków oryginalna funkcja może zwrócić kontrolę przeglądarce, gdy tylko zaplanowane zostaną operacje wejścia/wyjścia, i pozostawić cały interfejs użytkownika w stanie gotowości do obsługi innych zadań, takich jak renderowanie, przewijanie itp., podczas gdy operacje wejścia/wyjścia będą wykonywane w tle.
Na koniec warto wspomnieć, że nawet proste interfejsy API, takie jak „sleep”, które powodują, że aplikacja czeka przez określony czas w sekundach, są też formą operacji wejścia–wyjścia:
#include <stdio.h>
#include <unistd.h>
// ...
printf("A\n");
sleep(1);
printf("B\n");
Oczywiście. Możesz przetłumaczyć go w prosty sposób, aby zablokować bieżący wątek do czasu wygaśnięcia limitu czasu:
console.log("A");
for (let start = Date.now(); Date.now() - start < 1000;);
console.log("B");
W fakcie właśnie to robi Emscripten w swojej domyślnej implementacji funkcji „sleep”, ale jest to bardzo niewydajne, ponieważ blokuje cały interfejs użytkownika i nie pozwala na przetwarzanie innych zdarzeń. Zazwyczaj nie należy tego robić w kodzie produkcyjnym.
Bardziej idiomatyczna wersja „uśpienia” w języku JavaScript wymagałaby wywołania funkcji setTimeout()
i subskrybowania go za pomocą modułu obsługi:
console.log("A");
setTimeout(() => {
console.log("B");
}, 1000);
Co łączy wszystkie te przykłady i interfejsy API? W każdym przypadku idiomatyczny kod w pierwotnym języku systemowym używa blokującego interfejsu API do operacji wejścia/wyjścia, podczas gdy odpowiedni przykład w internecie używa asynchronicznego interfejsu API. Podczas kompilowania kodu na potrzeby internetu trzeba jakoś przekształcić te 2 modele wykonywania, a WebAssembly nie ma jeszcze wbudowanej możliwości, która umożliwiłaby to zrobienie.
Rozwiązanie problemu za pomocą Asyncify
Właśnie w tym momencie do akcji wkraczają skrypty Asyncify. Asyncify to funkcja kompilacji obsługiwana przez Emscripten, która umożliwia wstrzymanie całego programu i jego asynchroniczne wznowienie w późniejszym czasie.
Użycie w języku C/C++ z Emscriptenem
Jeśli w przypadku ostatniego przykładu chcesz użyć Asyncify do zaimplementowania asynchronicznego uśpienia, możesz to zrobić w ten 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 makro, które pozwala definiować fragmenty kodu JavaScript w taki sposób, jakby były funkcjami C. Użyj w środku funkcji Asyncify.handleSleep()
, która informuje Emscripten o zawieszeniu programu i udostępnia moduł obsługi wakeUp()
, który należy wywołać po zakończeniu operacji asynchronicznej. W przykładzie powyżej uchwyt jest przekazywany do funkcji setTimeout()
, ale można go użyć w dowolnym innym kontekście, który akceptuje wywołania zwrotne. Na koniec możesz wywołać async_sleep()
w dowolnym miejscu, tak jak zwykłe sleep()
lub dowolny inny synchroniczny interfejs API.
Podczas kompilowania takiego kodu musisz poprosić Emscripten o włączenie funkcji Asyncify. Aby to zrobić, prześlij parametr -s ASYNCIFY
oraz -s ASYNCIFY_IMPORTS=[func1,
func2]
z listą funkcji o strukturze tablicy, które mogą być asynchroniczne.
emcc -O2 \
-s ASYNCIFY \
-s ASYNCIFY_IMPORTS=[async_sleep] \
...
Dzięki temu Emscripten wie, że wywołania tych funkcji mogą wymagać zapisywania i przywracania stanu, więc kompilator wstrzykuje kod pomocniczy wokół takich wywołań.
Gdy uruchomisz ten kod w przeglądarce, zobaczysz płynny dziennik wyjściowy, w którym instrukcja B będzie się pojawiać po krótkim opóźnieniu po instrukcji A.
A
B
Możesz też zwracać wartości z funkcji Asyncify. Musisz tylko zwrócić wynik funkcji handleSleep()
i przekazać go do wywołania zwrotnego wakeUp()
. Jeśli na przykład zamiast odczytywać z pliku chcesz pobrać liczbę z zdalnego zasobu, możesz użyć fragmentu kodu podobnego do tego poniżej, aby wysłać żądanie, zawiesić kod C i wznowić jego działanie po pobraniu treści odpowiedzi. Wszystko to odbywa się płynnie, 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 funkcją async-await JavaScriptu zamiast używać interfejsu API opartego na wywołaniu zwrotnym. W tym celu zamiast Asyncify.handleSleep()
wywołaj Asyncify.handleAsync()
. Zamiast planować wywołanie funkcji wakeUp()
, możesz przekazać funkcję JavaScript async
i użyć w niej funkcji await
i return
, dzięki czemu kod będzie wyglądał jeszcze bardziej naturalnie i synchronicznie, a Ty nie stracisz żadnych korzyści asynchronicznych operacji 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();
Oczekywanie wartości złożonych
W tym przykładzie nadal ograniczasz się do liczb. A jeśli chcesz wdrożyć pierwotny przykład, w którym próbuję uzyskać nazwę użytkownika z pliku w postaci ciągu znaków? Możesz to zrobić.
Emscripten udostępnia funkcję Embind, która umożliwia konwersję wartości JavaScript na C++ i odwrotnie. Obsługuje też Asyncify, więc możesz wywoływać await()
w zewnętrznych Promise
, a będzie to działać tak samo jak await
w asynchronicznym kodzie 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>();
Przy użyciu tej metody nie musisz nawet przekazywać parametru ASYNCIFY_IMPORTS
jako flagi kompilacji, ponieważ jest on już domyślnie uwzględniony.
Wszystko działa świetnie w Emscripten. A co z innymi zestawami narzędzi i językami?
Użycie w innych językach
Załóżmy, że masz w kodzie Rust podobny wywołanie synchroniczne, które chcesz zmapować na asynchroniczny interfejs API w internecie. Okazuje się, że Ty też możesz to zrobić.
Najpierw musisz zdefiniować taką funkcję jako zwykły import za pomocą bloku extern
(lub składni wybranego 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. W przypadku C/C++ Emscripten wykonałby to za nas, ale nie jest tu używany, więc proces jest nieco bardziej ręczny.
Na szczęście przekształcenie Asyncify jest całkowicie niezależne od narzędzia. Może przekształcać dowolne pliki WebAssembly niezależnie od tego, jaki kompilator został wyprodukowany. Transformacja jest udostępniana osobno w ramach optymalizatora wasm-opt
z pakietu narzędzi Binaryen i może być wywoływana w ten sposób:
wasm-opt -O2 --asyncify \
--pass-arg=asyncify-imports@env.get_answer \
[...]
Przekaż wartość --asyncify
, aby włączyć przekształcenie, a potem użyj parametru --pass-arg=…
, aby podać rozdzieloną przecinkami listę funkcji asynchronicznych, w których stan programu powinien zostać zawieszony, a następnie wznowiony.
Teraz wystarczy Ci tylko przekazać pomocniczy kod środowiska wykonawczego, który to zrobi. Zawieś i wznów kod WebAssembly. W przypadku C/C++ jest to uwzględniane przez Emscripten, ale teraz potrzebujesz niestandardowego kodu JavaScript, który będzie obsługiwać dowolne pliki WebAssembly. Stworzyliśmy bibliotekę właśnie w tym celu.
Znajdziesz go na GitHubie pod adresem https://github.com/GoogleChromeLabs/asyncify lub npm pod nazwą asyncify-wasm
.
Symuluje on standardowe API instancjowania WebAssembly, ale w ramach własnej przestrzeni nazw. Jedyna różnica polega na tym, że w standardowym interfejsie WebAssembly API jako importu możesz udostępniać tylko funkcje synchroniczne, natomiast w ramach otoki Asyncify możesz udostępniać również importy asynchroniczne:
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 przykładzie powyżej – od strony WebAssembly, biblioteka wykryje zwrócony element Promise
, zawiesi działanie i zapisze stan aplikacji WebAssembly, zasubskrybuje obietnicę, a później bez problemu przywróci stos i stan wywołań i kontynuuje wykonywanie kodu tak, jakby nic się nie stało.
Ponieważ każda funkcja w module może wywołać wywołanie asynchroniczne, wszystkie eksporty mogą też być asynchroniczne, więc również zostaną owinięte. W przykładzie powyżej zauważyłeś, że aby uzyskać informację o tym, kiedy zadanie dobiega końca, musisz await
wynik funkcji instance.exports.main()
.
Jak to wszystko działa?
Gdy Asyncify wykryje wywołanie jednej z funkcji ASYNCIFY_IMPORTS
, rozpoczyna asynchroniczną operację, zapisuje cały stan aplikacji, w tym stos wywołań i wszystkie tymczasowe zmienne lokalne, a później, gdy operacja się zakończy, przywraca całą pamięć i stos wywołań oraz wznawia działanie od tego samego miejsca i w tym samym stanie, jakby program nigdy nie został zatrzymany.
Jest to funkcja podobna do async-await w JavaScript, którą pokazałem wcześniej, ale w odróżnieniu od niej nie wymaga żadnej specjalnej składni ani obsługi w czasie wykonywania przez język. Zamiast tego działa poprzez przekształcanie zwykłych synchronicznych funkcji w czasie kompilacji.
Podczas kompilowania wcześniejszego przykładu wywołania asynchronicznego:
puts("A");
async_sleep(1);
puts("B");
Asyncify przekształca ten kod w postać mniej więcej taką: (pseudokod, rzeczywista transformacja jest bardziej skomplikowana):
if (mode == NORMAL_EXECUTION) {
puts("A");
async_sleep(1);
saveLocals();
mode = UNWINDING;
return;
}
if (mode == REWINDING) {
restoreLocals();
mode = NORMAL_EXECUTION;
}
puts("B");
Na początku parametr mode
ma wartość NORMAL_EXECUTION
. W związku z tym przy pierwszym wykonaniu tak przekształconego kodu zostanie oceniona tylko część do async_sleep()
. Gdy tylko zaplanujesz operację asynchroniczną, Asyncify zapisze wszystkie zasoby lokalne i rozwija stos, wracając z każdej funkcji na sam początek, dając kontrolę nad pętlą zdarzeń w przeglądarce.
Gdy async_sleep()
zostanie rozwiązany, kod obsługi Asyncify zmieni wartość mode
na REWINDING
i ponownie wywoła funkcję. Tym razem gałąź „normalnego wykonania” jest pomijana, ponieważ już wykonała zadanie podczas poprzedniego wywołania, a ja chcę uniknąć dwukrotnego drukowania „A”. Zamiast tego przechodzi od razu do gałęzi „przewijania wstecz”. Po osiągnięciu tego punktu przywraca wszystkie zapisane lokalne, zmienia tryb z powrotem na „normalny” i kontynuuje wykonywanie kodu tak, jakby nigdy nie został zatrzymany.
Koszty transformacji
Niestety przekształcenie Asyncify nie jest całkowicie bezpłatne, ponieważ musi wstrzyknąć sporo kodu pomocniczego do przechowywania i przywracania wszystkich tych zmiennych lokalnych, poruszania się po stosie wywołań w różnych trybach itp. Próbuje modyfikować tylko funkcje oznaczone w wierszu poleceń jako asynchroniczne, a także wszystkie ich potencjalne elementy wywołujące, ale narzut rozmiaru kodu może nadal wynosić około 50% przed kompresją.
Nie jest to jednak idealne rozwiązanie, ale w wielu przypadkach dopuszczalne, gdy rozwiązanie nie działa w całości lub nie wymaga wprowadzania znaczących zmian w oryginalnym kodzie.
Pamiętaj, aby zawsze włączać optymalizacje w przypadku wersji końcowych, aby nie zwiększać jeszcze bardziej ich rozmiaru. Możesz też zaznaczyć opcje optymalizacji dotyczące Asyncify, aby zmniejszyć obciążenie, ograniczając przekształcenia tylko do określonych funkcji lub tylko do bezpośrednich wywołań funkcji. Wywołania asynchroniczne mają też niewielki wpływ na wydajność w czasie wykonywania, ale dotyczy to tylko samych wywołań. Jednak w porównaniu z kosztem faktycznej pracy jest to zwykle nieistotne.
Demonstracje w rzeczywistych warunkach
Teraz, gdy znasz już proste przykłady, przejdę do bardziej skomplikowanych scenariuszy.
Jak wspomniano na początku tego artykułu, jedną z opcji przechowywania w internecie jest asynchroniczny interfejs File System Access API. Zapewnia dostęp z aplikacji internetowej do rzeczywistego systemu plików hosta.
Z drugiej strony w konsoli i po stronie serwera dostępny jest standard defacto o nazwie WASI dla I/O WebAssembly. Został zaprojektowany jako docel kompilacji dla języków systemowych i wyświetla wszystkie rodzaje systemów plików oraz inne operacje w tradycyjnej formie asynchronicznej.
Co jeśli można by je ze sobą powiązać? Następnie można skompilować dowolną aplikację w dowolnym języku źródłowym za pomocą dowolnego zestawu narzędzi obsługującego docel WASI i uruchomić ją w piaskownicy w internecie, zachowując przy tym możliwość działania na rzeczywistych plikach użytkownika. Dzięki Asyncify możesz to zrobić.
W tym pokazie skompilowałem bibliotekę coreutils w Rust z kilkoma drobnymi poprawkami do WASI, przekazanymi za pomocą transformacji Asyncify, oraz zaimplementowałem asynchroniczne wiązania z WASI do interfejsu File System Access API po stronie JavaScript. Po połączeniu z elementem terminala Xterm.js zapewnia realistyczne środowisko, które działa w karcie przeglądarki i obsługuje rzeczywiste pliki użytkownika – tak jak prawdziwy terminal.
Możesz go sprawdzić na żywo na stronie https://wasi.rreverser.com/.
Zastosowania asynchronizacji nie ograniczają się tylko do zegarów i systemów plików. Możesz też użyć bardziej wyspecjalizowanych interfejsów API w internecie.
Na przykład za pomocą Asyncify można mapować libusb – prawdopodobnie najpopularniejszą natywną bibliotekę do pracy z urządzeniami USB – na WebUSB API, które zapewnia asynchroniczny dostęp do takich urządzeń w internecie. Po zmapowaniu i skompilowaniu mam standardowe testy i przykłady libusb do uruchomienia na wybranych urządzeniach bezpośrednio w piaskownicy strony internetowej.
To chyba artykuł na inny post na blogu.
Te przykłady pokazują, jak potężne może być narzędzie Asyncify w zaspokajaniu potrzeb użytkowników i przenoszeniu różnych aplikacji do sieci. Umożliwia ono uzyskiwanie dostępu na różnych platformach, tworzenie piaskownicy i zwiększanie bezpieczeństwa bez utraty funkcjonalności.