Korzystanie z asynchronicznych internetowych interfejsów API z WebAssembly

Internetowe interfejsy API wejścia-wyjścia są asynchroniczne, ale w większości języków systemowych są synchroniczne. Przy kompilowaniu kodu pod kątem WebAssembly musisz połączyć ze sobą jeden rodzaj interfejsów API. Ten most to Asyncify. Z tego posta dowiesz się, kiedy i jak używać Asyncify, a także jak działa ta usługa.

I/O w językach systemu

Zacznę od prostego przykładu w języku C. Załóżmy, że chcesz odczytać z pliku imię i nazwisko użytkownika i powitać go komunikatem „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;
}

Ten przykład niczego nie daje, ale już pokazuje coś, co znajdziesz w aplikacji o dowolnej wielkości: odczytuje dane wejściowe ze świata zewnętrznego, przetwarza je wewnętrznie i zapisuje dane wyjściowe z powrotem na zewnątrz. Wszystkie te interakcje ze światem zewnętrznym odbywają się za pomocą kilku funkcji, zwanych też funkcjami wejścia-wyjścia (skróconymi do wejścia-wyjścia).

Aby odczytać nazwę z instancji C, potrzebujesz co najmniej 2 ważnych wywołań wejścia/wyjścia: fopen (do otwarcia pliku) i fread do odczytu z niego danych. 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, a ich odczytywanie i zapisywanie danych nie wymaga dużo zastanawiania się nad maszyną. Jednak w zależności od środowiska może się dziać sporo:

  • Jeśli plik wejściowy znajduje się na dysku lokalnym, aplikacja musi dokonać serii dostępu do pamięci i dysku, aby go zlokalizować, sprawdzić uprawnienia, otworzyć go w celu odczytu, a następnie odczytywać blok po bloku do momentu pobrania żądanej liczby bajtów. Może to przebiegać dość wolno 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 takim przypadku weźmie on udział również w stosie sieciowym, co zwiększy złożoność i czas oczekiwania oraz liczbę potencjalnych ponownych prób w przypadku każdej operacji.
  • Nie ma gwarancji, że nawet printf wydrukuje rzeczy w konsoli i może zostać przekierowany do pliku lub lokalizacji sieciowej. W takim przypadku trzeba będzie wykonać powyższe czynności.

W skrócie, I/O może trwać długo i nie można przewidzieć, ile czasu zajmie konkretne połączenie, jeśli wystarczy spojrzeć na kod. Podczas wykonywania tej operacji cała aplikacja będzie sprawiała wrażenie zamrożonej i nie będzie odpowiadać użytkownikowi.

Nie ogranicza się to także do języków C i C++. Większość języków systemowych prezentuje wszystkie operacje wejścia-wyjścia w formie synchronicznych interfejsów API. Jeśli na przykład przetłumaczysz przykład na kod Rust, interfejs API może wyglądać prostszy, ale obowiązują te same zasady. Po prostu wykonujesz wywołanie i synchronicznie czekasz na zwrócenie wyniku, podczas gdy wykonuje się wszystkie kosztowne operacje i w końcu zwraca wynik w jednym wywołaniu:

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

Co się jednak dzieje, gdy próbujesz skompilować te przykłady w WebAssembly i przetłumaczyć je na sieć? Albo, aby podać konkretny przykład, na co może wskazywać operacja „odczyt pliku”? Konieczne byłoby odczytywanie danych z jakiegoś miejsca.

Model asynchroniczny sieci

W internecie jest wiele różnych opcji miejsca na dane, które można zmapować, np. pamięć masowa (obiekty JS), localStorage, IndexedDB, pamięć po stronie serwera czy nowy File System Access API.

Jednak tylko 2 z tych interfejsów API – pamięć w pamięci i localStorage – mogą być używane synchronicznie i oba opcje są najbardziej restrykcyjne pod względem tego, co i jak długo można przechowywać. Pozostałe opcje oferują wyłącznie asynchroniczne interfejsy API.

To jedna z podstawowych właściwości wykonywania kodu w internecie: każda czasochłonna operacja, która obejmuje dowolne operacje wejścia-wyjścia, musi odbywać się asynchronicznie.

Wynika to z tego, że sieć była dotąd jednowątkowa, a każdy kod użytkownika, który dotyka interfejsu użytkownika, musi działać w tym samym wątku co ten interfejs. Musi konkurować o czas pracy procesora z innymi ważnymi zadaniami, takimi jak układ, renderowanie i obsługa zdarzeń. Kod JavaScript lub WebAssembly nie powinien uruchamiać operacji odczytu pliku i blokować wszystko inne – całą kartę lub całą przeglądarkę – na czas od milisekund do kilku sekund, aż do jej zakończenia.

Zamiast tego kod może zaplanować operację wejścia-wyjścia wraz z wywołaniem zwrotnym, które ma zostać wykonane po jej zakończeniu. Takie wywołania zwrotne są realizowane w ramach pętli zdarzeń przeglądarki. Nie będę tu zagłębiać się w szczegóły. Jeśli chcesz dowiedzieć się, jak działa pętla zdarzeń, przeczytaj szczegółowe informacje na ten temat na stronie o zadaniach, mikrozadaniach, kolejkach i harmonogramach.

W skrócie: przeglądarka uruchamia wszystkie fragmenty kodu w nieskończonej pętli, pobierając je jeden po drugim. Po wywołaniu jakiegoś zdarzenia przeglądarka doda odpowiedni moduł obsługi do kolejki, a w kolejnej jego powtórzeniu jest usuwany z kolejki i wykonywany. Ten mechanizm umożliwia symulowanie równoczesności i wykonywanie wielu operacji równoległych przy użyciu tylko jednego wątku.

Warto pamiętać o tym mechanizmie, że podczas wykonywania niestandardowego kodu JavaScript (lub WebAssembly) pętla zdarzeń jest zablokowana i nie ma możliwości reagowania na zewnętrzne moduły obsługi, zdarzenia, wejścia-wyjścia itp. Jedynym sposobem na uzyskanie wyników wejścia/wyjścia jest zarejestrowanie wywołania zwrotnego, zakończenie wykonywania kodu i przywrócenie kontroli do przeglądarki. Po zakończeniu wejścia-wyjścia Twój moduł obsługi stanie się jednym z tych zadań i zostanie wykonany.

Jeśli chcesz np. zmodyfikować powyższe przykłady we współczesnym języku JavaScript i chcesz odczytać nazwę ze zdalnego adresu URL, użyj interfejsu Fetch API i składni asynchronicznej:

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

Chociaż każdy element await wygląda synchronicznie, jako składnia wywołań zwrotnych jest zasadniczo cukier składniowy:

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 zrozumiały, żądanie jest wysyłane, a odpowiedzi są zasubskrybowane przy pierwszym wywołaniu zwrotnym. Gdy przeglądarka otrzyma początkową odpowiedź (tylko nagłówki HTTP), wywoła to wywołanie zwrotne asynchronicznie. Wywołanie zwrotne rozpoczyna odczytywanie treści jako tekstu za pomocą funkcji response.text() i subskrybuje wynik za pomocą kolejnego wywołania zwrotnego. Gdy fetch pobierze całą zawartość, wywołuje w konsoli ostatnie wywołanie zwrotne, które wyświetla w konsoli tekst „Hello, (username)!”.

Dzięki asynchronicznemu naturze tych kroków pierwotna funkcja może zwrócić kontrolę do przeglądarki natychmiast po zaplanowaniu wejścia-wyjścia i pozostawić cały interfejs użytkownika w elastycznym działaniu i dostępny dla innych zadań, w tym renderowania, przewijania itd., podczas gdy operacje wejścia-wyjścia są wykonywane w tle.

Ostatnim przykładem są też proste interfejsy API, takie jak „uśpienie”, które sprawiają, że aplikacja oczekuje określoną liczbę sekund, są również 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 bardzo prosty sposób, który blokowałby bieżący wątek do czasu zakończenia:

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

W rzeczywistości to właśnie robi Emscripten w swojej domyślnej implementacji „uśpienia”, ale takie działanie jest bardzo nieefektywne, blokuje cały interfejs użytkownika i nie pozwala na obsługę żadnych innych zdarzeń. Ogólnie nie rób tego w kodzie produkcyjnym.

Bardziej idiomatyczna wersja wyrażenia „sleep” w języku JavaScript wymagałaby wywołania setTimeout() i subskrypcji za pomocą modułu obsługi:

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

Co jest wspólne dla tych wszystkich przykładów i interfejsów API? W każdym przypadku kod idiomatyczny w pierwotnym języku systemów używa blokującego API na potrzeby wejścia-wyjścia, podczas gdy w jego odpowiedniku w sieci stosuje się asynchroniczny interfejs API. Podczas kompilacji w internecie musisz w jakiś sposób przekształcić te 2 modele wykonawcze. WebAssembly nie ma jeszcze wbudowanych funkcji, które to umożliwia.

Tu się pomaga za pomocą Asyncify

W tym celu warto skorzystać z funkcji Asyncify. Asyncify to funkcja czasu kompilacji obsługiwana przez Emscripten, która umożliwia wstrzymywanie całego programu i asynchroniczne wznawianie go później.

Wykres wywołań opisujący JavaScript -> WebAssembly -> internetowy interfejs API -> asynchroniczne wywołanie zadania, gdzie Asyncify łączy wynik zadania asynchronicznego z powrotem z WebAssembly

Użycie w językach C / C++ z Emscripten

Jeśli chcesz użyć Asyncify, aby zaimplementować asynchroniczny sen w ostatnim przykładzie, możesz to zrobić 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 makro, które umożliwia definiowanie fragmentów kodu JavaScript w taki sposób, jakby były funkcjami C. Użyj w nim funkcji Asyncify.handleSleep(), która informuje Emscripten, że ma zawiesić program, i udostępnia moduł obsługi wakeUp(), który należy wywołać po zakończeniu operacji asynchronicznej. W powyższym przykładzie moduł obsługi jest przekazywany do funkcji setTimeout(), ale można go używać w dowolnym 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 należy poprosić Emscripten o aktywowanie funkcji Asyncify. Zrób to, pomijając -s ASYNCIFY i -s ASYNCIFY_IMPORTS=[func1, func2] z listą funkcji przypominających tablicę, które mogą być asynchroniczne.

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

Dzięki temu Emscripten wie, że wszelkie wywołania tych funkcji mogą wymagać zapisania i przywrócenia stanu, więc kompilator będzie wstrzyknąć kod pomocniczy do tych wywołań.

Teraz, gdy uruchomisz ten kod w przeglądarce, zobaczysz płynny dziennik wyjściowy, zgodnie z oczekiwaniami, gdzie wartość B pojawi się z krótkim opóźnieniem po ścieżce A.

A
B

Możesz też zwracać wartości z funkcji Asyncify. Należy tylko zwrócić wynik funkcji handleSleep() i przekazać go do wywołania zwrotnego wakeUp(). Jeśli na przykład zamiast odczytują dane z pliku, chcesz pobrać numer z zasobu zdalnego, możesz użyć fragmentu opisanego poniżej, aby wysłać żądanie, zawiesić kod C i wznowić jego działanie po pobraniu treści odpowiedzi – wszystko odbywa się płynnie, jakby wywołania były 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żna nawet połączyć Asyncify z asynchronizacją w języku JavaScript, zamiast korzystać z interfejsu API opartego na wywołaniach zwrotnych. Aby to zrobić, zamiast Asyncify.handleSleep() użyj funkcji Asyncify.handleAsync(). Potem zamiast planować wywołanie zwrotne wakeUp(), możesz przekazać funkcję JavaScript async i użyć w niej await oraz return. Dzięki temu kod będzie wyglądał jeszcze bardziej naturalnie i synchronicznie, a jednocześnie nie stracisz ż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 zespolone

Jednak ten przykład nadal ogranicza się tylko do liczb. A jeśli chcesz wdrożyć pierwotny przykład, w którym próbowano pobrać z pliku nazwę użytkownika jako ciąg znaków? Ty też możesz to zrobić!

Emscripten udostępnia funkcję o nazwie Embind, która umożliwia obsługę konwersji między wartościami JavaScript a wartościami C++. Obsługuje też funkcję Asyncify, więc możesz wywołać metodę await() w zewnętrznych układach Promise, która będzie działać 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>();

Gdy korzystasz z tej metody, nie musisz nawet przesyłać flagi ASYNCIFY_IMPORTS jako flagi kompilacji, ponieważ jest ona już uwzględniona domyślnie.

OK, więc w Emscripten wszystko działa doskonale. Co z innymi łańcuchami narzędzi i językami?

Użycie z innych języków

Załóżmy, że w dowolnym miejscu kodu Rust masz podobne wywołanie synchroniczne, które chcesz zmapować na asynchroniczny interfejs API w internecie. Okazuje się, że Ty też możesz tak 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 w WebAssembly:

cargo build --target wasm32-unknown-unknown

Teraz musisz dodać do pliku WebAssembly kod do przechowywania/przywracania stosu. W przypadku języka C i C++ program Emscripten zrobi to za nas, ale nie używa się go tutaj, więc proces jest nieco bardziej ręczny.

Na szczęście sama transformata Asyncify jest całkowicie niezależna od łańcucha narzędzi. Może przekształcać dowolne pliki WebAssembly niezależnie od tego, przez który kompilator jest tworzony. Przekształcenie jest dostarczane niezależnie jako część optymalizatora wasm-opt i pęku narzędzi Binaryen. Można je wywoływać w ten sposób:

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

Przekaż --asyncify, aby włączyć przekształcenie, a następnie użyj funkcji --pass-arg=…, aby podać rozdzieloną przecinkami listę funkcji asynchronicznych, w których przypadku stan programu powinien zostać zawieszony, a następnie wznowiony.

Wystarczy jeszcze przesłać kod środowiska wykonawczego, które to umożliwia – zawiesić i wznowić kod WebAssembly. Tutaj również w przypadku języka C / C++ umieszczamy te dane przez Emscripten, ale teraz potrzebny jest niestandardowy kod typu klej w języku JavaScript, który obsługuje dowolne pliki WebAssembly. Właśnie do tego utworzyliśmy bibliotekę.

Znajdziesz ją na GitHubie na stronie https://github.com/GoogleChromeLabs/asyncify lub npm pod nazwą asyncify-wasm.

Symuluje standardowy interfejs API instancji WebAssembly, ale we własnej przestrzeni nazw. Jedyną różnicą jest to, że w ramach zwykłego interfejsu WebAssembly API możesz udostępniać jako importowanie tylko funkcje synchroniczne, a w opakowaniu Asyncify możesz też wykonywać 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ą, np. get_answer() w przykładzie powyżej, po stronie WebAssembly biblioteka wykryje zwrócony komponent Promise, zawiesi i zapisze stan aplikacji WebAssembly, zasubskrybuje realizację obietnicy, a po rozwiązaniu problemu bez problemu przywróci stos wywołań i stan oraz kontynuuje wykonywanie, jak gdyby nic się nie wydarzyło.

Każda funkcja w module może wykonywać asynchroniczne wywołania, więc wszystkie eksporty również stają się asynchroniczne i również są opakowane. Być może zauważyłeś w przykładzie powyżej, że aby wiedzieć, kiedy wykonanie kodu rzeczywiście się zakończy, musisz await w wyniku funkcji instance.exports.main().

Jak to wszystko działa?

Gdy Asyncify wykryje wywołanie jednej z funkcji ASYNCIFY_IMPORTS, uruchamia operację asynchroniczną, zapisuje cały stan aplikacji, w tym cały stos wywołań i wszystkie tymczasowe obiekty lokalne, a po zakończeniu tej operacji przywraca całą pamięć i stos wywołań oraz wznawia operację w tym samym miejscu i w tym samym stanie, jak gdyby program nigdy się nie zatrzymał.

Jest to funkcja bardzo podobna do funkcji asynchronicznej w JavaScripcie, którą pokazaliśmy wcześniej. Jednak w przeciwieństwie do JavaScriptu nie wymaga specjalnej obsługi składni ani działania w środowisku wykonawczym. Zamiast tego działa przez przekształcanie zwykłych funkcji synchronicznych w czasie kompilowania.

Podczas kompilowania pokazanego wcześniej przykładu snu asynchronicznego:

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

Asyncify pobiera ten kod i przekształca go w mniej więcej taki sposób (pseudokod, rzeczywiste przekształcenie)

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 wykonaniu takiego przekształconego kodu oceniana jest tylko część poprzedzająca async_sleep(). Gdy tylko zaplanujesz operację asynchroniczną, Asyncify zapisuje wszystkie lokalne i odpręża stos, powracając z każdej funkcji aż do samej góry. W ten sposób odzyskasz kontrolę nad pętlą zdarzeń przeglądarki.

Następnie, gdy async_sleep() rozwiąże ten problem, kod pomocy Asyncify zmieni się z mode na REWINDING i ponownie wywoła tę funkcję. Tym razem gałąź „zwykłe wykonanie” została pominięta, ponieważ ostatnio wykonała zadanie, a ja chcę uniknąć dwukrotnego drukowania zdania „A”. Zamiast tego gałąź „A” sprowadza się bezpośrednio do gałęzi do tyłu. Po osiągnięciu tego trybu przywraca on wszystkie zapisane lokalne, zmienia tryb z powrotem na „normalny” i kontynuuje wykonywanie kodu tak, jakby kod nigdy nie został zatrzymany.

Koszty przekształcenia

Transformacja Asyncify nie jest niestety całkowicie bezpłatna, ponieważ musi wstrzyknąć dużo kodu obsługującego przechowywanie i przywracanie danych lokalnych, poruszanie się po stosie wywołań w różnych trybach itd. Próbuje modyfikować tylko funkcje oznaczone jako asynchroniczne w wierszu poleceń, a także wszystkie ich potencjalne elementy wywołujące, ale przed skompresowaniem narzut rozmiaru kodu może i tak sumować się do około 50%.

Wykres przedstawiający narzut rozmiaru kodu w różnych testach porównawczych: od prawie 0% w warunkach dopracowanych do ponad 100% w najgorszych przypadkach.

Nie jest to idealne rozwiązanie, ale w wielu przypadkach akceptowalne jest, gdy alternatywą jest brak wszystkich funkcji lub konieczność wprowadzenia znaczących zmian w oryginalnym kodzie.

Pamiętaj, aby w końcowych kompilacjach zawsze włączyć optymalizację, aby nie podnosić go jeszcze wyżej. Możesz też zaznaczyć opcje optymalizacji związane z Asyncify, aby zmniejszyć nakład pracy przez ograniczenie przekształceń tylko do określonych funkcji lub tylko do bezpośrednich wywołań funkcji. Wiąże się to też z niewielkim kosztem wydajności, ale ogranicza się tylko do samych wywołań asynchronicznych. Jednak w porównaniu z kosztem faktycznej pracy jest on zazwyczaj nieistotny.

Praktyczne wersje demonstracyjne

Po zapoznaniu się z prostymi przykładami przejdźmy do bardziej złożonych scenariuszy.

Jak wspomnieliśmy na początku artykułu, jedną z opcji przechowywania danych w internecie jest asynchroniczny interfejs File System Access API. Zapewnia dostęp do rzeczywistego systemu plików hosta z poziomu aplikacji internetowej.

Z drugiej strony w konsoli i po stronie serwera znajduje się standard WASI dla WebAssembly I/O. Został on zaprojektowany jako cel kompilacji dla języków systemowych i udostępnia różne rodzaje systemów plików oraz inne operacje w tradycyjnej postaci synchronicznej.

A gdyby można było zmapować je na inne? Następnie można skompilować dowolną aplikację w dowolnym języku źródłowym z dowolnym łańcuchem narzędzi obsługującym środowisko docelowe WASI i uruchomić ją w piaskownicy w internecie, jednocześnie umożliwiając wykonywanie operacji na prawdziwych plikach użytkownika. Jest to możliwe dzięki usłudze Asyncify.

W tej demonstracji skompilowaliśmy plik crate Rust coreutils z kilkoma drobnymi poprawkami do WASI, który został przekazany przez przekształcenie Asyncify i zaimplementowałem po stronie JavaScript asynchroniczne powiązania z WASI z interfejsem File System Access API. W połączeniu z komponentem terminala Xterm.js tworzy realistyczną powłokę działającą w karcie przeglądarki i działającą na prawdziwych plikach użytkownika – zupełnie jak prawdziwy terminal.

Możesz obejrzeć go na żywo na stronie https://wasi.rreverser.com/.

Przypadki użycia asynchronicznej nie ograniczają się też do liczników czasu i systemów plików. Można pójść dalej i wykorzystać bardziej niszowe interfejsy API w internecie.

Na przykład dzięki Asyncify można zmapować libusb – prawdopodobnie najpopularniejszą bibliotekę natywną do pracy z urządzeniami USB – na interfejs WebUSB API, który zapewnia asynchroniczny dostęp do takich urządzeń w internecie. Po zmapowaniu i skompilowaniu mam dostęp do standardowych testów libusb i przykładów, które można uruchomić na wybranych urządzeniach bezpośrednio w piaskownicy strony internetowej.

Zrzut ekranu z wynikiem debugowania biblioteki libusb na stronie
z informacjami o połączonym aparacie Canon

To chyba temat na inny post na blogu.

Te przykłady pokazują, jak wszechstronna może być Asyncify w zakresie wypełniania luk i przenoszenia wszystkich rodzajów aplikacji do internetu, co pozwala uzyskać dostęp do wielu platform, piaskownicę i lepszą ochronę, a wszystko to bez utraty funkcjonalności.