Die I/O-APIs im Web sind asynchron, in den meisten Systemsprachen jedoch synchron. Beim Kompilieren von Code in WebAssembly müssen Sie eine Art von APIs mit einer anderen verbinden. Diese Verbindung ist Asyncify. In diesem Beitrag erfahren Sie, wann und wie Sie Asyncify verwenden und wie es funktioniert.
I/O in Systemsprachen
Ich beginne mit einem einfachen Beispiel in C. Angenommen, Sie möchten den Namen des Nutzers aus einer Datei lesen und ihn mit „Hallo, (Nutzername)!“ begrüßen:
#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;
}
Das Beispiel ist zwar nicht sehr komplex, aber es zeigt bereits etwas, das Sie in einer Anwendung jeder Größe finden werden: Es liest einige Eingaben aus der Außenwelt, verarbeitet sie intern und schreibt Ausgaben zurück in die Außenwelt. Die gesamte Interaktion mit der Außenwelt erfolgt über einige Funktionen, die allgemein als Ein-/Ausgabe-Funktionen bezeichnet werden, abgekürzt E/A.
Um den Namen aus C zu lesen, sind mindestens zwei wichtige E/A-Aufrufe erforderlich: fopen zum Öffnen der Datei und fread zum Lesen von Daten aus der Datei. Nachdem Sie die Daten abgerufen haben, können Sie mit einer anderen E/A-Funktion printf das Ergebnis in der Konsole ausgeben.
Diese Funktionen sehen auf den ersten Blick recht einfach aus und Sie müssen nicht lange über die Mechanismen nachdenken, die zum Lesen oder Schreiben von Daten erforderlich sind. Je nach Umgebung kann es jedoch sehr viel zu tun geben:
- Wenn sich die Eingabedatei auf einem lokalen Laufwerk befindet, muss die Anwendung eine Reihe von Speicher- und Festplattenzugriffen durchführen, um die Datei zu finden, Berechtigungen zu prüfen, sie zum Lesen zu öffnen und dann Block für Block zu lesen, bis die angeforderte Anzahl von Byte abgerufen wurde. Je nach Geschwindigkeit Ihrer Festplatte und der angeforderten Größe kann dies sehr langsam sein.
- Oder die Eingabedatei befindet sich an einem bereitgestellten Netzwerkspeicherort. In diesem Fall ist auch der Netzwerk-Stack beteiligt, was die Komplexität, Latenz und Anzahl potenzieller Wiederholungsversuche für jeden Vorgang erhöht.
- Schließlich ist auch
printfnicht garantiert, dass Dinge in der Konsole ausgegeben werden. Die Ausgabe kann auch an eine Datei oder einen Netzwerkspeicherort umgeleitet werden. In diesem Fall müssten die oben genannten Schritte ausgeführt werden.
Kurz gesagt: I/O kann langsam sein und Sie können nicht vorhersagen, wie lange ein bestimmter Aufruf dauern wird, indem Sie sich den Code kurz ansehen. Während dieser Vorgang ausgeführt wird, friert Ihre gesamte Anwendung ein und reagiert nicht mehr auf Nutzereingaben.
Das ist nicht auf C oder C++ beschränkt. Bei den meisten Systemsprachen werden alle Ein- und Ausgaben in Form von synchronen APIs dargestellt. Wenn Sie das Beispiel beispielsweise in Rust übersetzen, sieht die API möglicherweise einfacher aus, aber es gelten dieselben Prinzipien. Sie führen einfach einen Aufruf aus und warten synchron darauf, dass das Ergebnis zurückgegeben wird. In der Zwischenzeit werden alle rechenintensiven Vorgänge ausgeführt und das Ergebnis wird schließlich in einem einzigen Aufruf zurückgegeben:
fn main() {
let s = std::fs::read_to_string("name.txt");
println!("Hello, {}!", s);
}
Was passiert aber, wenn Sie versuchen, eines dieser Beispiele in WebAssembly zu kompilieren und ins Web zu übertragen? Oder, um ein konkretes Beispiel zu geben: Was könnte der Vorgang „Datei lesen“ bedeuten? Dazu müssten Daten aus einem Speicher gelesen werden.
Asynchrones Modell des Webs
Im Web gibt es verschiedene Speichermöglichkeiten, die Sie zuordnen können, z. B. In-Memory-Speicher (JS-Objekte), localStorage, IndexedDB, serverseitiger Speicher und eine neue File System Access API.
Allerdings können nur zwei dieser APIs – der In-Memory-Speicher und localStorage – synchron verwendet werden. Beide sind die restriktivsten Optionen hinsichtlich der Speicherdauer und der Art der Daten, die gespeichert werden können. Alle anderen Optionen bieten nur asynchrone APIs.
Dies ist eine der wichtigsten Eigenschaften der Ausführung von Code im Web: Jeder zeitaufwendige Vorgang, einschließlich aller E/A-Vorgänge, muss asynchron sein.
Das liegt daran, dass das Web traditionell Single-Threaded ist und jeder Nutzercode, der die Benutzeroberfläche berührt, im selben Thread wie die Benutzeroberfläche ausgeführt werden muss. Sie muss mit anderen wichtigen Aufgaben wie Layout, Rendering und Ereignisverarbeitung um die CPU-Zeit konkurrieren. Es wäre nicht wünschenswert, wenn ein JavaScript- oder WebAssembly-Code einen „Dateilesevorgang“ starten und alles andere blockieren könnte – den gesamten Tab oder in der Vergangenheit den gesamten Browser – für einen Zeitraum von Millisekunden bis zu einigen Sekunden, bis der Vorgang abgeschlossen ist.
Stattdessen darf Code nur einen E/A-Vorgang zusammen mit einem Callback planen, der nach Abschluss des Vorgangs ausgeführt werden soll. Solche Callbacks werden im Rahmen der Ereignisschleife des Browsers ausgeführt. Ich werde hier nicht ins Detail gehen, aber wenn Sie wissen möchten, wie die Ereignisschleife unter der Haube funktioniert, lesen Sie den Artikel Tasks, microtasks, queues and schedules.
Kurz gesagt: Der Browser führt alle Codeabschnitte in einer Art Endlosschleife aus, indem er sie einzeln aus der Warteschlange entnimmt. Wenn ein Ereignis ausgelöst wird, stellt der Browser den entsprechenden Handler in die Warteschlange. In der nächsten Schleifeniteration wird er aus der Warteschlange entfernt und ausgeführt. Dieser Mechanismus ermöglicht es, Nebenläufigkeit zu simulieren und viele parallele Vorgänge auszuführen, während nur ein einzelner Thread verwendet wird.
Wichtig bei diesem Mechanismus ist, dass die Ereignisschleife blockiert wird, während Ihr benutzerdefinierter JavaScript- oder WebAssembly-Code ausgeführt wird. In dieser Zeit kann nicht auf externe Handler, Ereignisse oder E/A reagiert werden. Die E/A-Ergebnisse können nur abgerufen werden, wenn Sie einen Callback registrieren, die Ausführung Ihres Codes beenden und die Steuerung an den Browser zurückgeben, damit er alle ausstehenden Aufgaben weiter verarbeiten kann. Sobald I/O abgeschlossen ist, wird Ihr Handler zu einer dieser Aufgaben und wird ausgeführt.
Wenn Sie die obigen Beispiele in modernem JavaScript neu schreiben und einen Namen aus einer Remote-URL lesen möchten, verwenden Sie die Fetch API und die async-await-Syntax:
async function main() {
let response = await fetch("name.txt");
let name = await response.text();
console.log("Hello, %s!", name);
}
Auch wenn es synchron aussieht, ist jedes await im Grunde nur Syntaxzucker für Callbacks:
function main() {
return fetch("name.txt")
.then(response => response.text())
.then(name => console.log("Hello, %s!", name));
}
In diesem vereinfachten Beispiel, das etwas übersichtlicher ist, wird eine Anfrage gestartet und mit dem ersten Callback werden Antworten abonniert. Sobald der Browser die erste Antwort (nur die HTTP-Header) empfängt, wird dieser Callback asynchron aufgerufen. Der Callback beginnt mit dem Lesen des Texts mit response.text() und abonniert das Ergebnis mit einem weiteren Callback. Sobald fetch alle Inhalte abgerufen hat, wird der letzte Callback aufgerufen, der „Hallo, (Nutzername)!“ in der Konsole ausgibt.
Da diese Schritte asynchron ausgeführt werden, kann die ursprüngliche Funktion die Steuerung an den Browser zurückgeben, sobald die E/A geplant wurde. Die gesamte Benutzeroberfläche bleibt reaktionsfähig und für andere Aufgaben verfügbar, z. B. für das Rendern und Scrollen, während die E/A im Hintergrund ausgeführt wird.
Als letztes Beispiel sind auch einfache APIs wie „sleep“, die eine Anwendung eine bestimmte Anzahl von Sekunden warten lassen, eine Form von E/A-Vorgang:
#include <stdio.h>
#include <unistd.h>
// ...
printf("A\n");
sleep(1);
printf("B\n");
Natürlich könnten Sie es auf sehr einfache Weise übersetzen, wodurch der aktuelle Thread blockiert würde, bis die Zeit abgelaufen ist:
console.log("A");
for (let start = Date.now(); Date.now() - start < 1000;);
console.log("B");
Tatsächlich ist das genau das, was Emscripten in seiner Standardimplementierung von „sleep“ tut. Das ist jedoch sehr ineffizient, blockiert die gesamte Benutzeroberfläche und verhindert, dass in der Zwischenzeit andere Ereignisse verarbeitet werden können. Im Allgemeinen sollten Sie das nicht im Produktionscode tun.
Stattdessen würde eine idiomatischere Version von „sleep“ in JavaScript den Aufruf von setTimeout() und das Abonnieren mit einem Handler umfassen:
console.log("A");
setTimeout(() => {
console.log("B");
}, 1000);
Was haben alle diese Beispiele und APIs gemeinsam? In jedem Fall wird im idiomatischen Code in der Systemsprache des Originals eine blockierende API für die E/A verwendet, während in einem entsprechenden Beispiel für das Web stattdessen eine asynchrone API verwendet wird. Wenn Sie für das Web kompilieren, müssen Sie irgendwie zwischen diesen beiden Ausführungsmodellen wechseln. WebAssembly bietet dafür noch keine integrierte Möglichkeit.
Lücke mit Asyncify schließen
Hier kommt Asyncify ins Spiel. „Asyncify“ ist ein von Emscripten unterstütztes Feature zur Kompilierzeit, mit dem das gesamte Programm angehalten und später asynchron fortgesetzt werden kann.
Verwendung in C / C++ mit Emscripten
Wenn Sie Asyncify verwenden möchten, um für das letzte Beispiel einen asynchronen Sleep zu implementieren, können Sie das so tun:
#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 ist ein Makro, mit dem JavaScript-Snippets so definiert werden können, als wären sie C-Funktionen. Verwenden Sie darin die Funktion Asyncify.handleSleep(), die Emscripten anweist, das Programm anzuhalten, und einen wakeUp()-Handler bereitstellt, der aufgerufen werden soll, sobald der asynchrone Vorgang abgeschlossen ist. Im obigen Beispiel wird der Handler an setTimeout() übergeben. Er könnte aber auch in jedem anderen Kontext verwendet werden, der Callbacks akzeptiert. Schließlich können Sie async_sleep() überall aufrufen, genau wie reguläre sleep() oder andere synchrone APIs.
Beim Kompilieren von solchem Code müssen Sie Emscripten anweisen, das Asyncify-Feature zu aktivieren. Dazu übergeben Sie -s ASYNCIFY sowie -s ASYNCIFY_IMPORTS=[func1,
func2] mit einer arrayähnlichen Liste von Funktionen, die möglicherweise asynchron sind.
emcc -O2 \
-s ASYNCIFY \
-s ASYNCIFY_IMPORTS=[async_sleep] \
...
So weiß Emscripten, dass für alle Aufrufe dieser Funktionen möglicherweise der Zustand gespeichert und wiederhergestellt werden muss. Der Compiler fügt daher entsprechenden Code um solche Aufrufe ein.
Wenn Sie diesen Code jetzt im Browser ausführen, wird ein nahtloses Ausgabeprotokoll angezeigt, wie Sie es erwarten würden. B wird nach einer kurzen Verzögerung nach A angezeigt.
A
B
Sie können auch Werte aus Asyncify-Funktionen zurückgeben. Sie müssen das Ergebnis von handleSleep() zurückgeben und an den wakeUp()-Callback übergeben. Wenn Sie beispielsweise nicht aus einer Datei lesen, sondern eine Zahl von einer Remote-Ressource abrufen möchten, können Sie einen Snippet wie den unten stehenden verwenden, um eine Anfrage zu senden, den C-Code aussetzen und fortzusetzen, sobald der Antworttext abgerufen wurde. Das alles geschieht nahtlos, als wäre der Aufruf synchron.
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);
Bei Promise-basierten APIs wie fetch() können Sie Asyncify sogar mit der async-await-Funktion von JavaScript kombinieren, anstatt die Callback-basierte API zu verwenden. Rufen Sie dazu anstelle von Asyncify.handleSleep() die Funktion Asyncify.handleAsync() auf. Anstatt einen wakeUp()-Callback zu planen, können Sie dann eine async-JavaScript-Funktion übergeben und await und return darin verwenden. Der Code sieht dadurch noch natürlicher und synchroner aus, ohne dass die Vorteile der asynchronen E/A verloren gehen.
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();
Warten auf komplexe Werte
In diesem Beispiel sind Sie aber immer noch auf Zahlen beschränkt. Was ist, wenn Sie das ursprüngliche Beispiel implementieren möchten, in dem ich versucht habe, den Namen eines Nutzers aus einer Datei als String abzurufen? Das ist auch möglich.
Emscripten bietet eine Funktion namens Embind, mit der Sie Konvertierungen zwischen JavaScript- und C++-Werten verarbeiten können. Asyncify wird ebenfalls unterstützt. Sie können also await() für externe Promises aufrufen. Das Verhalten ist dann dasselbe wie bei await in asynchronem JavaScript-Code:
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>();
Wenn Sie diese Methode verwenden, müssen Sie ASYNCIFY_IMPORTS nicht einmal als Kompilierungsflag übergeben, da es standardmäßig enthalten ist.
Das funktioniert alles hervorragend in Emscripten. Was ist mit anderen Toolchains und Sprachen?
Nutzung in anderen Sprachen
Angenommen, Sie haben einen ähnlichen synchronen Aufruf irgendwo in Ihrem Rust-Code, den Sie einer asynchronen API im Web zuordnen möchten. Das geht auch!
Zuerst müssen Sie eine solche Funktion als regulären Import über den extern-Block (oder die Syntax Ihrer gewählten Sprache für externe Funktionen) definieren.
extern {
fn get_answer() -> i32;
}
println!("Getting answer...");
let answer = get_answer();
println!("Answer is {}", answer);
Kompilieren Sie Ihren Code zu WebAssembly:
cargo build --target wasm32-unknown-unknown
Jetzt müssen Sie die WebAssembly-Datei mit Code zum Speichern/Wiederherstellen des Stacks instrumentieren. Für C/C++ würde Emscripten das für uns erledigen, aber es wird hier nicht verwendet, daher ist der Prozess etwas manueller.
Glücklicherweise ist die Asyncify-Transformation selbst völlig toolchain-unabhängig. Es kann beliebige WebAssembly-Dateien transformieren, unabhängig davon, mit welchem Compiler sie erstellt wurden. Die Transformation wird separat als Teil des wasm-opt-Optimierers aus der binären Toolchain bereitgestellt und kann so aufgerufen werden:
wasm-opt -O2 --asyncify \
--pass-arg=asyncify-imports@env.get_answer \
[...]
Übergeben Sie --asyncify, um die Transformation zu aktivieren, und verwenden Sie dann --pass-arg=…, um eine durch Kommas getrennte Liste asynchroner Funktionen anzugeben, bei denen der Programmstatus angehalten und später fortgesetzt werden soll.
Jetzt müssen Sie nur noch unterstützenden Laufzeitcode bereitstellen, der das tatsächlich erledigt: WebAssembly-Code anhalten und fortsetzen. Im C / C++-Fall würde dies wieder von Emscripten übernommen, aber jetzt benötigen Sie benutzerdefinierten JavaScript-Glue-Code, der beliebige WebAssembly-Dateien verarbeiten kann. Dafür haben wir eine Bibliothek erstellt.
Sie finden es auf GitHub unter https://github.com/GoogleChromeLabs/asyncify oder auf npm unter dem Namen asyncify-wasm.
Sie simuliert eine standardmäßige WebAssembly-Instanziierungs-API, jedoch unter einem eigenen Namespace. Der einzige Unterschied besteht darin, dass Sie bei einer regulären WebAssembly API nur synchrone Funktionen als Importe bereitstellen können, während Sie beim Asyncify-Wrapper auch asynchrone Importe bereitstellen können:
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();
Wenn Sie versuchen, eine solche asynchrone Funktion – wie get_answer() im obigen Beispiel – von der WebAssembly-Seite aus aufzurufen, erkennt die Bibliothek das zurückgegebene Promise, setzt die WebAssembly-Anwendung aus und speichert ihren Status, abonniert den Abschluss des Promise und stellt später, sobald es aufgelöst wird, den Aufrufstack und den Status nahtlos wieder her und setzt die Ausführung fort, als wäre nichts passiert.
Da jede Funktion im Modul einen asynchronen Aufruf ausführen kann, werden alle Exporte potenziell auch asynchron und daher ebenfalls umschlossen. Im obigen Beispiel ist zu sehen, dass Sie das Ergebnis von instance.exports.main() await müssen, um zu wissen, wann die Ausführung wirklich abgeschlossen ist.
Wie funktioniert das alles im Hintergrund?
Wenn Asyncify einen Aufruf einer der ASYNCIFY_IMPORTS-Funktionen erkennt, wird ein asynchroner Vorgang gestartet, der gesamte Status der Anwendung wird gespeichert, einschließlich des Aufrufstacks und aller temporären lokalen Variablen. Wenn der Vorgang abgeschlossen ist, werden der gesamte Speicher und der Aufrufstack wiederhergestellt und die Ausführung wird an derselben Stelle und mit demselben Status fortgesetzt, als ob das Programm nie angehalten worden wäre.
Das ist der Async-Await-Funktion in JavaScript, die ich zuvor gezeigt habe, sehr ähnlich. Im Gegensatz zu JavaScript sind jedoch keine spezielle Syntax oder Laufzeitunterstützung durch die Sprache erforderlich. Stattdessen werden einfache synchrone Funktionen zur Kompilierzeit transformiert.
Beim Kompilieren des oben gezeigten asynchronen Beispiels für den Ruhemodus:
puts("A");
async_sleep(1);
puts("B");
Asyncify nimmt diesen Code und transformiert ihn in etwa in den folgenden (Pseudocode, die tatsächliche Transformation ist komplexer):
if (mode == NORMAL_EXECUTION) {
puts("A");
async_sleep(1);
saveLocals();
mode = UNWINDING;
return;
}
if (mode == REWINDING) {
restoreLocals();
mode = NORMAL_EXECUTION;
}
puts("B");
Anfangs ist mode auf NORMAL_EXECUTION festgelegt. Wenn der transformierte Code zum ersten Mal ausgeführt wird, wird nur der Teil bis async_sleep() ausgewertet. Sobald der asynchrone Vorgang geplant ist, speichert Asyncify alle lokalen Variablen und entwindet den Stack, indem es von jeder Funktion bis ganz nach oben zurückkehrt. So wird die Steuerung wieder an die Browser-Ereignisschleife zurückgegeben.
Sobald async_sleep() aufgelöst wird, ändert der Asyncify-Supportcode mode in REWINDING und ruft die Funktion noch einmal auf. Dieses Mal wird der Zweig „normale Ausführung“ übersprungen, da er beim letzten Mal bereits ausgeführt wurde und ich vermeiden möchte, dass „A“ zweimal ausgegeben wird. Stattdessen wird direkt zum Zweig „Zurückspulen“ gesprungen. Sobald der Haltepunkt erreicht ist, werden alle gespeicherten lokalen Variablen wiederhergestellt, der Modus wird wieder auf „normal“ geändert und die Ausführung wird fortgesetzt, als wäre der Code nie angehalten worden.
Transformationskosten
Leider ist die Asyncify-Transformation nicht völlig kostenlos, da sie eine Menge unterstützenden Code zum Speichern und Wiederherstellen all dieser lokalen Variablen, zum Navigieren im Aufrufstack in verschiedenen Modi usw. einfügen muss. Es wird versucht, nur Funktionen zu ändern, die in der Befehlszeile als asynchron gekennzeichnet sind, sowie alle ihre potenziellen Aufrufer. Der Overhead der Codegröße kann jedoch vor der Komprimierung immer noch bis zu etwa 50% betragen.

Das ist nicht ideal, aber in vielen Fällen akzeptabel, wenn die Alternative darin besteht, die Funktionalität überhaupt nicht zu haben oder den ursprünglichen Code erheblich umschreiben zu müssen.
Achten Sie darauf, dass Sie immer Optimierungen für die endgültigen Builds aktivieren, damit der Wert nicht noch weiter steigt. Sie können auch die Asyncify-spezifischen Optimierungsoptionen verwenden, um den Overhead zu reduzieren, indem Sie Transformationen nur auf bestimmte Funktionen und/oder nur auf direkte Funktionsaufrufe beschränken. Die Laufzeitleistung wird dadurch nur geringfügig beeinträchtigt, und zwar nur bei den asynchronen Aufrufen selbst. Im Vergleich zu den Kosten der eigentlichen Arbeit ist sie jedoch in der Regel vernachlässigbar.
Demos aus der Praxis
Nachdem Sie sich die einfachen Beispiele angesehen haben, gehe ich nun zu komplizierteren Szenarien über.
Wie bereits am Anfang des Artikels erwähnt, ist eine der Speicheroptionen im Web die asynchrone File System Access API. Sie bietet Zugriff auf ein echtes Host-Dateisystem über eine Webanwendung.
Andererseits gibt es einen De-facto-Standard namens WASI für WebAssembly-I/O in der Konsole und auf der Serverseite. Es wurde als Kompilierungsziel für Systemsprachen entwickelt und bietet alle Arten von Dateisystem- und anderen Vorgängen in einer herkömmlichen synchronen Form.
Was wäre, wenn Sie sie einander zuordnen könnten? Sie könnten dann jede Anwendung in jeder Ausgangssprache mit jeder Toolchain kompilieren, die das WASI-Ziel unterstützt, und sie in einer Sandbox im Web ausführen, während sie weiterhin auf echte Nutzerdateien zugreifen kann. Mit Asyncify ist das möglich.
In dieser Demo habe ich den Rust-coreutils-Crate mit einigen kleineren Patches für WASI kompiliert, die über die Asyncify-Transformation übergeben wurden, und asynchrone Bindings von WASI zur File System Access API auf der JavaScript-Seite implementiert. In Kombination mit der Xterm.js-Terminalkomponente wird so eine realistische Shell bereitgestellt, die im Browser-Tab ausgeführt wird und auf echte Nutzerdateien zugreift – genau wie ein echtes Terminal.
Sie können sich die Live-Demo unter https://wasi.rreverser.com/ ansehen.
Asyncify-Anwendungsfälle sind auch nicht nur auf Zeitgeber und Dateisysteme beschränkt. Sie können noch einen Schritt weiter gehen und APIs für bestimmte Nischen im Web verwenden.
Beispielsweise ist es auch mithilfe von Asyncify möglich, libusb – die wahrscheinlich beliebteste native Bibliothek für die Arbeit mit USB-Geräten – einer WebUSB API zuzuordnen, die asynchronen Zugriff auf solche Geräte im Web ermöglicht. Nach der Zuordnung und Kompilierung konnte ich Standard-libusb-Tests und -Beispiele für die ausgewählten Geräte direkt in der Sandbox einer Webseite ausführen.

Das ist aber wahrscheinlich eine Geschichte für einen anderen Blogpost.
Diese Beispiele zeigen, wie leistungsstark Asyncify sein kann, um die Lücke zu schließen und alle Arten von Anwendungen ins Web zu portieren. So erhalten Sie plattformübergreifenden Zugriff, Sandboxing und bessere Sicherheit, ohne dass Funktionen verloren gehen.