Bytecode im Browser: Mit WebAssembly und Rust zur Web-Anwendung

Am Beispiel eines Chatsystems demonstrieren die Autoren die Programmierung eines browserbasierten Chatclients und -servers mit Rust und Wasm.

In Pocket speichern vorlesen Druckansicht 76 Kommentare lesen

(Bild: Lightspring/Shutterstock.com)

Lesezeit: 11 Min.
Von
  • Dr. Jens Breitbart
  • Dr. Stefan Lankes
Inhaltsverzeichnis

WebAssembly (Wasm) ist ein offener Standard für Bytecode, der das Ausführen von Programmen im Webbrowser ermöglicht. Die gängigen Browser wie Firefox, Chrome, Safari und Edge unterstützen Wasm bereits. Das WebAssembly-Projekt geht aus Mozillas asm.js und dem Portable Native Client (PNaCl) hervor. Beide Projekte schufen die Voraussetzungen, um vorkompilierten C/C++-Code durch den Browser für das Host-System zu kompilieren und anschließend nativ im Browser auszuführen. Im Vergleich zu JavaScript lässt sich dadurch eine höhere Performance erzielen, während gleichzeitig wichtige Security-Funktionen des Browsers wie das Sandboxing aktiv bleiben – und Nutzer von potenziell höherer Sicherheit profitieren.

Mehr zur Rust

Die Idee, vorkompilierten Bytecode auszuliefern, ist nicht neu. Sie kam schon in den Neunzehnneunziger Jahren mit Java zum Einsatz. Vorkompilierter Java-Code stand Nutzern damals als Java-Bytecode zur Verfügung. Beim Programmstart musste dieser interpretiert oder durch einen Just-in-Time-(JIT)-Compiler übersetzt werden und ließ sich anschließend ausführen.

Java- und WebAssembly-Bytecode unterscheiden sich allerdings deutlich. Java-Bytecode bietet eine höhere Abstraktionsebene und enthält unter anderen Typinformationen – verlangt aber Garbage Collection, da Objekte nicht explizit freigegeben werden. WebAssembly hingegen lässt sich eher mit klassischem Assembler vergleichen und enthält keine Informationen über Objekte. Es verwundert daher kaum, dass eine typische Java-VM deutlich komplexer ausfällt als das entsprechende WebAssembly-Gegenstück.

WebAssembly lässt sich aufgrund seiner Ähnlichkeit mit Assembler recht einfach als weiteres Backend in einen Compiler integrieren. Beispielsweise kann die Compiler-Infrastruktur LLVM WebAssembly erzeugen. Daher lässt sich mit clang aus C/C++ oder mit rustc aus Rust WebAssembly generieren. Auch mit dem Compiler Emscripten lässt sich C-Code über das LLVM-Backend im Browser zur Ausführung bringen.

Alle weiteren Beispiele im Rahmen dieses Artikels verwenden Rust. Die Programmiersprache geht ursprünglich auf Mozilla zurück, daher sind unter anderen Teile des Firefox-Browsers in Rust geschrieben. Da Mozilla auch nach dem Wechsel von Teilen des Wasm-Teams zu Fastly zu den engagierten Förderern von WebAssembly gehört, fällt die WebAssembly-Unterstützung von Rust umfangreich aus.

Bytecode bezeichnet eine Menge an Instruktionen für eine virtuelle Maschine. Sie sind im Allgemeinen unabhängig von konkreter Hardware und dem verwendeten Betriebssystem. Bytecode lässt sich durch das Kompilieren des Quellcodes erzeugen. Prominentes Beispiel ist Java-Bytecode, der sich mit einer Java Virtual Machine (JVM) auf verschiedenen Betriebssystemen und unterschiedlicher Hardware ausführen lässt. Dabei unterscheiden sich je nach Plattform zwar die JVMs, der Code hingegen ist immer gleich. Als Vorteil gegenüber der Interpretation des Quellcodes zur Laufzeit verspricht Bytecode eine schnellere Ausführung.

Die Abbildung zeigt den geplanten Aufbau des beispielhaften Chatsystems für den Browser (Abb. 1).

Der Client soll in der Lage sein, mit einem Server zu kommunizieren, um Nachrichten zu versenden und zu empfangen. Um die nachfolgenden Beispiele auszuführen, müssen die Rust-Toolchain sowie Node.js installiert sein. Wichtig ist zudem das wasm-pack, das das Erstellen von WebAssembly-basierten Projekten vereinfacht. Verschiedene Vorlagen erleichtern darüber hinaus den Einstieg in WebAssembly. Mit npm init rust-webpack chat-frontend lässt sich eine Rust-basierte Projektvorlage erzeugen, mit npm start bauen und anschließend lokal über http://localhost:8080 öffnen. Im Browser liefert diese URL zunächst nur eine leere Seite. Der Quellcode der Webseite findet sich im Verzeichnis static unter dem Namen index.html. Direkt nach Erstellen des Beispiels ist die Datei noch relativ leer und lädt nur die JavaScript-Datei index.js. Dieses Skript wiederum lädt den aus dem Rust-Code im Verzeichnis src erzeugten WebAssembly-Code.

Um Nachrichten anzuzeigen und eine Eingabemöglichkeit für neue Nachrichten anzubieten, benötigt der Chatclient eine Textbox. Dazu ist die Datei index.html folgendermaßen anzupassen:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>My Rust Chat project!</title>
</head>
<body>
    <script src="index.js"></script>
    <div><textarea rows="20" cols="64" id="chat-display" value=""></textarea></div>
    <form id="chat-controls" action="" method="dialog">
        <input type="text" id="username" value="username" name="username">
        <input type="text" id="message" value="message" name="message">
        <input type="submit" value="Send">
    </form>
</body>
</html>

Die erstellte Textbox enthält drei IDs: Mit der ID chat-display lassen sich ankommende Nachrichten visualisieren. Über die ID message lassen sich in den Eingabefeldern Nachrichten verfassen und mit einem Sendernamen (ID username) versehen. Beim Drücken des Send-Button soll der WebAssembly-Code die Nachricht an einen Server senden, der sie an alle Chatteilnehmer weiterleitet.

In der JavaScript-Datei index.js, die den WebAssembly-Code lädt, muss zunächst ein Einstiegspunkt definiert werden, der die Anwendung initialisiert. Für jegliche Interaktion zwischen JavaScript und Rust hilft das Crate wasm-bindgen. Crates stellen eine Code-Sammlung dar, die eine eine Bibliothek zur Verfügung stellt oder selbst eine Anwendung ist (siehe Artikel Rust: Crates und Continuous Integration – eine perfekte Mischung). Durch dieses Crate lassen sich Funktionen mit #[wasm_bindgen] markieren, sodass die Rust-Funktionen sich von JavaScript aus aufrufen lassen. Alternativ lassen sich Funktionen auch automatisch beim Initialisieren des Moduls aufrufen. Dazu ist lediglich der Zusatz start hinzuzufügen:

// Called by our JS entry point to run the example     .
#[wasm_bindgen(start)]
pub fn run() -> Result<(), JsValue> wurde {
    ...
}

Browser stellen eine Reihe von Schnittstellen zur Verfügung, die auch für WebAssembly-Anwendungen interessant sind. Um sie verwenden zu können, steht das Crate web_sys zur Verfügung. Für den Chatclient im Browser ist ein Socket-Interface erforderlich, um eine Verbindung mit einem Server aufzubauen. Dazu stehen in allen Browsern sogenannte WebSockets zur Verfügung. Die Bearbeitung erfolgt komplett asynchron und für jedes Ereignis ist ein Handler zu registrieren, der es bearbeitet.

Um einen Chatclient zu realisieren, baut die Initialisierungsfunktion run über web_sys eine WebSocket-Verbindung auf. Der Aufruf der Funktion setup_ws_connection liefert das erzeugte WebSocket als Ergebnis zurück.

    fn setup_ws_connection() -> WebSocket {
        let ws = WebSocket::new_with_str("ws://localhost:2794", "rust-websocket")
            .expect("WebSocket failed to connect 'ws://localhost:2794'");

        let ws_c = ws.clone();
        let open_handler = Box::new(move || {
            console::log_1(&"Send me messages!".into());
        });
        let cb_oh: Closure<dyn Fn()> = Closure::wrap(open_handler);
        ws.set_onopen(Some(cb_oh.as_ref().unchecked_ref()));
        cb_oh.forget();
        ws
    }

Da WebSockets asynchron arbeiten, ist es nicht notwendig auf den Verbindungsaufbau zu warten. Stattdessen lässt sich ein zuvor registrierter Handler aufrufen, sobald die Verbindung vollständig aufgebaut ist. In Rust stehen dazu namenlose Funktion vom Typ Closure zur Verfügung, die Zugriff auf den Erstellungskontext haben. Wie das vorangehende Beispiel zeigt, wird der Closure open_handler erzeugt und mit set_onopen am WebSocket ws registriert. Innerhalb dieses Closures lässt sich eine Debug-Meldung auf der Console ausgeben, die anzeigt, wann die Verbindung erfolgreich aufgebaut ist.

Auf die gleiche Weise sind zwei weitere Handler zu registrieren: einer, der beim Klicken des Sendeknopfes die Nachricht aus den Eingabefelder versendet, und ein zweiter, der ankommende Nachrichten verarbeitet und in der Textbox hinzufügt. Dabei helfen die IDs, die entsprechenden HTML-Elemente anzusprechen.

Ein simpler Chatclient zählt nicht unbedingt zu den besonders rechenintensiven Aufgaben, bei denen WebAssembly seine Stärken voll ausspielen könnte. Allerdings lässt sich die Komplexität des Chatclient erhöhen, indem Funktionen zum Versenden und Bearbeiten von Bildern hinzukommen.

Der webbasierter Chatclient benötigt grundsätzlich noch einen Server, der die Nachrichten an alle Teilnehmer weiterleitet. Im Server existieren für jeden Teilnehmer beziehungsweise für jede Verbindung zwei Threads. Ein Thread dient dem Verarbeiten ankommender Nachrichten, der zweite sendet die Nachrichten an die Teilnehmer. Darüber hinaus ist ein dritter Thread als Dispatcher erforderlich. Im Stil eines einfachen Message Broker nimmt er alle ankommenden Nachrichten entgegen und leitet sie an sämtliche Sende-Threads weiter. Die Kommunikation zwischen den Threads läuft über Multi-producer, single-consumer channels. Die Verwaltung solcher Channel zwischen dem Dispatcher-Thread und den Sende-Threads erfolgt über einen Vektor.

let (dispatcher_tx, dispatcher_rx) = mpsc::channel::<String>();
let client_senders: Arc<Mutex<Vec<mpsc::Sender<websocket::OwnedMessage>>>> = Arc::new(Mutex::new(vec![]));

thread::spawn(move || {
    while let Ok(msg) = dispatcher_rx.recv() {
        for sender in client_senders.lock().expect("Unable to get lock").iter() {
            let _ = sender.send(msg.clone());
        }
    }
});

Muss der Server um eine nur bedingt vertrauenswürdige Komponente eines Dritthersteller erweitert werden, gilt es, die Anwendung vor dem Zugriff Dritter zu schützen. Dazu bietet WebAssembly die Möglichkeit, eine Komponente in einer Sandbox auszuführen. Es spricht außerdem grundsätzlich nichts dagegen, WebAssembly außerhalb des Browsers zu verwenden und Anwendungen als WebAssembly betriebssystemunabhängig auszuliefern. Der Bytecode verspricht eine Performance, die nahezu der von nativem Code entspricht. Allerdings definiert WebAssembly nur eine abstrakte Maschine und keine Schnittstelle zu einem abstrakten und entsprechende Abbildung zum verwendeten Betriebssystem. Diesem Problem widmet sich die WebAssembly System Interface (WASI) Subgroup, die an einer entsprechenden Schnittstelle zu einem abstrakten Betriebssystem arbeitet.

Sie befindet sich in der Entwicklung und gilt noch nicht als stabil. Allerdings existiert heute bereits eine brauchbare Unterstützung innerhalb der Rust Toolchain. Mit rustup target add wasm32-wasi lassen sich die Komponenten des Compilers installieren, um entsprechenden WebAssembly-Code zu erzeugen. Die Laufzeitumgebung wasmtime erlaubt es, WASI-basierten WebAssembly-Code auf jedem Betriebssystem auszuführen.

Steht eine Komponente als WASI-Komponente zur Verfügung, lässt sich innerhalb der nativen Anwendung der WebAssembly-Code in einer Sandbox ausführen. Mit wasmtime ist dies einfach zu realisieren. Im Beispiel soll die Funktion add_emoji als WebAssembly zur Verfügung gestellt werden. Laut Hersteller wandelt die Funktion alle Zeichenfolgen von :-) in 😀 um.

thread::spawn(move || {
    let pipe_stdout = Arc::new(RwLock::new(Cursor::new(vec![])));
    let pipe_stdin = Arc::new(RwLock::new(Cursor::new(vec![])));

    let store = Store::default();
    let mut linker = Linker::new(&store);
    let wasi_ctx = WasiCtxBuilder::new()
        .inherit_args()
        .inherit_env()
        .stdin(ReadPipe::from_shared(pipe_stdin.clone()))
        .stdout(WritePipe::from_shared(pipe_stdout.clone()))
        .inherit_stderr()
        .build().expect("Unable to build WasiCtx");
    let wasi = Wasi::new(&store, wasi_ctx);
    wasi.add_to_linker(&mut linker)
        .expect("Unable to add linker");

    // Load WebAssembly Code
    let module = Module::from_file(store.engine(), "plugin.wasm").unwrap();
    linker.module("", &module).unwrap();
    let add_emoji = linker
        .get_one_by_name("", "add_emoji")
        .expect("Unable to find symbol")
        .into_func()
        .expect("Unable to convert into a function")
        .get0::<()>()
        .expect("Unable to specify the signature");

    while let Ok(msg) = dispatcher_rx.recv() {
        for sender in client_senders.lock().expect("Unable to get lock").iter() {
            // forward message to webassembly code
            pipe_stdin.write().unwrap().get_mut().extend_from_slice(msg.as_bytes());

            // call WebAssembly function
            add_emoji().expect("Unable to call add_emoji");

            // receive result
            let new_msg = String::from_utf8(pipe_stdout.write().unwrap().get_ref().to_vec()).unwrap();

            let _ = sender.send(new_msg.clone());
        }
    }
});

Der Beispielcode erzeugt eine Sandbox, die über zwei virtuelle Pipes für den Eingabe- (pipe_stdin) und Ausgabe-Stream (pipe_stdout) mit der Außenwelt kommunizieren kann. Nachdem die WebAssembly-Funktion als Module geladen sind und dem wasmtime-Linker zur Verfügung stehen, lässt sich die aufzurufende Funktion mit get_one_by_name im Modul suchen. Ist das Symbol gefunden, gilt es zu überprüfen, ob es eine Funktion ist, die kein Argument erwartet (get0) und zudem kein Ergebnis zurückliefert (() in get0::<()>). In dem Fall lässt sich anschließend die Funktion add_emoji aufrufen und mit ihr über die virtuellen Pipes kommunizieren.

Dank WebAssembly wird die Funktion einmal vor dem Ausführen kompiliert und danach nativ ausgeführt. Theoretisch ist zu erwarten, dass die Leistungseinbußen im Vergleich zu einer nativen Bibliothek bei wiederholter Ausführung der Funktion relativ gering ausfallen. Da sich allerdings WebAssembly und WASI noch in Entwicklung befinden, sind erst noch praktische Erfahrungen abzuwarten.

Noch kommt WebAssembly in der Praxis nicht im große Stil zum Einsatz. Da jedoch alle relevanten Browser Wasm bereits unterstützen, ist zu erwarten, dass es künftig eine immer größere Rolle spielt. WebAssembly in Rust befindet sich aktuell noch in aktiver Entwicklung, funktioniert nach Erfahrung der beiden Autoren dieses Beitrags aber bereits sehr gut. Erste kommerzielle Einsatzgebiete wie das Serverless-Produkt Workers von Cloudflare unterstreichen die wachsende Bedeutung von Wasm.

Der vollständige Code des gezeigten Beispiels steht auf GitHub zum Download parat.

Dr. Stefan Lankes
arbeitet als akademischer Direktor am Institute of Automation of Complex Power Systems der RWTH Aachen University. Er forscht seit circa 20 Jahre im Bereich der systemnahen Software für Hochleistungsrechner und echtzeitfähige Systeme. Unter anderem ist er Initiator des Open-Source-Projekts HermitCore.

Dr. Jens Breitbart
arbeitet als Softwarearchitekt bei Driver Assistance, Robert Bosch GmbH. Er forschte fast zehn Jahre im Bereich des High Performance Computing und war bis Oktober vergangenen Jahres Mitarbeiter am Lehrstuhl für Rechnertechnik und Rechnerorganisation der TU München beschäftigt. Er arbeitet privat in verschiedenen Softwareprojekten mit, unter anderem HermitCore. (map)