個人的に興味があるRustとWasmについて

どうも、ヨシザウルスです。光栄ながら琉球大学知能情報アドベントカレンダーのトップバッターを務めさせていただきます。

1. はじめに

この記事は、私が個人的に興味を持っているRustとWasmについて説明します。

間違いや訂正が必要だと感じた箇所があればコメント大歓迎です。

では、早速、RustとWebAssemblyの説明をしたいと思います。

2. Rust

Rust logo

Rustはメモリ安全性を保証しつつもC/C++に匹敵するパフォーマンスを発揮できるプログラミング言語です。

メモリ安全性とは、メモリアクセスに関するバグをコンパイル時に検知することで、実行時にメモリアクセスに関するバグが発生しないことを保証することです。

モリーアクセスに関するバグはセキュリティの脆弱性にも繋がるため、メモリ安全性を保証することは非常に重要です。

ちなみに、Rustは習得が非常に難しい言語です。 所有権、ライフタイムなど、一部の文法が特殊で、慣れるのに時間がかかります。しかし、これらの文法はRustのメモリ安全性を保証するために必要なので、それらを理解し、適切に使用することが不可欠です。

かなり荒く不親切な内容になりますが、Rustの基本的なルール(所有権、ライフタイム)を紹介します。

2.1. Rustの所有権

以下に所有権を説明するコードを示します。

fn main() {
    // 文字列を作成し、所有権をs1に与える
    let s1 = String::from("Hello, Rust!");

    // s1の所有権をs2にムーブする
    let s2 = s1;

    // s1を使用しようとすると、所有権が移動しているためエラーが発生する
    // println!("{}", s1); // この行はコンパイルエラーになる

    // s2は所有権を持っているため、正常に動作する
    println!("{}", s2);
}

このコードは、s1に文字列を作成し、その所有権をs2に移動しています。 そのため、s1を使用しようとするとコンパイルエラーが発生しますが、s2は所有権を持っているため、正常に動作します。

Rustは、所有権を移動することで、メモリアクセスに関するバグをコンパイル時に検知することができます。

少し発展させて、借用ルールも説明します。

fn main() {
    // 文字列を作成し、所有権をs1に与える
    let s1 = String::from("Hello, Rust!");

    // s1の所有権をs2にムーブする
    let s2 = s1;

    // s2の所有権を借用し、参照を作成する
    let len = calculate_length(&s2);

    // s2は所有権を持っているため、正常に動作する
    println!("{} is {} characters long.", s2, len);
}

// sの所有権を借用し、文字数を返す関数
fn calculate_length(s: &String) -> usize {
    s.len()
}

このコードは、calculate_length関数がsを参照として借用していることを示しています。 そのため、sの所有権を持っているs2を使用しようとしてもコンパイルエラーが発生しません。 また、sの所有権を持っているs2を変更することもできません。

このようにRustは所有権を使ってメモリ安全性を保証しています。

2.2. Rustのライフタイム

所有権の他にもRustには、ライフタイムという概念があります。ライフタイムとは、参照が有効である期間のことです。ライフタイムは、コンパイラが参照の有効な期間を検証するのに使用されます。

ライフタイムに関する例を示します。

fn main() {
    let string1 = String::from("Hello, Rust!");
    let string2 = String::from("Hello, Lifetimes!");

    // longest関数を呼び出し、戻り値をresultとして受け取る
    let result;
    {
        // string1とstring2の参照を渡し、戻り値の参照をresultに格納する
        result = longest(&string1, &string2);
        println!("The longest string is: {}", result);
    } // resultはスコープを抜けるが、resultの参照は有効なライフタイム内にあるため問題ない

    // resultの参照を使おうとすると、スコープ外なのでエラーが発生する
    // println!("The longest string is: {}", result);
}

// 'aは関数の引数と返り値のライフタイムが関連していることを示す
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

このコードのlongest関数の関数シグネチャにある'aはライフタイム注釈で、これはコンパイラlongest関数によって返される参照の参照が有効である期間は引数xyのライフタイムに依存していること示しています。これによってresultが参照しているstring1またはstring2の参照が有効であるということを保証しています。

このケースでは、longest関数は2つの文字列の参照を引数として受け取り、そのうちの1つを参照として返します。しかし、この関数がどの参照を返すかは実行時までわかりません。したがって、このようにライフタイム注釈を使って、引数の参照と同じまたはそれより短いライフタイムを持つことを保証する必要がありました。

Rustの魅力はまだまだあります。RustからC/C++を呼べたり、asm!でRustからインラインでアセンブリーが書けたり、低レイヤー、組み込み、アプリケーション、何にでも応用が効く言語なので魅力満載です。

3. Wasm

これもかなり面白い技術だと思います。簡単に紹介すると、どのOS, CPUでも実行できるバイナリフォーマットです。

WebAssembly(Wasm)については以下のように公式ページで説明されています。

WebAssembly(略称Wasm)は、スタックベースの仮想マシンのためのバイナリ命令形式です。Wasmは、プログラミング言語の移植可能なコンパイルターゲットとして設計されており、クライアントとサーバーのアプリケーションをWeb上にデプロイすることを可能にします。

「スタックベースの仮想マシンのためのバイナリ命令形式」という表現が直感的に理解しづらいので以降噛み砕いて説明していきます。これがわかるとかなり面白いです。

3.1. 「スタックベースの仮想マシンのためのバイナリ命令形式」とは

「スタックベースの仮想マシンのためのバイナリ命令形式」とは何なのか、用語を一つづつ紐解いて説明していきます。

3.1.1. 「スタック」

スタック」は、データ構造の一種で、データの追加(プッシュ)と削除(ポップ)が一方向からしか行えない特性を持っています。この特性は「後入れ先出し」(Last In First Out, LIFO)とも呼ばれます。

スタックの主な操作は以下の通りです:

  • プッシュ(Push):スタックの一番上に新しいデータを追加する。
  • ポップ(Pop):スタックの一番上のデータを取り出し、そのデータをスタックから削除する。

スタックの具体的な使われ方について、スタックベースの計算機やプログラミング言語(RPN, Forth, PostScript)で使われる逆ポーランド記法を例に出して説明します。

逆ポーランド記法オペランド(数)の後に演算子を置くという書き方をします。

つまり、具体例を出すと5 * (3 + 4)という式を表すと5 3 4 + *となります。

計算は以下のように行います。

  1. 5 をスタックにプッシュする(スタック:5)。
  2. 3 をスタックにプッシュする(スタック:5, 3)。
  3. 4 をスタックにプッシュする(スタック:5, 3, 4)。
  4. + を見つけたので、スタックから 3 と 4 をポップし、それらを足す。結果の 7 をスタックにプッシュする(スタック:5, 7)。
  5. * を見つけたので、スタックから 5 と 7 をポップし、それらを掛ける。結果の 35 をスタックにプッシュする(スタック:35)。
  6. 式が終了したので、スタックのトップにある 35 が最終的な結果となる。

スタックを使った計算の流れはこのようなものになります。実はWasmもスタックベースの処理をしているので、逆ポーランド記法と同じ計算をします。

3.1.2. 「仮想マシン

仮想マシンは、異なるOSやCPUアーキテクチャ間での互換性を提供します。つまり、仮想マシンは「OSやCPUのアーキテクチャを吸収する」役割を果たします。Wasmも仮想マシン上で命令が実行されるので、これにより、同じバイナリ命令形式ファイルが異なる環境で一貫して動作することが保証されます。これは、開発者が各環境に対して個別にバイナリ命令形式ファイルを生成する必要がなく、一度生成したWasmをどの環境でも実行できるということを意味します。

WebAssemblyテキストフォーマット(wat)を使ってWasmの仮想マシン上での計算フローを説明します。watはWebAssemblyのバイナリ命令形式を人間が読み書きできるテキスト表現です。Wasmからwatへの変換もできます。

以下のようなwatファイル(ファイル名: calculate.wat)を用意します。 内容は先程の5 * (3 + 4)を関数化してx * (y + z)としたものです。

(module
  (func (export "calculate") (param $x i32) (param $y i32) (param $z i32) (result i32)
    local.get $x
    local.get $y
    local.get $z
    (i32.add)
    (i32.mul)
  )
)

この関数の実行フローは以下のようになります:

  1. $x、$y、$zの値が順にスタックにプッシュされます。
  2. i32.add命令が実行されると、スタックのトップから2つの値($zと$y)がポップされ、それらの加算結果がスタックにプッシュされます。
  3. 次にi32.mul命令が実行されると、スタックのトップから2つの値(加算結果と$x)がポップされ、それらの乗算結果がスタックにプッシュされます。
  4. 最後に、スタックのトップにある値(乗算結果)が関数の戻り値として返されます。

例に出した逆ポーランド記法と同じ流れの処理をしています。

この段階でwatファイルが用意できたので、変換して実際にWasmのバイナリ命令形式ファイルを使ってみます。

$ wat2wasm calculate.watを実行して、calculate.wasmを生成します。

このwasmファイルを使うために、htmlファイルとjsファイルを作成します。すべて同じディレクトリに配置してください。

index.html

<!DOCTYPE html>
<html>
<head>
    <title>WebAssembly Demo</title>
</head>
<body>
    <h1>WebAssembly Demo: x * (y + z)</h1>
    <input id="x" type="number" placeholder="Enter x">
    <input id="y" type="number" placeholder="Enter y">
    <input id="z" type="number" placeholder="Enter z">
    <button onclick="calculate()">Calculate</button>
    <p id="result"></p>
    <script src="main.js"></script>
</body>
</html>

main.js

async function calculate() {
    const x = document.getElementById('x').value;
    const y = document.getElementById('y').value;
    const z = document.getElementById('z').value;

    const wasmModule = await WebAssembly.instantiateStreaming(fetch('calculate.wasm'));
    const result = wasmModule.instance.exports.calculate(x, y, z);

    document.getElementById('result').innerText = `${x} * (${y} + ${z}) = ${result}`;
}

$ python3 -m http.server 8000を実行してください。

http://localhost::8000を開いて、x = 5, y = 3, z = 4 を代入して計算結果を取得してください。

この画像のような結果になると思います。

これはWasmをjsから呼び出すことに成功したことを意味します。(拍手)

ちなみにwasmはjs以外でも読み込めます。

Rustでも読み込んでみましょう。

$ cargo init rust-wasm

Cargo.tomlに以下の行を追加(バージョンはブログ公開時点での最新版)

[dependencies]
wasmer = "4.2.4"

wasmディレクトリをrust-wasmに作成して、wasmファイルを置きます。

main.rsを以下のように書きます。

// 標準ライブラリからenvとfs::readをインポートします。
use std::env;
use std::fs::read;
// wasmerから必要なものをインポートします。これはWebAssemblyを扱うためのライブラリです。
use wasmer::{imports, Instance, Module, RuntimeError, Store, Value};

// main関数を定義します。エラーが発生した場合は、エラーを返します。
fn main() -> Result<(), Box<dyn std::error::Error>> {
    // コマンドライン引数を取得します。
    let args: Vec<String> = env::args().collect();
    // 引数をi32型にパースします。
    let arg1 = args[1].parse::<i32>()?;
    let arg2 = args[2].parse::<i32>()?;
    let arg3 = args[3].parse::<i32>()?;

    // WebAssemblyバイナリを読み込みます。
    let wasm_bytes = read("./wasm/calculate.wasm")?;
    // ストアを作成します。これはWebAssemblyの状態を保持します。
    let mut store = Store::default();
    // モジュールを作成します。これはWebAssemblyのコードを保持します。
    let module = Module::new(&store, &wasm_bytes)?;

    // インポートオブジェクトを作成します。これはWebAssemblyがホスト環境から関数を呼び出すために使用します。
    let import_object = imports! {};
    // インスタンスを作成します。これはWebAssemblyの実行環境を保持します。
    let instance = Instance::new(&mut store, &module, &import_object)?;

    // "calculate"関数をエクスポートから取得します。
    let calculate = instance.exports.get_function("calculate")?;
    // 関数を呼び出し、結果を取得します。
    let result = calculate.call(
        &mut store,
        &[Value::I32(arg1), Value::I32(arg2), Value::I32(arg3)],
    )?;

    // 結果を取得します。結果がi32型でない場合はエラーを返します。
    let result_value = match result[0] {
        Value::I32(val) => val,
        _ => return Err(Box::new(RuntimeError::new("Unexpected result type"))),
    };

    // 結果を出力します。
    println!("{} * ({} + {}) = {}", arg1, arg2, arg3, result_value);

    // 正常終了します。
    Ok(())
}

$ cargo 5 3 4と実行すると以下のような出力が得られます。

$ cargo run 5 3 4
    Finished dev [unoptimized + debuginfo] target(s) in 0.12s
     Running `target/debug/rust-wasm 5 3 4`
5 * (3 + 4) = 35

このようにjsでなくとも専用のランタイムさえあれば、Wasmをどの言語からでも呼び出せます。

Wasmの仮想マシンさえあればどのようなOS, CPU, さらにどのような言語(Wasmランタイムがあれば)からでもWasmが呼び出せることがわかりました。

ここまで読めば、「スタックベースの仮想マシン」が何を意味するのか理解できると思います。

3.1.3. 「バイナリ命令形式」

次に「バイナリ形式」の部分について簡単に解説します。

jsが呼んでいるcalculate.wasmの中身を見てみましょう。

xxdコマンドでバイナリファイルを16進数で表示してみます。

$ xxd calculate.wasm

00000000: 0061 736d 0100 0000 0108 0160 037f 7f7f  .asm.......`....
00000010: 017f 0302 0100 070d 0109 6361 6c63 756c  ..........calcul
00000020: 6174 6500 000a 0c01 0a00 2000 2001 2002  ate....... . . .
00000030: 6a6c 0b   

これでは中身がさっぱりなので、専用のコマンドを使います。

$ wasm-objdump -s calculate.wasmを実行してバイナリファイルを読んでみましょう。

$ wasm-objdump --full-contents calculate.wasm

calculate.wasm: file format wasm 0x1

Contents of section Type:
000000a: 0160 037f 7f7f 017f                      .`......

Contents of section Function:
0000014: 0100                                     ..

Contents of section Export:
0000018: 0109 6361 6c63 756c 6174 6500 00         ..calculate..

Contents of section Code:
0000027: 010a 0020 0020 0120 026a 6c0b            ... . . .jl.

若干読めるようになりました。

この出力をWasmのバイナリフォーマットのドキュメントを参考にして読んでいきます。

このWasmのバイナリ命令フォーマットは、セクションがType, Function, Export, Codeに分かれています。

Code010a 0020 0020 0120 026a 6c0bを読んでみましょう。

ドキュメントにある命令のバイトコードのインデックスから以下のコードが読み解けました。

  • 0x20
    • local.get
    • 引数をスタックにpushする命令
  • 0x6a
    • i32.add
    • 2つのi32の加算をする命令
  • 0x6c
    • i32.mul
    • 2つのi32の乗算をする命令
  • 0x0b
    • 関数を終了する命令

これを使ってもう一度バイナリ命令フォーマットを読んでみましょう。

20 00 20 01 20 02で3つの引数(00, 01, 02)をスタックにpushしていることがわかります。

6aでスタックから2つ値をpopして加算しています。

6cでスタックから2つ値をpopして乗算しています。

0bで関数を終了しています。

先程、書いたwatファイルと同じ内容になってることが確認できました。

バイナリも触り程度ですが、概要を掴むことができました。

Wasmを示す「スタックベースの仮想マシンのためのバイナリ命令形式」という表現の意味がこれでわかるようになりました。

3.2. もっと面白いWasm

Wasmの説明が内部構造よりの話になってしまいましたが、これで説明は終わりです。

jsの限界速度よりも速く実行ができるので、フロントエンドのパフォーマンスチューニングで最後の手段みたいに使うと楽しいかもしれません。

実はWasmはもっと面白くて、コンパイラ言語に限らずどのような言語からでもWasmにコンパイルできたりなど、もっと深い話があります。実装する言語によっては同じ処理でも実行速度が違ったりします。例えばPythonで生成したWasmとRustで生成したWasmでは、RustのWasmの方が圧倒的に速いです。

あと、Wasmは、設計上、システムコールなどのユーザーランド外の処理はできないです。サンドボックス内の実行をしているので、セキュリティ的にも安心して使えると言ってもいいです。

もっとWasmには魅力がありますが、長くなるので一旦ストップします。

4. 終わり

RustとWasmのお話をしました。

実は、Wasmを書くならRustがまず第一候補に上がるくらい両方とも相性がいいです。

Rust + Wasmでコンテナ技術がなくなる...みたいな未来があったら楽しいですね。10年後の話かもしれません。

読みにくいオタクの早口みたいな記事になりましたが、書く作業は楽しかったです。

記事を読んでくださって、ありがとうございました。