- Erläutert detailliert anhand eines praktischen Rubik’s-Cube-Solver-Beispiels den Prozess, C/C++-Code mit Emscripten nach WebAssembly zu portieren und daraus eine im Browser laufende Web-App zu erstellen
- Behandelt konkrete Hürden und Lösungswege in Browser-/WebAssembly-Umgebungen Schritt für Schritt, von Hello World über Multithreading, Callbacks, persistenten Speicher bis hin zur Modularisierung
- Fokussiert sich auf praxisnahes Troubleshooting zu Themen wie asynchroner JavaScript-Initialisierung, Export von Funktionen, Web Workern und Spectre-Problemen sowie persistenter Speicherung über IndexedDB via IDBFS
- Betont wiederholt, dass die Abstraktionen von Emscripten in der Praxis oft „leaky abstractions“ sind, und unterstreicht die Notwendigkeit, die Grenzen und innere Struktur der Webplattform zu verstehen
- Ein erfahrungsbasierter Leitfaden, der Entwicklern, die bestehende C/C++-Bibliotheken ins Web portieren möchten, durch praktische Erfahrungen beim Überführen komplexer C-Codebasen ins Web mit nur minimalen JavaScript-/HTML-Kenntnissen konkrete Hilfe und Know-how bietet
Einführung
- Kürzlich wurde ein Projekt umgesetzt, das den Rubik’s-Cube-Optimal-Lösungsalgorithmus als Web-App implementiert
- Dokumentiert wird der Prozess, einen in C entwickelten Rubik’s-Cube-Optimierungssolver mit Emscripten zu kompilieren und als WebAssembly im Webbrowser auszuführen
- Der Hauptgrund für den Einsatz von WebAssembly ist, im Web eine nahezu native Performance im Vergleich zu JavaScript zu erreichen
- Dieser Artikel ist kein klassisches Webentwicklungs-Tutorial, sondern ein „Leidensweg“ für Entwickler, die bestehenden C/C++-Code ins Web portieren möchten
- Auch ohne viel Erfahrung in der Webentwicklung ist er nachvollziehbar, solange man die Grundstruktur von HTML und JavaScript sowie den Umgang mit Browser-Entwicklertools kennt
Umgebung einrichten
- Sämtlicher Beispielcode ist im git-Repository und auf github verfügbar
- Emscripten muss installiert werden (Installationsanleitung siehe offizielle Website); als Webserver können etwa darkhttpd oder Python
http.server verwendet werden
- Die Tutorial-Codebeispiele wurden unter Linux und UNIX-artigen Systemen getestet. Windows-Nutzern wird WSL (Windows Subsystem for Linux) empfohlen
Hello World
- Wenn man Hello World in C mit dem Befehl
emcc -o index.html hello.c kompiliert, entstehen drei Dateien: index.html (Webseite), index.wasm (WebAssembly-Bytecode) und index.js (JavaScript glue code)
- Die Ausgabe kann sowohl im Browser als auch unter Node.js laufen; für beide Umgebungen gibt es unterschiedliche Einsatzweisen
- Um nur
.wasm zu erzeugen, wird die Option -sSTANDALONE_WASM verwendet
- Zwar kann Emscripten auch nur
.wasm erzeugen, in den meisten Fällen ist jedoch JavaScript glue code unverzichtbar
Intermezzo I: Was ist WebAssembly?
- WebAssembly (WASM) ist eine Low-Level-Sprache, die in einer hochperformanten virtuellen Maschine innerhalb des Webbrowsers ausgeführt wird
- WASM wird seit 2017 von allen wichtigen Browsern unterstützt
- Ursprünglich wandelte Emscripten C/C++-Code in asm.js, eine Teilmenge von JavaScript, um; mit dem Aufkommen von WASM erfolgte der Wechsel
- Es gibt auch eine Textdarstellung, und die Struktur ist stackbasiert. Bis vor Kurzem wurde nur eine 32-Bit-Architektur unterstützt, sodass nicht mehr als 4 GB Speicher nutzbar waren; WASM64 wird inzwischen schrittweise in Browsern eingeführt
Bibliothek bauen
- Es wird ein einfaches Grundbeispiel gezeigt, bei dem die C-Funktion
multiply() zu WASM gebaut und anschließend aus JavaScript aufgerufen wird
- Beim Standard-Build versieht Emscripten Funktionsnamen mit einem Unterstrich (_), z. B.
_multiply
- Für die externe Freigabe von Funktionen muss die Option
-sEXPORTED_FUNCTIONS gesetzt werden
- Da die Initialisierung beim Laden der Bibliothek asynchron erfolgt, ist eine asynchrone Behandlung über
onRuntimeInitialized oder await erforderlich
- Der Übungscode befindet sich im Ordner
01_library des Repositories
Intermezzo II: JavaScript und DOM
- Um in JavaScript auf Bestandteile von HTML zuzugreifen und sie zu verändern, muss das Document Object Model (DOM) genutzt werden
- Mit Event Listenern (
addEventListener) und eingebauten Operatoren/Funktionen lassen sich dynamische UIs umsetzen
- Für die Beispiele wird eine grundlegende HTML-/JavaScript-Struktur mit Eingabe, Button und Ergebnisanzeige erläutert
- Es werden auch praktische Vorgehensweisen und Probleme beim Trennen/Zusammenführen von Skripten erklärt, etwa die Nutzung von
defer und die Lade-Reihenfolge von DOM-Elementen
Bibliothek modularisieren und laden
- Damit sich WASM-Bibliotheken mehrfach einbinden oder sowohl unter Node.js als auch im Web wiederverwenden lassen, können sie mit den Optionen MODULARIZE und EXPORT_NAME als Modul gebaut werden
- Die Erweiterung
.mjs wird aus Gründen der Node.js-Kompatibilität empfohlen
- So lässt sich das Modul sowohl im Web als auch unter Node mit
import MyLibrary from ... verwenden
Multithreading
- In WebAssembly lässt sich pthread-basiertes Multithreading portieren, um die Performance zu steigern
- Innerhalb einer Funktion werden mehrere Threads erzeugt, um Rechenaufgaben parallel auszuführen (z. B. das Zählen von Primzahlen)
- Beim Build werden die Optionen
-pthread und -sPTHREAD_POOL_SIZE= benötigt
- In realen Browsern müssen zusätzlich HTTP-Header wie
Cross-Origin-Opener-Policy: same-origin und Cross-Origin-Embedder-Policy: require-corp gesetzt werden
- Alle Beispiele finden sich im Ordner
03_threads des Repositories
Intermezzo III: Web Workers und Spectre
- Emscripten-Multithreading wird mit Web Workers umgesetzt (Web Workers sind separate Prozesse mit nachrichtenbasierter Kommunikation)
- Die Nutzung von SharedArrayBuffer unterliegt sicherheitsbedingten Einschränkungen
- Nach dem Auftreten der Spectre-Schwachstelle im Jahr 2018 wurden Anforderungen an Cross-Origin Isolation sowie die entsprechenden Header verpflichtend
Vorsicht vor dem Blockieren des Main Threads
- Wenn lange laufende Aufgaben den Main-UI-Thread des Browsers BLOCKIEREN, verschlechtert sich die Benutzererfahrung drastisch
- Um das zu vermeiden, werden Web Worker eingeführt: Verarbeitung von UI/Eingaben und Rechenlogik werden klar getrennt
- Die ereignisbasierte Kommunikation zwischen Main Thread und Worker wird mit
postMessage und onmessage umgesetzt
- Im Web Worker wird das Emscripten-WASM-Modul geladen und übernimmt ausschließlich asynchrone Berechnungen
Callback-Funktionen
- Wird ein Funktionszeiger (Callback) als Parameter an eine C-Funktion übergeben, ist keine automatische Anbindung an JavaScript-Funktionsobjekte möglich
- Dafür müssen von Emscripten bereitgestellte Funktionen wie
addFunction() und UTF8ToString() genutzt werden; beim Build sind zusätzlich -sEXPORTED_RUNTIME_METHODS und -sALLOW_TABLE_GROWTH erforderlich
- Callbacks funktionieren nur stabil, wenn sie ausschließlich im Main Thread aufgerufen werden (aus Web Workern heraus ist kein Zugriff möglich)
Persistenter Speicher
- Um Daten dauerhaft im Browser des Nutzers zu speichern, wird IDBFS von Emscripten verwendet, ein auf IndexedDB basierendes Dateisystem
- Beim Build sind Flags wie
--lidbfs.js und --pre-js für die anfängliche Einrichtung nötig
- Im C-Code können Dateiein-/ausgabefunktionen wie
fopen, fread und fwrite unverändert genutzt werden; damit Daten jedoch tatsächlich übernommen und synchronisiert werden, sind in JavaScript explizites Mapping und Sync-Verarbeitung erforderlich
- Aufgrund der Sandbox- und Sicherheitsrichtlinien des Browsers ist direkter Zugriff auf das lokale Dateisystem nur in Node.js möglich; im Browser müssen für sichere persistente Speicherung Backends wie IDBFS verwendet werden
Fazit
- Über den gesamten Ablauf dieses Tutorials hinweg lässt sich im Detail lernen, wie komplexer nativer C/C++-Code mit nur minimalem JavaScript und HTML sicher und ohne Performanceverlust im Browser ausgeführt werden kann
- In einer realen Umgebung erlebt man die Hürden und Lösungen aller zentralen Themenbereiche – Multithreading, Callbacks, asynchrone Verarbeitung und Speicheranbindung – und lernt zugleich aktuelle Trends wie passende Konfigurationen und Browser-Einschränkungen kennen
- Die bereitgestellten Git-Repository-Beispiele können als Grundlage für die eigene Anwendung genutzt und erweitert werden
1 Kommentare
Hacker-News-Kommentare
.jszu.mjsgeändert wurde; das trifft genau das frustrierende Gefühl, dass man in der Realität unabhängig von der verwendeten Endung auf Probleme stößt. Aus der Perspektive von jemandem, der von dojo über CommonJS, AMD, ESM, webpack, esbuild und rollup schon viele Modulsysteme genutzt hat, ist das wirklich 100% nachvollziehbar.versionseinfach die in den letzten 30 Tagen meistgeladene Version auswählt, weil das mit hoher Wahrscheinlichkeit die letzte CommonJS-Version ist. ESM ist zwar eindeutig ein fortschrittlicheres Modulsystem, aber dass tc39 es fast absichtlich inkompatibel zu CommonJS gemacht hat, etwa mit Top-Levelawait, ist ehrlich gesagt kaum nachvollziehbar.Function-Objekt zur Laufzeit beliebigen JS-Code kompilieren kann, und in meiner Umgebung, in der ich nicht einmalimportverwenden kann, ist das zu einer Art Lebensader geworden und sehr nützlich. Im JS-Ökosystem braucht man das vielleicht nicht besonders oft, aber mir hilft es enorm..esm.jsverwenden?varlieberletoderconstzu verwenden.varfunktioniert zwar weiterhin, aber die meisten heutigen JS-Entwickler verbieten die Nutzung vonvarper Linter.varunterstützt nur Function Scope, was für Entwickler aus den meisten anderen Sprachen früher oder später verwirrend ist. Als Beispiel für Portierungsprobleme nativer Apps wurde genannt, dass Copy-and-Paste zur Compile-Zeit mit hartkodiertenCtrl-C- undCtrl-V-Tastenkombinationen umgesetzt wurde, was unter Linux und Windows funktioniert, auf dem Mac aber nicht. Im Web sollte man stattdessencopy- undpaste-Events erkennen. Selbst in Frameworks wie Unity hat man gesehen, dass wegen fest verdrahteter Tasten auf dem Mac Kopieren und Einfügen nicht funktioniert. Für die meisten Spiele ist das egal, aber sobald man Web-Funktionen mit Copy-and-Paste-Bedarf ausliefert, wird es zum Problem.mutexoderrwlock, mit denen sich Werte selbst zwischen Kontexten wie z. B. v8 isolates übertragen ließen, letztlich nur der praktisch kaum nützlicheSharedArrayBuffereingeführt wurde. Die Thread-Synchronisation läuft am Ende doch auf Thunking und Datenkopien über eine RPC-Schicht hinaus. Unsere Produktions-App im Unternehmen ist eine riesige Anwendung, die 70 bis 100 GB RAM nutzt, was schon so war, bevor ich sie gebaut habe. Daher suchen wir nach einer eigenartigen Lösung auf Basis nativen Codes, bei der Speicherseiten und benutzerdefinierte Datenstrukturen direkt verwaltet werden, um Serialisierung und Deserialisierung zu minimieren. V8 verwendet für String-Encoding UTF-16, wodurch die Arbeit mit JS-Werten in der nativen Schicht teuer wird.sqlite-API-Aufrufe aus C mit einer SQLite-Datenbank im Browser verbinden könnte, wäre das ideal und definitiv eine nähere Untersuchung wert.