Mario Reder

Lesezeit: 13 Minuten

Techblog

WebAssembly: Noch ein neues JavaScript Framework?

WebAssembly wird in Browsern nativ supported. Sprachen wir RUST oder AssemblyScript bieten WASM als Kompilierziel an.

Techblog

Nein, zum Glück nicht.

WebAssembly ist eine neue Programmiersprache, die – neben JavaScript – in Browsern nativ supported wird. Das bedeutet, man braucht keine Plugins, um es zu nutzen. Seit Februar 2017 hat WebAssembly (kurz WASM) den MVP-Status erreicht, seit im März 2018 Safari nachgezogen hat, wird es in den vier großen Browsern unterstützt. Ich werde in diesem Blog-Post insbesondere die Möglichkeiten durchleuchten, mit denen man schon jetzt WASM nutzen kann.

Das Ziel von WASM ist nicht etwa JavaScript zu ersetzen, sondern es soll nebenher laufen und rechenintensive Aufgaben übernehmen.

Das Besondere an WASM ist, dass man normalerweise nicht direkt damit programmiert, sondern dass andere Sprachen WebAssembly als Kompilierziel anbieten. WASM selbst liegt in einem binärem Format vor und wird durch den Browser in Maschinensprache übersetzt. Im Gegensatz zu JavaScript ist das Parsing also deutlich schneller und auch die zu übermittelnden Dateien sind deutlich kleiner. In den meisten Fällen wird sogar das Parsen der Datei schneller sein als das Empfangen. Gekoppelt mit der Möglichkeit schon während dem Streaming zu Parsen, entfällt dieser Teil komplett im Critical Rendering Path.

Die Idee, andere Programmiersprachen in Browsern auszuführen, ist schon deutlich älter. Bereits 2013 konnte man mit Hilfe von asm.js andere Sprachen in JavaScript-Code kompilieren. Asm.js ist ein striktes Subset von JavaScript, welches durch Ahead-of-time-Optimierung deutliche Performancevorteile bringt. Allerdings können die erzeugten JavaScript-Bundle extrem groß werden und zu Zeiten von Single-Page-Applications wird das Problem der initialen Ladezeit auf Webseiten immer deutlicher. Man könnte also sagen, dass WASM der Nachfolger von asm.js ist und für Browser, die WASM noch nicht unterstützen, kann man asm.js auch als Fallback verwenden.

Anwendungen mit WebAssembly schreiben

Da WASM selbst low level ist, können auch nur entsprechende Programmiersprachen dazu kompiliert werden, etwa C, C++, Rust und AssemblyScript. JavaScript zu WASM zu kompilieren, macht dagegen keinen Sinn. Eine Liste inklusive Fortschritt gibt es hier.

Wenn man einfach mal schnell etwas bauen will, lohnt es sich WebAssembly Studio anzuschauen. Das ist eine Online-IDE speziell entwickelt für WebAssembly. Es bietet Templates und “Hello World”-Beispiele für verschiedene Sprachen. Diese können logischerweise auch direkt im Browser ausgeführt werden.

Ansonsten kommt es darauf an, mit welcher Sprache man etwas in WASM entwickeln will. Hierzu habe ich mir zwei Sprachen genauer angeschaut, diese sind AssemblyScript und Rust.

AssemblyScript

AssemblyScript nutzt die gleiche Syntax wie TypeScript, also JavaScript mit strikter Typisierung. Im Grunde genommen soll die Sprache kompatibel mit TypeScript sein – mit ein paar Besonderheiten: Die Größte ist, dass jede Variable einen Typ haben muss. Somit gibt es kein any oder undefined und, sofern es dem Compiler nicht möglich ist, den Typ zu suggerieren (aka Type Inference), muss dieser angegeben werden. Außerdem muss man für primitive Typen auch solche nutzen, die es auch in WebAssembly gibt. Entwickelt wird die Sprache von der Open-Source-Community.

Ich verfolge die Entwicklung der Sprache schon etwas länger. Damals gab es noch eine weitere TypeScript-ähnliche Sprache, die genau das Gleiche tat. Diese wird inzwischen nicht mehr weiterentwickelt; es sieht so aus als würde sich AssemblyScript durchsetzen. Man hört auch immer häufiger, dass diverse Libraries auch einen AssemblyScript-Port bekommen sollen. Im Grunde genommen beherrscht man als JavaScript-Entwickler die Sprache schon fast. Außerdem gibt es bekanntermaßen viele JavaScript-Entwickler und die Sprache ist einfach zu erlernen.

Da es bei WASM insbesondere darauf ankommt, wie man es zusammen mit JavaScript verwenden kann, ist es besonders spannend sich genau das anzuschauen. Hierzu verwende ich das Template, welches von WebAssembly Studio bereitgestellt wird. So sieht die AssemblyScript-Datei aus:

declare function sayHello(): void;
 
sayHello();
 
export function add(x: i32, y: i32): i32 {
  return x + y;
}

sayHello ist eine importierte JavaScript-Funktion und add wird als WASM-Funktion exportiert. In JavaScript sieht der relevante Teil der Nutzung dieser Funktion so aus:

const { add } = (await WebAssembly.instantiateStreaming(/* load WASM file */)).instance.exports;
const result = add(19, 23);
assert(result).isEqual(42);

Wenn man primitive Typen verwendet ist das Ganze also ziemlich einfach. Meistens wird man aber komplexere Anwendungen bauen und da stellt sich die Frage, wie weit man eigentlich gehen kann. Kann man zum Beispiel ein Klassenobjekt in AssemblyScript erzeugen und dieses in JavaScript übergeben? Und wenn ja, was passiert dann?

Probieren wir das einfach mal aus:

// AssemblyScript
declare function log(msg: string): void
 
export class Cow {
  public makeSound(): void {
    log('moo');
  }
}
 
// JavaScript
const { Cow } = (await WebAssembly.instantiateStreaming(fetch("../out/main.wasm"), {
  main: {
    log(msg) {
      console.log(msg);
    }
  }, // ...
})).instance.exports;
const cow = new Cow();
console.log(cow);
 

Wenn wir diesen Code ausführen, bekommen wir folgenden Fehler angezeigt in der Konsole: Cow is not a constructor. Offensichtlich kann man den Constructor also nicht von JavaScript aus aufrufen. Stattdessen kann man den Constructor in AssemblyScript aufrufen und das Ergebnis in JavaScript zurückgeben:

// AssemblyScript
declare function log(msg: string): void
 
export function createCow(): Cow {
  return new Cow();
}
 
class Cow {
  public makeSound(): void {
    log('moo');
  }
}
 
// JavaScript
const { createCow } = (await WebAssembly.instantiateStreaming(/* ... */)).instance.exports;
const cow = createCow();
console.log(cow);
 

Diesmal hat es funktioniert und wir sehen in der Konsole, wie in meinem Fall eine 3000 geloggt wird. Es handelt sich also um einen Pointer zu dem Objekt. Das hat leider den Nachteil, dass wir makeSound jetzt nicht mehr einfach von JavaScript aus aufrufen können. Stattdessen muss man diesen Pointer einer weiteren AssemblyScript-Funktion übergeben, welche diese Arbeit für uns erledigt. Wir sagen unserem AssemblyScript Code also, dass es sich bei der Pointer-Stelle um eine Cow handelt:

// AssemblyScript
export function makeSound(cow: Cow): void {
  cow.makeSound();
}
// ...
 
// JavaScript
const { createCow, makeSound } = (await WebAssembly.instantiateStreaming(/* ... */)).instance.exports;
const cow = createCow();
makeSound(cow);
 

Das sollte funktionieren, oder?

….

Leider ist dem nicht so. Wenn wir das Programm nun ausführen, bekommen wir folgende Ausgabe: 56. Was ist hier passiert? Dass es sich bei dem Pointer um eine Cow handelt wurde offenbar erkannt, denn der console.log-Aufruf wurde ausgeführt. Allerdings hat AssemblyScript hier den String im Static Memory abgelegt, wie hier beschrieben. Obwohl string im Sinne von JavaScript ein primitiver Typ ist, ist er das aus Memory-Sicht keineswegs und so bekommen wir wieder einen Pointer, welcher zu dem String im Memory zeigt.

Da wir wissen, wie ein String von AssemblyScript im WebAssembly Memory kodiert wird, brauchen wir lediglich Zugriff auf dieses Memory und können es anschließend dekodieren. Da es sich bei dem WASM Memory lediglich um ein JavaScript Objekt handelt, können wir das ohne Probleme tun, allerdings müssen wir das Dekodieren des Strings selbst implementieren.

Wer sich das komplette Beispiel anschauen will, der kann diesem Link folgen.

Rust

Rust ist eine von Mozilla seit 2013 entwickelte “Systemprogrammiersprache, die blitzschnell läuft, Speicherfehler vermeidet und Threadsicherheit garantiert”, so die ersten Sätze auf deren Website und das beschreibt die Sprache auch ziemlich gut. Wenn man schon mehrere Programmiersprachen gelernt hat, dann fühlt es sich so an, als hätte sich Rust aus jeder Sprache das Beste rausgesucht. Darüber hinaus gibt es dort komplett neue Konzepte wie die Ownership einer Variable und das daraus resultierende Konzept der Lifetime. Rust benötigt aufgrund dieser Konzepte keine Garbage Collection was WASM zu Gute kommt, da es bisher noch keinen eingebauten Garbage Collector gibt.

In der Stackoverflow Developer Survey 2018 kam außerdem heraus, dass Rust-Entwickler zum dritten Mal in Folge die zufriedensten Entwickler sind. Wenn das mal kein Grund ist, sich die Sprache anzuschauen!

Rust hat WebAssembly schon früh als Kompilierziel angeboten, zumindest so früh, dass man mit der aktuellen Nightly-Version dies tun kann. Es gibt aber auch schon länger Emscripten, ein LLVM-zu-JavaScript-Compiler, welcher Rust ebenfalls zu WASM kompilieren kann.

Es gibt verschiedene vielversprechende Projekte rund um Rust und WASM. Diese sollen den Entwicklungsprozess und die Schnittstelle zwischen JavaScript und Rust vereinfachen. Hierbei kann man zur Zeit zwischen zwei Richtungen wählen.

Zum einen gibt es stdweb, mit dessen Hilfe man mittels Rust Makros in-line JavaScript Code schreiben kann. Den umgekehrten Weg, also Rust-Code in JavaScript ausführen, macht man mit Hilfe von Annotationen an der jeweiligen Rust-Funktion, wodurch eine JavaScript-Datei erzeugt wird. Man kann auch mittels Serialisierung beliebige Rust-Objekte nach JavaScript übergeben. Zur Codegenerierung gibt es ein CLI-Tool cargo-web, welches den Rust- und JavaScript-Code zu den benötigten WASM-Dateien und JavaScript Bindings kompilieren kann.

Als Alternative zu stdweb bietet sich wasm-bindgen an. Die Richtung von Rust zu JavaScript löst es sehr ähnlich: indem man seine Structs und Funktionen annotiert. Hierbei fällt auf, dass die von stdweb benötigte Serialisierung nicht immer benötigt wird. Manche Objekte können auch ohne Serialisierungsannotation zu JavaScript übergeben werden, das wird von wasm-bindgen übernommen. Für die Rust-Exports werden sogar TypeScript Typings erstellt und mittels einfachem ES6-Import können die Rust-Dateien als WASM-Modul geladen werden. JavaScript kann ebenfalls in Rust importiert werden durch einen extern-Block. Das hat zwar den Nachteil, dass die importierten JavaScript-Prototypen und Funktionen doppelte Bezeichner haben, aber es bietet eine klarere Trennung von JavaScript- und Rust-Code. Damit man nicht zu viel Code nur zum Importieren von Standard-JavaScript-APIs schreiben muss, gibt es zwei Crates, um dies abzudecken. Für Web APIs gibt es web-sys und für globale APIs nach ECMAScript Standard gibt es js-sys.

Ich habe mich ganz klar für Letzteres entschieden, da die Mischung von Rust- und JavaScript-Code in einer Datei, wie es bei stdweb der Fall ist, zu gewissen Einschränkungen führt. Zum Beispiel kann man den JavaScript-Code nicht mehr so einfach Linten und man hat keine Möglichkeit, Tools wie Webpack zu verwenden. Für mich ein ganz klares K.O.-Kriterium. Außerdem verfolgt wasm-bindgen eine “pay-only-what-you-use”-Philosophie, auch bekannt als Tree-Shaking oder Dead Code Elimination. Dadurch können WASM-Dateien sehr klein gehalten werden. Leider verwendet WebAssembly Studio eine ältere Version von wasm-bindgen, weshalb man hier zwar auch ein Beispiel bringen könnte, dieses aber nicht den aktuellen Stand darstellen würde. Als wichtigen Punkt zu nennen ist, dass im Vergleich zu AssemblyScript sehr viel Arbeit der JavaScript-zu-WASM-Bindings wegfällt. Ein konkretes Beispiel wäre hier die String-Rekonstruktion, welche wir bei AssemblyScript selbst implementieren mussten.

Ein Wort zu Shared Memory

Für den effizienten Datenaustausch bei Multithreading nutzt man in der Regel Shared Memory, d.h. mehrere Threads können auf den gleichen Speicher zugreifen. Bei JavaScript hört man selten etwas darüber, wie Multithreading eigentlich geht… häufig sind es nur die Stichwörter “Web Worker” und “postMessage”. Dies ist ein anderes Konzept des Datenaustausches, welches Nachteile mit sich bringt. Durch postMessage sendet man ein JavaScript-Objekt zu einem anderen Thread, d.h. man übergibt es. Somit kann man es danach zwar immer noch bearbeiten, das macht aber keinen Sinn, weil der empfangende Thread soll die komplette Arbeit übernehmen und anschließend das Endergebnis zurücksenden. Man kann also nicht innerhalb mehrerer Threads sinnvoll an dem gleichen Objekt arbeiten.

Dabei gibt es einen sogenannten SharedArrayBuffer, welcher das ersehnte Shared Memory bereit stellt. Leider wurde Anfang 2018 ein Prozessor-Bug entdeckt, wodurch es möglich ist, Werte im RAM anderer Prozesse auszulesen. Dies ist auch mit Hilfe von SharedArrayBuffern möglich, also könnte ein Angreifer etwa durch den Besuch einer Website den RAM auslesen.

Der Bug ist bekannt als “Spectre” und hatte für Browser zur Folge, dass SharedArrayBuffer zunächst ausgeschaltet wurden. Man kommt dem Punkt immer näher, ab dem es wieder sicher sein wird, diese nutzen zu können, wie man in diesem Artikel unter Threading nachlesen kann. Ab dann wird man dem Threads Proposal für WebAssembly ein ganzes Stück näher kommen.

Das Memory, welches von WebAssembly selbst verwendet wird, kann allerdings von JavaScript ebenso verwendet werden, wie man in dem AssemblyScript-Beispiel sieht. Der Zugriff hierauf erfolgt direkt. Es handelt sich also um eine Memoryinstanz, welche zwischen JavaScript und WASM geshared ist, man kann also von dem JavaScript-Thread, welcher den WASM-Thread erzeugt hat, und dem WASM-Thread selbst darauf zugreifen. Der einzige Nachteil hierbei ist die Interoperabilität der Daten, da diese nicht von beiden Sprachen verstanden werden können. Dieses Problem ist generell schwierig zu lösen und wird wie oben beschrieben zur Zeit in Rust von wasm-bindgen gelöst, aber mehrere Proposals sind auf dem Weg, um den Datenaustausch deutlich zu beschleunigen.


Über den Autor

Mario Reder

Web Engineering

Mario arbeitet seit 2017 bei MaibornWolff. Beim Kunden baut er hauptsächlich Web Apps, er lies sich aber schon früh für WebAssembly begeistern und verfolgt dessen Entwicklung schon sehr lange. Zudem wurde dadurch Rust zu seiner bevorzugten Programmiersprache. Im privaten Leben ist er Vater von zwei Söhnen, die seine volle Aufmerksamkeit verlangen.