Die I/O-APIs im Web sind asynchron, aber in den meisten Systemsprachen synchron. Wenn Sie Code in WebAssembly kompilieren, müssen Sie eine Brücke zwischen einer API und einer anderen API schlagen. Diese Brücke ist Asyncify. In diesem Beitrag erfährst du, wann und wie du Asyncify verwenden kannst und wie es funktioniert.
E/A 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 der Nachricht „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 leistungsfähig, zeigt aber bereits etwas, das Sie in einer Anwendung jeder Größe finden: Es liest einige Eingaben von außen, verarbeitet sie intern und schreibt die Ausgabe zurück an die Außenwelt. Alle diese Interaktionen mit der Außenwelt erfolgen über einige Funktionen, die allgemein als Eingabe-/Ausgabefunktionen bezeichnet werden, auch abgekürzt als E/A.
Um den Namen aus C zu lesen, sind mindestens zwei wichtige E/A-Aufrufe erforderlich: fopen
, um die Datei zu öffnen, und fread
, um Daten daraus zu lesen. 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 zweimal über die Abläufe zum Lesen oder Schreiben von Daten nachdenken. Je nach Umgebung kann es jedoch ziemlich viel los sein:
- Wenn sich die Eingabedatei auf einem lokalen Laufwerk befindet, muss die Anwendung eine Reihe von Speicher- und Laufwerkzugriffen ausführen, um die Datei zu finden, die 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 des Laufwerks und der angeforderten Größe kann dies ziemlich langsam sein.
- Möglicherweise befindet sich die Eingabedatei auch an einem bereitgestellten Netzwerkspeicherort. In diesem Fall ist auch der Netzwerkstack beteiligt, was die Komplexität, die Latenz und die Anzahl der möglichen Wiederholungen für jeden Vorgang erhöht.
- Schließlich ist auch nicht garantiert, dass
printf
die Ausgabe in die Konsole umleitet. Es kann auch sein, dass die Ausgabe an eine Datei oder einen Netzwerkspeicherort umgeleitet wird. In diesem Fall müssen Sie die oben genannten Schritte ausführen.
Kurz gesagt: I/O-Vorgänge können langsam sein und Sie können nicht anhand eines kurzen Blicks auf den Code vorhersagen, wie lange ein bestimmter Aufruf dauern wird. Während dieser Vorgang ausgeführt wird, erscheint die gesamte Anwendung eingefroren und reagiert nicht auf Nutzereingaben.
Das ist auch nicht auf C oder C++ beschränkt. Die meisten Systemsprachen stellen alle E/A-Vorgänge in Form von synchronen APIs dar. Wenn Sie das Beispiel beispielsweise in Rust übersetzen, sieht die API möglicherweise einfacher aus, aber dieselben Prinzipien gelten. Sie führen einfach einen Aufruf aus und warten synchron auf das Ergebnis, während alle ressourcenintensiven Vorgänge ausgeführt werden. Das Ergebnis wird dann in einem einzigen Aufruf zurückgegeben:
fn main() {
let s = std::fs::read_to_string("name.txt");
println!("Hello, {}!", s);
}
Aber was passiert, wenn Sie versuchen, eines dieser Samples in WebAssembly zu kompilieren und ins Web zu übertragen? Oder, um ein konkretes Beispiel zu nennen: Was könnte der Befehl „Datei lesen“ bedeuten? Es müssten Daten aus einem Speicher gelesen werden.
Asynchrones Webmodell
Im Web gibt es eine Vielzahl verschiedener Speicheroptionen, die Sie zuordnen können, z. B. In-Memory-Speicher (JS-Objekte), localStorage
, IndexedDB, serverseitiger Speicher und die neue File System Access API.
Allerdings können nur zwei dieser APIs – der In-Memory-Speicher und die localStorage
– synchron verwendet werden. Beide sind die Optionen mit den stärksten Einschränkungen in Bezug auf das, was und wie lange Sie speichern können. Bei allen anderen Optionen werden nur asynchrone APIs bereitgestellt.
Dies ist eine der Haupteigenschaften der Codeausführung im Web: Alle zeitaufwendigen Vorgänge, einschließlich E/A-Vorgängen, müssen asynchron sein.
Der Grund dafür ist, dass das Web traditionell ein einzelner Thread ist und jeder Nutzercode, der die Benutzeroberfläche betrifft, im selben Thread wie die Benutzeroberfläche ausgeführt werden muss. Sie muss mit anderen wichtigen Aufgaben wie Layout, Rendering und Ereignisbehandlung um die CPU-Zeit konkurrieren. Es ist nicht wünschenswert, dass ein JavaScript- oder WebAssembly-Code einen Dateilesevorgang starten und alles andere – den gesamten Tab oder in der Vergangenheit den gesamten Browser – für einen Zeitraum von Millisekunden bis zu einigen Sekunden blockieren kann, bis der Vorgang abgeschlossen ist.
Stattdessen ist es nur zulässig, einen E/A-Vorgang zusammen mit einem Rückruf zu planen, der nach Abschluss ausgeführt wird. Solche Rückrufe werden im Rahmen des Ereignis-Loops des Browsers ausgeführt. Ich werde hier nicht weiter auf Details eingehen. Wenn Sie jedoch wissen möchten, wie der Ereignis-Loop im Detail funktioniert, lesen Sie den Artikel Tasks, Microtasks, Queues und Scheduler. Dort wird dieses Thema ausführlich erläutert.
Kurz gesagt: Der Browser führt alle Codeteile in einer Art Endlosschleife aus, indem er sie nacheinander aus der Warteschlange nimmt. Wenn ein Ereignis ausgelöst wird, stellt der Browser den entsprechenden Handler in die Warteschlange. Bei der nächsten Iteration der Schleife wird er aus der Warteschlange genommen und ausgeführt. Mit diesem Mechanismus können Sie die Parallelität simulieren und viele parallele Vorgänge ausführen, während nur ein einzelner Thread verwendet wird.
Wichtig ist, dass der Ereignis-Loop blockiert ist, während Ihr benutzerdefinierter JavaScript- (oder WebAssembly-)Code ausgeführt wird. In diesem Fall kann nicht auf externe Handler, Ereignisse, E/A usw. reagiert werden. Die einzigen Möglichkeiten, die E/A-Ergebnisse zurückzugeben, sind die Registrierung eines Rückrufs, das Beenden der Codeausführung und die Rückgabe der Steuerung an den Browser, damit er alle ausstehenden Aufgaben weiter verarbeiten kann. Sobald die E/A-Vorgänge abgeschlossen sind, wird Ihr Handler zu einer dieser Aufgaben und ausgeführt.
Wenn Sie beispielsweise 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 jede await
im Grunde eine Syntaxvereinfachung für Callbacks:
function main() {
return fetch("name.txt")
.then(response => response.text())
.then(name => console.log("Hello, %s!", name));
}
In diesem Beispiel ohne Verschleierung, das etwas verständlicher ist, wird eine Anfrage gestartet und Antworten werden mit dem ersten Rückruf abonniert. Sobald der Browser die erste Antwort – nur die HTTP-Header – empfängt, ruft er diesen Rückruf asynchron auf. Der Callback beginnt mit dem Lesen des Textkörpers mit response.text()
und abonniert das Ergebnis mit einem anderen Callback. Sobald fetch
alle Inhalte abgerufen hat, ruft er den letzten Rückruf auf, der „Hallo, (Nutzername)!“ in die Konsole druckt.
Da diese Schritte asynchron sind, kann die ursprüngliche Funktion die Steuerung an den Browser zurückgeben, sobald die E/A-Vorgänge geplant wurden. Die gesamte Benutzeroberfläche bleibt dabei reaktionsschnell und für andere Aufgaben verfügbar, z. B. für das Rendern und Scrollen, während die E/A-Vorgänge im Hintergrund ausgeführt werden.
Als letztes Beispiel: Auch einfache APIs wie „sleep“, mit der eine Anwendung eine bestimmte Anzahl von Sekunden wartet, sind eine Form von I/O-Vorgang:
#include <stdio.h>
#include <unistd.h>
// ...
printf("A\n");
sleep(1);
printf("B\n");
Natürlich können Sie das ganz einfach so übersetzen, dass der aktuelle Thread bis zum Ablauf der Zeit blockiert wird:
console.log("A");
for (let start = Date.now(); Date.now() - start < 1000;);
console.log("B");
Genau das tut Emscripten in der Standardimplementierung von „sleep“. Das ist jedoch sehr ineffizient, blockiert die gesamte Benutzeroberfläche und verhindert, dass andere Ereignisse in der Zwischenzeit verarbeitet werden. Das ist im Produktionscode in der Regel nicht empfehlenswert.
Stattdessen würde eine idiomatischere Version von „sleep“ in JavaScript setTimeout()
aufrufen und mit einem Handler abonnieren:
console.log("A");
setTimeout(() => {
console.log("B");
}, 1000);
Was haben alle diese Beispiele und APIs gemeinsam? In jedem Fall verwendet der idiomatische Code in der ursprünglichen Systemsprache eine blockierende API für die E/A, während in einem entsprechenden Beispiel für das Web stattdessen eine asynchrone API verwendet wird. Beim Kompilieren für das Web müssen Sie zwischen diesen beiden Ausführungsmodellen irgendwie transformieren. WebAssembly bietet derzeit keine integrierte Möglichkeit dazu.
Mit Asyncify die Lücke schließen
Hier kommt Asyncify ins Spiel. Asyncify ist eine von Emscripten unterstützte Compilezeitfunktion, mit der 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 eine asynchrone Pause zu implementieren, könnten Sie so vorgehen:
#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()
, um Emscripten anzuweisen, das Programm anzuhalten, und einen wakeUp()
-Handler anzugeben, der aufgerufen werden soll, sobald der asynchrone Vorgang abgeschlossen ist. Im obigen Beispiel wird der Handler an setTimeout()
übergeben, er kann aber in jedem anderen Kontext verwendet werden, der Callbacks akzeptiert. Schließlich können Sie async_sleep()
wie normale sleep()
oder jede andere synchrone API an einer beliebigen Stelle aufrufen.
Beim Kompilieren solchen Codes müssen Sie Emscripten anweisen, die Asyncify-Funktion zu aktivieren. Dazu übergeben Sie -s ASYNCIFY
und -s ASYNCIFY_IMPORTS=[func1,
func2]
eine arrayartige Liste von Funktionen, die asynchron sein können.
emcc -O2 \
-s ASYNCIFY \
-s ASYNCIFY_IMPORTS=[async_sleep] \
...
Dadurch weiß Emscripten, dass für Aufrufe dieser Funktionen möglicherweise der Zustand gespeichert und wiederhergestellt werden muss. Der Compiler fügt daher unterstützenden Code um solche Aufrufe ein.
Wenn Sie diesen Code jetzt im Browser ausführen, sehen Sie ein nahtloses Ausgabeprotokoll, wie Sie es erwarten würden, wobei B nach einer kurzen Verzögerung nach A kommt.
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 eine Zahl nicht aus einer Datei lesen, sondern aus einer Remote-Ressource abrufen möchten, können Sie mit einem Snippet wie dem unten stehenden eine Anfrage senden, den C-Code anhalten und fortsetzen, sobald der Antworttext abgerufen wurde. Das funktioniert 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()
Asyncify.handleAsync()
auf. Anstatt einen wakeUp()
-Callback planen zu müssen, können Sie dann eine async
-JavaScript-Funktion übergeben und await
und return
darin verwenden. So wirkt der Code noch natürlicher und synchroner, 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();
Complex values are still pending.
In diesem Beispiel sind aber immer noch nur Zahlen zulässig. 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 die Funktion Embind, mit der Sie Konvertierungen zwischen JavaScript- und C++-Werten verarbeiten können. Außerdem wird Asyncify unterstützt. Du kannst await()
also für externe Promise
s aufrufen. Es funktioniert dann genauso wie await
in async-await-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>();
Bei dieser Methode müssen Sie ASYNCIFY_IMPORTS
nicht einmal als Kompilierungsflag übergeben, da es standardmäßig bereits enthalten ist.
Okay, das funktioniert also in Emscripten gut. Was ist mit anderen Toolchains und Sprachen?
Verwendung aus anderen Sprachen
Angenommen, Sie haben irgendwo in Ihrem Rust-Code einen ähnlichen synchronen Aufruf, den Sie einer asynchronen API im Web zuordnen möchten. Das geht aber auch!
Zuerst müssen Sie eine solche Funktion als regulären Import über einen extern
-Block (oder die Syntax der von Ihnen ausgewählten Sprache für externe Funktionen) definieren.
extern {
fn get_answer() -> i32;
}
println!("Getting answer...");
let answer = get_answer();
println!("Answer is {}", answer);
Und kompilieren Sie den Code in WebAssembly:
cargo build --target wasm32-unknown-unknown
Jetzt müssen Sie die WebAssembly-Datei mit Code zum Speichern und Wiederherstellen des Stacks instrumentieren. Bei C/C++ würde Emscripten dies für uns erledigen, wird hier aber 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 umwandeln, unabhängig davon, mit welchem Compiler sie erstellt wurden. Die Transformation wird separat als Teil des wasm-opt
-Optimierers aus der Binaryen-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 von asynchronen Funktionen anzugeben, bei denen der Programmstatus angehalten und später fortgesetzt werden soll.
Jetzt müssen Sie nur noch den unterstützenden Runtime-Code bereitstellen, der dies tatsächlich tut – WebAssembly-Code anhalten und fortsetzen. Im Fall von C / C++ würde dies wieder von Emscripten übernommen, aber jetzt benötigen Sie benutzerdefinierten JavaScript-Bindungscode, der beliebige WebAssembly-Dateien verarbeitet. 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-Instanzierungs-API, aber in einem eigenen Namespace. Der einzige Unterschied besteht darin, dass Sie unter einer regulären WebAssembly API nur synchrone Funktionen als Importe angeben können, während Sie unter dem Asyncify-Wrapper auch asynchrone Importe angeben 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 Beispiel oben von der WebAssembly-Seite aus aufzurufen, erkennt die Bibliothek den zurückgegebenen Promise
, hält die Ausführung an und speichert den Status der WebAssembly-Anwendung. Sie abonniert die Fertigstellung des Promise und stellt später, sobald es abgeschlossen ist, nahtlos den Aufrufstapel und den Status wieder her und fährt mit der Ausführung fort, als wäre nichts passiert.
Da jede Funktion im Modul einen asynchronen Aufruf ausführen kann, werden auch alle Exporte potenziell asynchron, sodass sie ebenfalls umschlossen werden. Im obigen Beispiel haben Sie vielleicht bemerkt, 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?
Wenn Asyncify einen Aufruf einer der ASYNCIFY_IMPORTS
-Funktionen erkennt, startet es einen asynchronen Vorgang, speichert den gesamten Zustand der Anwendung, einschließlich des Aufrufstapels und aller temporären lokalen Variablen, und stellt später, wenn dieser Vorgang abgeschlossen ist, den gesamten Arbeitsspeicher und den Aufrufstapel wieder her und fährt an derselben Stelle und mit demselben Status fort, als wäre das Programm nie angehalten worden.
Das ist der async-await-Funktion in JavaScript sehr ähnlich, die ich bereits gezeigt habe. Im Gegensatz zu dieser Funktion erfordert sie jedoch keine spezielle Syntax oder Laufzeitunterstützung der Sprache. Stattdessen werden einfache synchrone Funktionen zur Kompilierungszeit umgewandelt.
Beim Kompilieren des zuvor gezeigten Beispiels für den asynchronen Schlaf:
puts("A");
async_sleep(1);
puts("B");
Asyncify wandelt diesen Code in etwa in den folgenden Code um (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");
Ursprünglich ist mode
auf NORMAL_EXECUTION
festgelegt. Wenn dieser transformierte Code zum ersten Mal ausgeführt wird, wird nur der Teil bis zu async_sleep()
ausgewertet. Sobald der asynchrone Vorgang geplant ist, speichert Asyncify alle lokalen Variablen und entwirft den Stack, indem er von jeder Funktion bis ganz nach oben zurückkehrt. So wird die Kontrolle wieder an den Browser-Ereignis-Loop übergeben.
Sobald async_sleep()
aufgelöst wurde, ändert der Asyncify-Supportcode mode
in REWINDING
und ruft die Funktion noch einmal auf. Diesmal wird der Zweig „normale Ausführung“ übersprungen, da die Aufgabe bereits beim letzten Mal ausgeführt wurde und ich vermeiden möchte, „A“ zweimal zu drucken. Stattdessen wird direkt der Zweig „Zurückspulen“ aufgerufen. Sobald dies der Fall ist, werden alle gespeicherten lokalen Variablen wiederhergestellt, der Modus wird wieder in „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 ziemlich viel unterstützender Code eingefügt werden muss, um alle lokalen Variablen zu speichern und wiederherzustellen, den Aufrufstapel in verschiedenen Modi zu durchlaufen usw. Es werden nur Funktionen geändert, die an der Befehlszeile als asynchron gekennzeichnet sind, sowie alle potenziellen Aufrufer. Der Overhead der Codegröße kann vor der Komprimierung jedoch immer noch etwa 50% betragen.
Das ist nicht ideal, aber in vielen Fällen akzeptabel, wenn die Alternative darin besteht, die Funktion nicht zu haben oder den ursprünglichen Code erheblich umschreiben zu müssen.
Achten Sie darauf, für die finalen Builds immer Optimierungen zu aktivieren, damit der Wert nicht noch höher wird. Sie können auch die Asyncify-spezifischen Optimierungsoptionen aktivieren, um den Overhead zu reduzieren, indem Sie Transformationen auf bestimmte Funktionen und/oder direkte Funktionsaufrufe beschränken. Außerdem ist die Laufzeitleistung etwas geringer, was sich aber nur auf die asynchronen Aufrufe selbst auswirkt. Im Vergleich zu den Kosten der eigentlichen Arbeit sind sie jedoch in der Regel vernachlässigbar.
Praxisnahe Demos
Nachdem Sie sich die einfachen Beispiele angesehen haben, möchte ich nun zu komplexeren Szenarien übergehen.
Wie bereits zu Beginn des Artikels erwähnt, ist eine der Speicheroptionen im Web eine asynchrone File System Access API. Es bietet Zugriff auf ein echtes Hostdateisystem über eine Webanwendung.
Andererseits gibt es einen De-facto-Standard namens WASI für WebAssembly-E/A in der Konsole und auf der Serverseite. Es wurde als Kompilierungsziel für Systemsprachen entwickelt und stellt alle Arten von Dateisystem- und anderen Vorgängen in traditioneller synchroner Form bereit.
Was wäre, wenn Sie sie aufeinander abbilden könnten? Dann können Sie jede Anwendung in jeder Quellsprache mit jeder Toolchain kompilieren, die das WASI-Ziel unterstützt, und sie in einer Sandbox im Web ausführen, während sie weiterhin auf echten Nutzerdateien ausgeführt werden kann. Mit Asyncify ist genau das möglich.
In dieser Demo habe ich den Rust-Chrom coreutils mit einigen kleineren Patches für WASI kompiliert, über die Asyncify-Transformation übergeben und asynchrone Bindungen von WASI zur File System Access API auf der JavaScript-Seite implementiert. In Kombination mit der Terminalkomponente Xterm.js wird eine realistische Shell bereitgestellt, die im Browsertab ausgeführt wird und mit echten Nutzerdateien arbeitet – genau wie ein echtes Terminal.
Sie können sich das Live unter https://wasi.rreverser.com/ ansehen.
Anwendungsfälle für die Async-Funktion sind nicht nur auf Timer und Dateisysteme beschränkt. Sie können auch speziellere APIs im Web verwenden.
Mit Asyncify ist es beispielsweise möglich, libusb – die wahrscheinlich beliebteste native Bibliothek für die Arbeit mit USB-Geräten – auf eine WebUSB API abzubilden, die asynchronen Zugriff auf solche Geräte im Web ermöglicht. Nach dem Zuordnen und Kompilieren konnte ich Standard-Libusb-Tests und ‑Beispiele direkt in der Sandbox einer Webseite auf ausgewählten Geräten 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 eine bessere Sicherheit, ohne dass Funktionen verloren gehen.