zurück zum Artikel

WebAssembly – Webanwendungen auf der Überholspur

Michael Gerhäuser
WebAssembly [--] Webanwendungen auf der Überholspur

JavaScript dominiert die Entwicklung von Software im Browser. Es gibt aber immer wieder Herausforderer – aktuell beispielsweise WebAssembly.

WebAssembly, kurz Wasm, soll helfen, Webanwendungen in puncto Geschwindigkeit näher an klassische Desktopanwendungen heranzubringen. Genau genommen ist WebAssembly eine Spezifikation, die eine Instruction Set Architecture definiert und ein Dateiformat spezifiziert. Die Instruction Set Architecture beschreibt einen (virtuellen) Computer. Das Dateiformat legt den Aufbau von WebAssembly-Modulen fest. Alle großen Browserhersteller haben die WebAssembly-Spezifikation im Frühjahr 2017 in ihren Browsern implementiert. Nur der Internet Explorer bleibt außen vor.

Der Browser lädt den zu WebAssembly kompilierten Code in Form von Modulen und führt ihn aus. Vorstellen kann man sich das wie ein Java-Applet, das vorab nach Java-Bytecode kompiliert wurde und später beim Abruf eine Java Virtual Machine (VM) ausführt. Java-Applets allerdings sind eine Erweiterung des Browsers, nicht Teil des Web. WebAssembly hingegen ist als Standard im Web-Ökosystem verankert. Es gehört nicht einem einzigen Unternehmen, das allein über die Weiterentwicklung bestimmt, sondern einer Gruppe bestehend aus Browser-Herstellern und Einzelpersonen.

Ein weiterer, wichtiger Unterschied ist die Möglichkeit, Wasm-Module nach JavaScript zu übersetzen. Im Binaryen-Toolkit gibt es dafür das Programm wasm2asm [1]. Das ist für Browser gedacht, die keine WebAssembly-Unterstützung besitzen. Nach JavaScript konvertiert funktioniert das Wasm-Modul ohne WebAssembly-Support, allerdings etwas langsamer als in Browsern mit WebAssembly-Unterstützung.

Damit Entwickler WebAssembly-Module erzeugen können, benötigen sie einen Compiler, der das Wasm-Format versteht und Code nach WebAssembly kompilieren kann. Davon gibt es mittlerweile einige [2]. Hervorzuheben sind C und C++ über Clang und Emscripten, Rust sowie das experimentelle C#-Framework Blazor.

Neben einem generellen Compiler-Support gibt es weitere Eigenschaften einer Toolchain, die beim Erstellen und Benutzen von Wasm-Modulen von Vorteil sind. WebAssembly unterstützt von Haus aus die Kommunikation mit der Host-Umgebung. Das heißt, man kann von einem Wasm-Modul aus JavaScript-Funktionen aufrufen und von JavaScript aus Wasm-Funktionen benutzen. Das Interface ist jedoch rudimentär. Anwender können nur solche Funktionen ex- und importieren, deren Rückgabewerte und Parameter entweder Integer oder IEEE-754-Floating-Point-Typen sind. Alles andere erfordert ein explizites Hinein- beziehungsweise Herauskopieren von JavaScript aus in den Wasm-Speicher. Sowohl Emscripten als auch Rust bieten Bibliotheken und Werkzeuge an, die eine Übergabe in beide Richtungen für viele und insbesondere komplexere Datenstrukturen übernehmen und damit die Kommunikation mit Wasm-Modulen erheblich vereinfachen.

Emscripten bietet hierfür zwei Methoden an: Web IDL [3] und die Emscripten-Bibliothek Embind. Web IDL ist ein standardisiertes Format, das Web-APIs für alle Browser einheitlich spezifiziert. Web IDL definiert die JS/Wasm-Schnittstelle und übersetzt sie mit einem Skript in C++ und JavaScript. Die Embind-Bibliothek legt die Schnittstelle direkt im C++-Code mit Makros fest und implementiert sie.

Für Rust gibt es ebenfalls mehrere Bibliotheken (bei Rust "crates" genannt). Zwei bekannte Werkzeuge sind stdweb [4] und wasm-bindgen [5]. stdweb ist eine Bibliothek, die ausgewählte JavaScript- und Web-APIs in Rust verfügbar macht und eine unkomplizierte Möglichkeit schafft, Rust-Funktionen nach JavaScript zu exportieren. wasm-bindgen bietet das Gleiche an, generiert Web-APIs allerdings automatisch aus Web-IDL-Definitionen. Makros importieren und exportieren eigene Funktionen.

Ein weiterer Vorteil von Binding-Bibliotheken wie stdweb und wasm-bindgen: Sie können JavaScript-Module produzieren, die sich um das Laden eines Wasm-Moduls kümmern. Entwickler müssen also nicht direkt mit der WebAssembly-API [6] arbeiten, sondern binden das Wasm-Modul wie ein ganz normales JavaScript-Modul ein. Dass dahinter eigentlich ein Wasm-Modul steckt, wissen die Nutzer nicht (s. Abb. 1).

Ein guter Wasm-Compiler generiert ein Wrapper-Modul in JavaScript, das sich um sämtliche Datenkonversionen kümmert. Der Benutzer bekommt im Idealfall nicht mit, dass er WebAssembly verwendet. (Abb. 1)

Ein guter Wasm-Compiler generiert ein Wrapper-Modul in JavaScript, das sich um sämtliche Datenkonversionen kümmert. Der Benutzer bekommt im Idealfall nicht mit, dass er WebAssembly verwendet. (Abb. 1)

wasm-bindgen kann unter anderem verschiedene Wrapper-Skripte erzeugen, je nachdem in welcher Umgebung man das Wasm-Modul einsetzt. Ein Beispiel für das Einbinden eines Wasm-Moduls in JavaScript-Code ist in Listing 2 zu sehen. Dem Code sieht man das dahinterstehende WebAssembly-Modul nicht an. Lediglich der import-Befehl deutet darauf hin. Das nächste Listing zeigt zunächst ein WebAssembly-Modul namens alert_wasm, geschrieben in Rust. wasm-bindgen kümmert sich um Details wie Datenkonversion.

#![feature(use_extern_macros)]

extern crate wasm_bindgen;
use wasm_bindgen::prelude::*;

// Wir importieren die JavaScript-alert()-Funktion.
#[wasm_bindgen]
extern {
fn alert(s: &str);
}

// Diese Funktion wird exportiert und
// kann von JavaScript aus benutzt werden
#[wasm_bindgen]
pub fn greet(who: &str) {
alert(&format!("Hello {}!", who));
}

Der folgende Code importiert das Wasm-Modul von oben und ruft seine greet-Funktion auf. Das Beispiel benötigt webpack.

const js = import("./pkg/alert_wasm");

js.then(js => {
js.greet("from wasm");
});

Der import-Befehl ist noch relativ jung. In der Funktionsschreibweise wie in zweiten Codebeispiel unterstützen Browser den Befehl noch nicht. Das macht die Verwendung eines Bundlers wie webpack nötig. Weitere Umgebungen, die wasm-bindgen unterstützt, sind Node.js und no-module. Letztere ist für Webseiten gedacht, die keinen Bundler verwenden können oder wollen, sowie für die Verwendung von Wasm-Modulen in Web-Workern. Die Verwendung von Web-Workern empfiehlt sich für Wasm-Funktionen, deren Ausführungszeit mehrere hundert Millisekunden überschreitet und damit nicht mehr in den Bereich von Interaktivität fällt. WebAssembly-Code wird im gleichen Thread ausgeführt wie JavaScript-Code und blockiert damit bei längerer Laufzeit auch das User Interface – der Benutzer kann keine Buttons mehr klicken und die Webseite erscheint eingefroren.

Der Browser führt WebAssembly-Module innerhalb der JavaScript-VM aus. Damit sind sie zunächst normalem JavaScript-Code gleichgestellt und haben die gleichen Zugriffsrechte. Zumindest im Vergleich zu JavaScript verändert sich im Hinblick auf Sicherheit nichts: Wasm-Module können nicht beliebig auf Hardware oder Dateisysteme zugreifen, es sei denn, der Nutzer gibt das im Browserinterface explizit frei.

Besonders kritisch sind Speicherzugriffe. Viele Sprachen, die nach Wasm kompiliert werden können, erlauben Zugriffe auf beliebige Speicheradressen. Wenn man weiß, wo man hinschauen muss, könnte man sicherheitsrelevante Daten wie Passwörter oder Authentifizierungs-Tokens auslesen. Das lässt sich verhindern, indem man WebAssembly-Modulen ein separates Stück Speicher zuweist. Das heißt, die Module können nur auf eine dedizierte Untermenge des Heap der JavaScript-VM zugreifen. Der spezielle Heap ist durch ein JavaScript-ArrayBuffer realisiert. Damit unterliegt der gesamte Speicher eines Wasm-Moduls den gleichen Beschränkungen wie normale JavaScript-Objekte. Dementsprechend trackt die Garbage Collection das Modul und gibt es frei. Memory Leaks sind damit in genau den gleichen Grenzen möglich wie bei normalen JavaScript-Programmen. Über die ArrayBuffer hat JavaScript-Code Zugriff auf den gesamten Speicher des WebAssembly-Moduls.

Da der komplette Heap eines WebAssembly-Moduls in einem ArrayBuffer steckt, weiß die JavaScript-Umgebung, wie groß der Heap des Moduls ist und wo genau er im Heap liegt. Damit kann die JavaScript-Umgebung bei jedem Speicherzugriff des WebAssembly-Codes zum einen exakt nachprüfen, ob sich der Zugriff innerhalb des ArrayBuffers bewegt, und zum anderen unerlaubte Zugriffe unterbinden [7]. Das macht die Ausführung geringfügig langsamer, aber verhindert unerlaubte Speicherzugriffe in die JavaScript-VM oder gar in den Adressraum des Browser-Prozesses.

Ebenfalls kritisch sind Zugriffe auf den Execution Stack. Neben dem Aufrufkontext liegt insbesondere die Rücksprungadresse an die aufrufende Funktion auf dem Stack. Gelingt es, die Rücksprungadresse zu manipulieren, kann ein Angreifer sie nutzen, um beliebigen Code auszuführen. Wasm unterbindet einen Schreibzugriff auf den Execution Stack komplett, da Wasm ihn außerhalb des Speichers des Wasm-Moduls verwaltet.

Ebenfalls kritisch sind üblicherweise Instruktionen, die an eine andere Stelle im Code springen. WebAssembly springt nur bei Funktionsaufrufen Speicheradressen an. Teilweise berechnet Wasm die Speicheradressen erst dynamisch zur Laufzeit. Um einen Missbrauch zu verhindern, kommen Tabellen zum Einsatz: Anstatt eine Adresse direkt in die call-Anweisung zu codieren, arbeiten call-Instruktionen mit zwei Parametern: einem Index in einer Tabelle und einer Funktionssignatur. Die beiden Parameter muss man nicht unbedingt statisch ablegen; sie können auch erst zur Laufzeit dynamisch ermittelt werden, zum Beispiel um Aufrufe an virtuelle Methoden in C++ [8] oder Traits-Implementierungen in Rust zu realisieren [9]. Der Index zeigt in eine Tabelle mit Funktions-Pointer. Sie liegt, genau wie der Execution Stack, außerhalb des Wasm-Moduls. Ein Überschreiben vom Wasm-Modul aus ist nicht möglich.

Wann immer eine solche call-Instruktion auftritt, schlägt die Wasm-Laufzeitumgebung zunächst die Funktion in der Tabelle nach und vergleicht die an die call-Instruktion übergebene Funktionssignatur mit der in der Tabelle hinterlegten. Gibt es unter dem Index keinen Eintrag in der Tabelle oder stimmen die beiden Signaturen nicht überein, stoppt die Ausführung des Wasm-Moduls sofort. Existiert der Eintrag und stimmen die Signaturen überein, ruft die Laufzeitumgebung die in der Tabelle hinterlegte Funktion auf.

Einige der Sicherheitsfeatures gehen auf Kosten von Geschwindigkeit, sind aber notwendig, um Sicherheitslücken in Wasm-Modulen zu verhindern. WebAssembly ist überdies noch relativ jung und das Performancepotenzial sicherlich noch lange nicht ausgeschöpft. Aber schon heute ist Wasm in einigen Szenarien deutlich schneller als JavaScript. Mehrere Untersuchungen haben die Geschwindigkeit von WebAssembly genauer geprüft.

Samsung hat die WebAssembly-Performance untersucht [10]. Dabei haben sie verschiedene Matrix-Matrix-Multiplikationsalgorithmen in JavaScript und C implementiert und miteinander verglichen. Im Ergebnis ist bei diesen Untersuchungen JavaScript bei kleinen Berechnungen schneller und WebAssembly bei rechenintensiven.

PSPDFKit ist eine Bibliothek zum Anzeigen und annotieren von PDFs. Erste Untersuchungen [11]
brachten ernüchternde Ergebnisse. In Zusammenarbeit mit den Browser-Herstellern konnte das Team die Wasm-Implementierung beschleunigen. Wasm ist nun entweder gleichauf mit JavaScript oder schneller. Das Google-V8 Team hat diese Benchmark Ende August in der Ankündigung von Liftoff [12], einem neuen Wasm-Compiler in V8, aufgegriffen und demonstriert enorme Verbesserungen.

Die beiden Projekte Gutenberg-Parser [13] und HeapViz [14] berichten von enormen Geschwindigkeitsvorteilen bei WebAssembly. Der Gutenberg-Parser ist mit wasm im Schnitt 159-mal schneller als die äquivalente JavaScript-Implementierung. HeapViz ist bis zu 30-mal schneller. Wer in seinem eigenen Browser JavaScript gegen WebAssembly antreten lassen möchte, kann dies mit dem Wasm-Raytracer [15]
tun.

Mit Wasm ist es ähnlich wie mit Multithreading: Man sollte nicht erwarten, einfach durch die Verwendung von WebAssembly auf magische Weise massive Performanceverbesserungen zu erhalten. WebAssembly gibt Entwicklern die Möglichkeit, performante Applikationen zu schreiben, macht sie aber nicht automatisch performant.

Umfangreiche Daten erfordern ein Kopieren von JavaScript aus in den Speicher des Wasm-Moduls. Unter Umständen ist es notwendig, Ergebnisse zu konvertieren, bevor man sie in JavaScript verwenden kann. Das verursacht Kosten, die in einer reinen JavaScript-Anwendung nicht auftreten.

Weiter ist jeder Aufruf einer Wasm-Funktion von JavaScript aus und jeder JavaScript-Funktion von Wasm aus mit einem Kontext-Switch verbunden, der ebenfalls Zeit in Anspruch nimmt. Deshalb bietet es sich nicht an, kleine und schnell laufende Funktionen in Wasm-Module auszulagern. Da es der Wasm-Standard derzeit nicht vorsieht, Wasm-Modulen direkten Zugriff auf Web-APIs zu gestatten, müssen Anwender Zugriffe immer über Aufrufe an JavaScript-Funktionen realisieren. Das geht ebenfalls zu Lasten der Performance. Wenn es um Performance geht, sollten Web-APIs demnach im JavaScript-Teil einer Anwendung verbleiben und nur Funktionen in WebAssembly-Module realisiert werden, die keinen oder nur wenig Zugriffe auf das Document Object Model (DOM) und Web-APIs benötigen. Die WebAssembly Working Group, die den Wasm-Standard erarbeitet hat, befasst sich jedoch bereits mit der Erweiterung von WebAssembly, die einen effizienten Zugriff auf DOM und Web-APIs von WebAssembly aus gestattet.

Die Module sind häufig unnötig groß. Das kostet Bandbreite, Zeit beim Download und beim Kompilieren. Oft kompilieren Entwickler einfach viel zu viele Funktionen in Wasm-Module ein, die niemand benötigt. Meist nutzt man existierende (Standard-)Bibliotheken, die nicht auf Wasm optimiert sind, und kompiliert sie mit. Die WebAssembly-Module sind dementsprechend groß. Ein Beispiel hierfür ist C#, bei dem anfangs die ganze .NET Runtime einkompiliert wurde. Es gibt aber bereits erste Werkzeuge, die Abhilfe oder zumindest Milderung schaffen sollen. Aufgrund der statischen Struktur von Wasm-Modulen ist es relativ einfach, nicht benötigten Code aus einem Modul zu entfernen.

Wasm-Module liegen üblicherweise im Binärformat vor. Es ist auf kleine Dateien und eine schnelle Verarbeitung durch den Compiler im Browser optimiert. Der Aufbau eines Wasm-Moduls ist derart gestaltet, dass Browser sie in einem einzigen Durchlauf parsen und nach optimiertem Maschinen-Code übersetzen können. Manche Browser beginnen mit der Übersetzung während des Downloads. JavaScript hingegen gibt es nur im Textformat. Man kann es lediglich verlustfrei komprimieren, muss es dann aber im Browser wieder dekomprimieren. Bis JavaScript-Code ausgeführt wird, muss er zunächst geparst werden. In modernen Engines geschieht das zweistufig: ein Interpreter, der die initiale Ausführung übernimmt, und ein optimierender Compiler, der häufig ausgeführte Code-Pfade in Maschinen-Code übersetzt. JavaScript kann man nicht direkt in Maschinencode übersetzen, weil die Datentypen der Variablen nicht statisch, sondern dynamisch sind und sich zur Laufzeit jederzeit ändern können.

Außerdem gibt es bei WebAssembly derzeit keinen Garbage Collector. Ohne ihn muss man sich selbst um die Speicherverwaltung kümmern. Das ist heutzutage in Sprachen wie Rust [16] mit seinen Lifetime- und Borrow-Checkern allerdings kein großes Problem mehr. Fehlt ein Garbage Collector, wird zudem keine Rechenzeit in Anspruch genommen.

Es sind bereits verschiedene Erweiterungen des Wasm-Standards in Arbeit, die auf Performance-Verbesserungen abzielen [17]. Auf der Liste der WebAssembly Working Group stehen unter anderem Multithreading und SIMD.

Auch wenn der Name eine strenge Bindung an das Web suggeriert, ist WebAssembly nicht nur für Webanwendungen interessant. Tatsächlich ist die Spezifikation unabhängig vom Web und sieht das Web nur als eine mögliche Host-Umgebung für WebAssembly-Module vor. In der Tat wird auch in anderen Bereichen, die nichts mit dem Web direkt zu tun haben, mit WebAssembly experimentiert und gearbeitet [18]. Nebulet zum Beispiel [19] ist ein Betriebssystem, das WebAssembly-Programme ausführt. Mit Parity [20] laufen WebAssembly-Module in der Ethereum-Blockchain. Tiny Emus nutzt [21] WebAssembly, um verschiedene Emulatoren für 8-Bit-Chips auf einer Webseite zu archivieren. Auch wenn heute noch sehr viele Dinge stark in Bewegung sind, lohnt sich ein Blick Richtung WebAssembly durchaus.

Dr. Michael Gerhäuser
ist als Senior Software Engineer bei Method Park tätig. Er unterstützt Kunden aus der Medizintechnik-Branche bei der Entwicklung ihrer Bildverarbeitungssoftware. Darüber hinaus interessiert er sich für die Themengebiete Webentwicklung, JavaScript und Softwareoptimierung.
(bbo [22])


URL dieses Artikels:
https://www.heise.de/-4165049

Links in diesem Artikel:
[1]  https://github.com/WebAssembly/binaryen
[2] https://github.com/appcypher/awesome-wasm-langs
[3] https://www.w3.org/TR/WebIDL-1/
[4] https://github.com/koute/stdweb
[5] https://rustwasm.github.io/wasm-bindgen/
[6] https://developer.mozilla.org/en-US/docs/WebAssembly/Using_the_JavaScript_API
[7] https://hacks.mozilla.org/2017/07/memory-in-webassembly-and-why-its-safer-than-you-think/
[8] http://fitzgeraldnick.com/2018/04/26/how-does-dynamic-dispatch-work-in-wasm.html
[9] https://hacks.mozilla.org/2017/07/webassembly-table-imports-what-are-they/
[10] https://medium.com/samsung-internet-dev/performance-testing-web-assembly-vs-javascript-e07506fd5875
[11] https://pspdfkit.com/blog/2018/a-real-world-webassembly-benchmark/
[12] https://v8project.blogspot.com/2018/08/liftoff.html
[13] https://github.com/Hywan/gutenberg-parser-rs#performance-and-guarantee
[14] https://hackernoon.com/screamin-speed-with-webassembly-b30fac90cd92
[15] https://mtharrison.github.io/wasm-raytracer/
[16] https://www.heise.de/hintergrund/Programmiersprache-Rust-als-sichere-Sprache-fuer-systemnahe-und-parallele-Software-4155591.html
[17]  https://webassembly.org/docs/future-features/
[18] http://words.steveklabnik.com/webassembly-is-more-than-just-the-web
[19] https://github.com/nebulet/nebulet
[20] https://www.parity.io/
[21] https://floooh.github.io/tiny8bit/
[22] mailto:bbo@ix.de