mrubyを通じてWebAssemblyの未来を想う~新しいウェブサービスの開発課程にて

2021年10月11日 月曜日


【この記事を書いた人】
山本 悠滋

日本Haskellユーザーグループ(愛称 Haskell-jp)発起人の一人にして、Haskell-jpで一番のおしゃべり。 HaskellとWebAssemblyとプリキュアとポムポムプリンをこよなく愛する。

「mrubyを通じてWebAssemblyの未来を想う~新しいウェブサービスの開発課程にて」のイメージ

こんにちは。ブラウザ外のWebAssemblyに関心が偏りすぎて、ブラウザにおけるWebAssemblyについて聞かれると戸惑うことが多い山本悠滋です。普段はIIJ-IIの技術開発室という部署で、IIJ本体をサポートするための開発をいろいろ行ったり、WebAssemblyを応用した新しいウェブサービスの開発に取り組んでいます。

今回は、開発している「WebAssemblyを応用した新しいウェブサービス」のサンプルとして、mrubyのインタープリタをWASIに準拠したWebAssemblyファイルにコンパイルするまでの課程や、それを通じてわかった、今のWebAssemblyに足りないもの、そして現在策定中の仕様でそれがカバーされようとしていることを解説します。

背景 — ブラウザ外でのWebAssemblyとは?

元来WebAssemblyは、C言語などJavaScript以外のプログラミング言語で書かれた資産を、ブラウザ上で活用しやすくするために作られた命令セットです。しかし、仕様上は特にブラウザ上での用途に限ったものではないため、その堅牢なセキュリティや、特定のハードウェア・プログラミング言語に強く依存しない可搬性を生かして、様々な場面での応用が試みられています。例えば、次のような採用事例があります:

そして冒頭で触れた私が開発している「WebAssemblyを応用した新しいウェブサービス」もそのうちの一つです。このようにWebAssemblyをアプリケーションの拡張機構として用いることで、安全にサードパーティのコードを実行したり、多くのプログラミング言語を対象にしたりできる環境が手に入ります。今回は「多くのプログラミング言語を対象に」できるという特徴のデモとして、mrubyを選ぶことにしました。

Emscriptenに続くもう一つのWebAssemblyビルドツールチェイン — wasi-sdk

実際のところ、mrubyをWebAssemblyにコンパイルした事例はすでにいくつかあります。例えば「mrubyをブラウザで実行するまで (WebAssembly)」や「mrubyをWebAssemblyで動かす(翻訳)」が該当します。これらには動かし方に違いがあり、前者はmrubyのインタープリタをWebAssemblyにコンパイルしたものであり、後者はRubyのソースコードをmrubyのバイトコードを経由してWebAssemblyに事前コンパイルした、というものです。いずれにしても、すでにRubyのコードをWebAssemblyで動かすことはできているのです。ところがこれらには、前述した「ブラウザ外でのWebAssembly」として動かす上で重大な問題があります。それは、ブラウザをはじめとする、JavaScriptの実行環境を備えたアプリケーション上でないと動かないという問題です。どちらも、JavaScriptが利用できる環境でCやC++のコードを動かす、Emscriptenでビルドしているため、ブラウザなどが提供するJavaScriptのAPIがなければ動かず、結果「ブラウザ外でのWebAssembly」としてmrubyを動かせたとは言いがたいのです1

これは、現状のWebAssemblyに(主にセキュリティ上の理由で敢えて定義していないものも含めて)機能が不足していて、本当にあらゆるCやC++のコードをコンパイルするには至っていないという背景もあります。EmscriptenはそれらをJavaScriptで実装して、ビルドしたWebAssemblyのモジュールにimportさせることでなんとか多くのCやC++のコードを動かせているのです。具体的には、例えば次の機能がWebAssemblyだけでは定義されていません:

  1. ファイルやソケットへの入出力処理
  2. マルチスレッド機構
  3. C++の例外ハンドラ

このうち「ファイルやソケットへの入出力処理」と「マルチスレッド機構2(の一部)をカバーする予定なのがWASI (WebAssembly System Interface)です(「C++の例外ハンドラ」をカバーするものについてはもっと後の節で説明します)。WebAssemblyには、WebAsssemblyを動かすアプリケーション(「ホスト」と呼ばれます)が明示的にインポートさせた関数を通じてしか入出力処理ができない、という強烈な制限があります。一方、世の中にはおなじみprintfなど入出力を行う関数が直書きされたコードがたくさんあります。これらをWebAssemblyにコンパイルして動かすには、ホストがそれらの入出力を行う関数を実装して、WebAssemblyのモジュールにインポートさせてあげなければなりません。Emscriptenは、WASIができる前にそうした関数を自前で実装してきました。今度はEmscripten以外のホストでも入出力用のAPIなどが実装できるよう、標準化するためにWASIは策定されました。WASIはあくまでも関数の引数や戻り値の型定義(API)を決めるだけで、WebAssemblyそのものに入出力用の機能ができる、というわけではないのでご注意ください。

wasi-sdkは、そうしたWASIに準拠したAPIを利用するWebAssemblyモジュールを、ビルドするためのツール群です。CやC++のコンパイラとしてclangや、WASIが定義したAPIを使いやすくラップするwasi-libcなどがひとまとめになっています。今回はこちらを利用してmrubyのインタープリタをコンパイルすることにしました。

mrubyをwasi-sdkでビルドするまで

0. wasi-sdkのインストール

いよいよmrubyをwasi-sdkでコンパイルする… 前に、ビルドに必要なwasi-sdkなどをインストールしておきましょう。wasi-sdkのインストールは簡単で、GitHubにおけるリポジトリのReleasesからtarballをダウンロードしてインストールしたいディレクトリに展開するだけです。debパッケージもある3ので、UbuntuやDebianをご利用の方はなおのこと楽にインストールできるでしょう。

なお、wasi-sdkを含め、私が今回ビルドやmrubyの実行に利用した環境は以下の通りです:

  • wasi-sdkのバージョン: wasi-sdk-12
  • wasi-sdkをインストールしたパス: /opt/wasi-sdk-12.0/
  • wasmtimeのバージョン: 0.30.0
  • wasmerのバージョン: 2.0.0
    • 本文中でwasmtimeで実行した例は、すべてwasmerでも試して同様の結果になることを確認しています
  • Rubyのバージョン: 2.7.4
    • ※mrubyそのもののバージョンではなくビルドのために使用したCRubyのバージョン。apt install rubyでインストールしたもの
  • rake: 13.0.3
    • ※こちらもapt install rakeでインストールしたもの
  • 使用したOS: Debian 11.0 “bullseye”

1. リポジトリのクローン

ひとまずmrubyのリポジトリをクローンして、作業用のブランチを作りましょう。今回は無用なトラブルを避けるため、最新安定版であろう3.0.0のタグが付いたバージョンからブランチを作ることにしました。

git clone https://github.com/mruby/mruby.git
cd mruby
git switch -c wasi-step-by-step 3.0.0

2. build configの作成

mrubyのコンパイル手順について書かれたドキュメントを読むと、ビルドの設定をRubyの内部DSLで書いてbuild_configというディレクトリに置き、環境変数MRUBY_CONFIGでその名前を指定しつつrakeコマンドを実行すればいいみたいです。というわけで、今回はbuild_config/default.rbをコピーしてから次のようなビルドの設定を書きました。抜粋します:

# 環境変数でwasi-sdkをインストールしたパスを指定できるようにしておく
WASI_SDK_ROOT = ENV['WASI_SDK_ROOT'] || fail('Specify WASI_SDK_ROOT environment variable!')

MRuby::CrossBuild.new('32bit') do |conf|
  # ...

  # (1) 必要なgemの指定
  conf.gembox "stdlib"
  conf.gembox "stdlib-ext"
  # Use standard IO/File class
  conf.gem :core => "mruby-io"
  # Use standard print/puts/p
  conf.gem :core => "mruby-print"
  conf.gembox "math"
  conf.gembox "metaprog"
  conf.gem :core => "mruby-bin-mruby"

  # (2) Cコンパイラの設定
  conf.cc do |cc|
    cc.command = "#{WASI_SDK_ROOT}/bin/clang"
    cc.flags = ["--sysroot=#{WASI_SDK_ROOT}/share/wasi-sysroot"]
    # ...
  end

  # (3) C++の例外機構は使用しない
  conf.disable_cxx_exception

  # ...

  # (4) ビルド時に使用するその他のツールの設定
  conf.linker do |linker|
    linker.command = "#{WASI_SDK_ROOT}/bin/wasm-ld"
    linker.flags = ['--verbose']
    linker.flags_before_libraries = []
    linker.libraries = %w(c clang_rt.builtins-wasm32)
    linker.flags_after_libraries = []
    linker.library_paths = ["#{WASI_SDK_ROOT}/share/wasi-sysroot/lib/wasm32-wasi", "#{WASI_SDK_ROOT}/lib/clang/11.0.0/lib/wasi/"]
    linker.option_library = '-l%s'
    linker.option_library_path = '-L%s'
    linker.link_options = "%{flags} -o '%{outfile}' '#{WASI_SDK_ROOT}/share/wasi-sysroot/lib/wasm32-wasi/crt1.o' %{objs} %{libs}"
  end
  conf.archiver do |archiver|
    archiver.command = "#{WASI_SDK_ROOT}/bin/llvm-ar"
    archiver.archive_options = 'vrs "%{outfile}" %{objs}'
  end

  # ...
end

番号を振った箇所を一つずつ解説します:

(1) 必要なgemの指定

mrubyでは、原則としてビルドする時点で使用するgem(ライブラリや、実行ファイル)をビルドの設定で明記しておく必要があります。今回は、とにかくmrubyのインタープリタを動かしたかったので、デフォルトのgembox(ビルドの設定での指定を楽にするために、複数のgemをひとまとめにしたもの)から、mrubyのインタープリタを提供するmruby-bin-mruby以外の実行ファイルを抜き出した設定にしました。

(2) Cコンパイラの設定

Cコンパイラとしてwasi-sdkが含んでいるclangのパスを指定します。このほか、wasi-sdkのドキュメントのとおり、--sysrootオプションとしてwasi-sdkが含んでいるsysrootのパスを指定する必要もあります。このsysrootに必要なライブラリの大半が含まれているようです。

(3) C++の例外機構は使用しない

mrubyは、Rubyの例外機構begin/rescue/ensure構文で使用するあの機能。他の多くの言語で言うところのtry/catch/finallyをC++の例外機構とCのsetjmp/longjmpで実装しています。mrubyのコード上ではMRB_TRYMRB_CATCHといったマクロで抽象化し、ビルド時にどちらを使うか選べるようになっています。今回は、C++の例外は使用せず、setjmp/longjmpを使うことにします。というのも、後で再度触れますが現在WebAssemblyには例外を再現する機能がなく、conf.enable_cxx_exceptionとしているとビルド時にエラーになってしまうためです。ただし、この設定によってC++製のgemが使用できなくなってしまう恐れがある点はご注意ください。

(4) ビルド時に使用するその他のツールの設定

残りは、リンカやアーカイバなど、Cコンパイラが各.cファイルをコンパイルした後、オブジェクトファイルをまとめるツール群の設定です。Cコンパイラの設定と同様に、wasi-sdkに含まれているリンカやアーカイバのパスを指定します。それから、libcやclangのランタイムライブラリなど、依存しているライブラリのパスと名前を指定するのも忘れないでください。コンパイラとしてwasi-sdkのclangを指定した時点で自動で使ってくれるだろうと思って当初省略していたら、残念ながらそうはなりませんでした。

3. setjmp.h を空の実装でごまかす

ビルドの設定ができたので、いよいよrakeを使ってビルドしてみます。

# 手元の環境では、なぜか rake コマンドを直接実行すると「Could not locate Gemfile or .bundle/ directory」
# というエラーが出てしまうので下記↓のコマンドで対応
$ MRUBY_CONFIG=wasi WASI_SDK_ROOT=/opt/wasi-sdk-12.0/ ruby -e "load Gem.activate_bin_path('rake', 'rake', '>= 0.a')"

… が、始まって早々次のエラーで失敗してしまいました:

# ... 省略 ...
In file included from /home/igrep/tmp/mruby/src/gc.c:24:
/home/igrep/tmp/mruby/include/mruby/throw.h:33:10: fatal error: 'setjmp.h' file not found
#include <setjmp.h>
         ^~~~~~~~~~
1 error generated.
# ... 省略 ...

読んで字のごとく、setjmp.hというヘッダーファイルがないことによるエラーです。これは文字通りsetjmp関数やlongjmp関数の宣言を含むヘッダーファイルです。現在のWebAssemblyでは例外を表現できないので、先ほどの手順でC++の例外を無効にしたからsetjmp.hを使おうとするのは当然なのですが、実はこのsetjmplongjmpもwasi-sdkにはありません。WebAssemblyは、セキュリティ上の理由でsetjmplongjmpのような、実行順序を動的に操作できる機能を使えなくしているのです。つまり、今のWebAssemblyではC++の例外もsetjmp/longjmpも使えないので、残念ながらmrubyを完全にコンパイルすることができません。しかしここでさじを投げてしまうのも面白くないので、この際Rubyの例外をまともに動かすのは諦めて、setjmp/longjmpを空の実装でごまかすことにしました4

というわけで、次のようにsetjmp.hを用意した上で、

include/setjmp.h:

typedef void* jmp_buf;

int setjmp(jmp_buf env);

void longjmp(jmp_buf env, int val);

代わりの実装として、次のsetjmp.cを作ります:

#include<setjmp.h>

int setjmp(jmp_buf env) { return 0; }

void longjmp(jmp_buf env, int val) {}

これで正しく動くことはないでしょうけども、とりあえずコンパイルを通すことができるはずです。

4. WASIで利用できないAPIに依存した処理の削除

再度ビルドすると、たくさんのファイルをコンパイルしたところで今度は次のエラーに出遭いました:

# ... 省略 ...
/home/igrep/tmp/mruby/mrbgems/mruby-io/src/file_test.c:22:12: fatal error: 'sys/wait.h' file not found
  #include <sys/wait.h>
           ^~~~~~~~~~~~
1 error generated.
# ... 省略 ...

今度はsys/wait.hというヘッダーファイルがないとのことです。sys/wait.hは、子プロセスの終了を待ち受けるwaitpid関数などを提供するファイルです。

残念なことに、sys/wait.hを含む子プロセスに関する機能は、WebAssemblyにも、そしてWASIにもありません。子プロセスを作る処理は、そのままだとWebAssemblyならびにWASIの設計思想に真っ向から反するためです。前述の通り、WebAssemblyはホストが明示的にインポートさせた関数を通じてしか入出力処理ができない、という制限を守るように設計されています。この「ホストが許可した処理しか実行させない」という思想はWASIにも受け継がれており、例えばWASIでファイルシステムにアクセスする場合においても、ホストが許可したディレクトリのファイルにしかアクセスできない、という制限が敷かれます。ところが子プロセスの作成を認めてしまうと、子プロセスがこの制限を超えてファイルにアクセスしてしまう恐れがあり、管理できなくなってしまうのです。なので、WebAssemblyはもちろんWASIでも子プロセスを作成したり管理したりするAPIは一切提供されず、当然sys/wait.hも利用できないのです。こうした考え方は「Capability-Oriented」と銘打たれてWASIのoverviewで解説されていて、後述のwasmtimeを用いたWASIのtutorialで体験することができます(まぁ、現状「指定したディレクトリ以外アクセスできない」くらいしか体験できないのですけど)

「えっ、じゃあ私が作ったsystem関数をたっぷり使ったこのプログラムはWebAssemblyにできないの?」とか「シェルはWebAssemblyにコンパイルできないの?」と思った方へ。残念ながら、少なくともそのままではできないでしょう。あらかじめ使用するコマンドが決まっている場合、対象のコマンドをWebAssemblyにコンパイルして、使用したい機能を提供する関数を直接呼び出す必要があります。この翻訳は結構面倒な作業でしょうが、頑張ってください。「Interface Types」という策定中の仕様が普及した場合、多かれ少なかれ楽な作業になるでしょう。一方、シェルのように呼び出すコマンドがユーザの入力によって決まる場合、私が知る限り、現在策定中の仕様を持ってしても、WebAssemblyやWASIの機能では不可能です。WebAssemblyの「ホストが許可した処理しかさせない」精神に則ってこれを実現するなら、system関数のように「指定した文字列が表すWebAssemblyモジュールをロードして、_startという名前の関数を(制限された状態で)呼ぶ」関数をホストが提供する必要があるでしょう。実際にbashなどが行っているのは、ユーザが指定した実行ファイルをネイティブコードで書かれたモジュールとして読んで、_start関数という名前の関数を呼ぶ、ということに他ならないのですから5。そうしたAPIをWASIで策定するのも一案かもしれません。さっと探した限り提案さえされていなかったので、気が向いたら提案してみようかな。

ちょっと脱線してしまいましたが、いずれにしてもsys/wait.hは利用できないのですから、依存している処理はすべて消してしまいましょう。実はこれ以外にも子プロセス関連のAPIをはじめ、WASIが現状提供していないAPIに依存している箇所がいくつもあったので、それらはすべて削除しました。それでもWASIが提供する、ファイルや標準入出力の読み書きはできるのですから、ある程度便利ではあるはずです。この手順以降の修正も含めたすべての修正は、https://github.com/mruby/mruby/compare/master…igrep:wasi-step-by-stepにまとまっていますのでご覧ください。

WASIで利用できないAPIに依存している箇所をどんどん消した結果、無事にビルドできました!

$ MRUBY_CONFIG=wasi WASI_SDK_ROOT=/opt/wasi-sdk-12.0/ ruby -e "load Gem.activate_bin_path('rake', 'rake', '>= 0.a')"
# ... 省略 ...
================================================
      Config Name: host
 Output Directory: build/host
         Binaries: mrbc
    Included Gems:
             mruby-bin-mrbc - mruby compiler executable
             mruby-compiler - mruby compiler library
================================================

5. at_exitを実行しないよう削除

いよいよ、ビルドしたWebAssembly製mrubyを使用してみましょう。ここでは、WASIのリファレンス実装として位置づけられているwasmtimeを使って実行してみます。生成されたmrubyのインタープリタはbuild/32bit/bin/というディレクトリに作られるので、次のように実行します:

$ wasmtime run  -- build/32bit/bin/mruby -e 'puts "Hello, mruby on WASI!"'
Hello, mruby on WASI!
Error: failed to run main module `build/32bit/bin/mruby`

Caused by:
    0: failed to invoke command default
    1: wasm trap: unreachable
       wasm backtrace:
           0: 0x12b5 - <unknown>!signature_mismatch:mrb_protect_atexit
           1: 0x1b17a - <unknown>!mrb_close
           2: 0x3059 - <unknown>!cleanup
           3: 0x2139 - <unknown>!main
           4: 0x149fa0 - <unknown>!__main_void
           5: 0x1491b3 - <unknown>!__original_main
           6: 0x12c5 - <unknown>!_start
       note: using the `WASMTIME_BACKTRACE_DETAILS=1` environment variable to may show more debugging information

無事にputs "Hello, mruby on WASI!"が実行できた! …と思いきや、直後にエラーが発生してしまいました。これはRubyのコードの問題と言うよりWebAssemblyのモジュール自身の問題です。wasm backtrace:という行から察せられるとおり、WebAssembly(よく「wasm」などと略されるので「wasm backtrace:」なのです)のモジュールにおける、関数のレベルでのスタックトレースが表示されているようです。これを見るに、mrb_protect_atexitという関数でエラーが発生していると思われます。早速ソースコードを読んでみましょう:

src/error.c:

void
mrb_protect_atexit(mrb_state *mrb)
{
  if (mrb->atexit_stack_len > 0) {
    struct mrb_jmpbuf *prev_jmp = mrb->jmp;
    struct mrb_jmpbuf c_jmp;
    for (int i = mrb->atexit_stack_len; i > 0; --i) {
      MRB_TRY(&c_jmp) {
        mrb->jmp = &c_jmp;
        mrb->atexit_stack[i - 1](mrb);
        mrb->jmp = prev_jmp;
      } MRB_CATCH(&c_jmp) {
        /* ignore atexit errors */
      } MRB_END_EXC(&c_jmp);
    }
#ifndef MRB_FIXED_STATE_ATEXIT_STACK
    mrb_free(mrb, mrb->atexit_stack);
#endif
    mrb->jmp = prev_jmp;
  }
}

大して長い関数ではないのですべて引用しました。for文の中を見るとわかるとおり、やはりMRB_TRYMRB_CATCHを使っていますね…。前述の通り、今回はsetjmp/longjmpを空の実装でごまかしたので、それが原因でMRB_TRYMRB_CATCHを使うと必ずWebAssemblyレベルでのエラー(「trap」と呼ばれています)が発生してしまうようです。該当の関数はどうやらmrubyのインタープリタを使うと必ず最後に呼ばれるもののようで、他のRubyのコードを入力しても同じエラーが発生します。恐らく、Rubyのat_exit関数で登録したブロックを呼び出しているのではないでしょうか。いずれにしても、今回は強引にでも正常終了させたいのでmrb_protect_atexit関数は使わないようにしましょう。

いざ実行! …しかし

mrb_protect_atexit関数を使わないよう修正した上で再度コンパイル・実行すると、無事エラーを起こさずに実行できました!

$ wasmtime run  -- build/32bit/bin/mruby -e 'puts "Hello, mruby on WASI!"'
Hello, mruby on WASI!

もう少し試してみましょう。例えば練習問題でありそうな、標準入力から受け取った英文を大文字にして出力するコードを書いてみます:

$ echo 'hello, wasm' | wasmtime run  -- build/32bit/bin/mruby -e '$stdin.each_line {|line| puts line.upcase }'
HELLO, WASM
Error: failed to run main module `build/32bit/bin/mruby`

Caused by:
    0: failed to invoke command default
    1: wasm trap: unreachable
       wasm backtrace:
           0: 0x3af5 - <unknown>!longjmp
           1: 0x35af1 - <unknown>!exc_throw
           2: 0x359b2 - <unknown>!mrb_exc_raise
           3: 0x35a4c - <unknown>!mrb_raise
           4: 0x130b64 - <unknown>!mrb_io_sysread_common
           5: 0x12b947 - <unknown>!mrb_io_sysread
           6: 0x5adea - <unknown>!mrb_vm_exec
           7: 0x4408c - <unknown>!mrb_vm_run
           8: 0x646d5 - <unknown>!mrb_top_run
           9: 0xd91b6 - <unknown>!mrb_load_exec
          10: 0xd9ae2 - <unknown>!mrb_load_nstring_cxt
          11: 0xd9b72 - <unknown>!mrb_load_string_cxt
          12: 0x1f91 - <unknown>!main
          13: 0x149fa0 - <unknown>!__main_void
          14: 0x1491b3 - <unknown>!__original_main
          15: 0x12c5 - <unknown>!_start
       note: using the `WASMTIME_BACKTRACE_DETAILS=1` environment variable to may show more debugging information

おっと、今度は別のエラーが発生してしまいました。スタックトレースにexc_throwmrb_raisemrb_io_sysread_commonとあることから、文字列を読んだときにRubyの例外が何かしら投げられたようです(例外機構をごまかしてコンパイルしたのでエラーメッセージすら見えません!)。詳細は割愛しますが、他の入力も試してみた限り、どうやら標準入力の最後に達したときに必ず例外が投げられてしまうみたいです。コードを読んでもwasi-libcの問題なのかmrubyの問題なのか分からず、申し訳なくもバグ報告さえできていません。どうもEOFの取り扱いについて齟齬があるようです。今回は試していませんが、他のファイルの読み込みでも同じ問題が発生するかもしれません。

所感: WebAssemblyに足りないものとmrubyなどに足りないもの

というわけで、お世辞にも実用性があるとは言えないものができてしまいましたが、なんとかmrubyのインタープリタをWASIに準拠したWebAssemblyモジュールとしてコンパイルし、Rubyのスクリプトを動かすことができました。うまくいかなかった理由は何度か触れているとおり、WebAssemblyとWASIの機能不足によるものです。

このうち、WASIがサポートしていない機能については、子プロセスの管理というWASIが現状策定するつもりのない機能でした。mrubyは、ビルドする際に不要な機能を細かい単位で無効にできるという強みがあるので「ビルド設定を切り替えるだけでWASI準拠にビルドできるのでは?」と思って今回はmrubyを採用したのですが、残念ながら実際にはうまくいかず、一部のコードを書き換える必要がありました。この点については是非mrubyの開発者の皆様にも、よりよいgemの分割方法を考えるきっかけになれば幸いです。

それでもmrubyは、CRubyをWASI準拠にビルドするのに比べれば簡単でした。実はmrubyのビルドを試す前にCRubyのビルドにも挑戦したのですが、WASIのマルチスレッド機能がまだ策定も実装もされていない関係で、./configureの時点で門前払いを食らってしまいました。私のautotools(Rubyのビルドに使用しているツール群)に対する習熟度が低すぎて回避することさえままならず。このように、今後任意のプログラミング言語をWebAssemblyで動かす場合、ビルドの設定で機能の有無を細かく切り替えられることは、重要な条件となるでしょう。WASIがサポートしていようといまいと、使用していない関数のインポートを避けられれば、デプロイ時のサイズを減らすことにもつながるでしょうし。もちろん、mrubyが搭載しているmrbcを含め、ビルド時にアプリケーションのソースコードごと事前にコンパイルできる言語処理系なら、このあたりはすでにクリア済みかもしれませんが。

一方、C++の例外やsetjmp/longjmpがサポートされていないことは致命的でした。このうち、setjmp/longjmpはあまりにも自由すぎる機能なのでセキュリティ上の懸念が拭えず、策定されることはないだろうと私は考えていますが、C++の例外についてはいかがでしょうか?これにはすでに解決策ができつつあって、Exception Handlingという仕様が提案・策定されています!しかも、まだ策定中とはいえこちらのissue曰くもうV8が実装済みらしいじゃないですか!V8で利用できると言うことはNode.jsでも利用できるはずで、しかもNode.jsにはWASIの実装まで漏れなく付いてくるので、あとはwasi-libcでサポートすれば、mrubyをWASIに準拠したWebAssemblyモジュールにビルドする、という本稿の目的は、より完全な形で実現できていたかもしれません。時間があれば「C++の例外ハンドラを自作してみる。」やその元ネタを参考にチャレンジしてみたいものですね…。なお、これを書いている途中で発表されたのですが、ChromiumがBeta版でException HandlingをOrigin Trialなしでサポートするようになったそうです。普及にまた一歩近づきましたね!

以上のように、WebAssemblyやその関連仕様であるWASIには、例外機構をはじめ多くのプログラミング言語をサポートするには足りない機能がまだまだたくさんあります。しかし、その多くはすでにロードマップに加えられていて、今後仕様策定ならびに実装される見込みがあります。仕様レベルでの脆弱性など本質的な欠陥が見つかったり、策定者達の気力が潰えたりしない限りは、実現するでしょう。今後も私は各種の仕様を追いかけつつ、できそうなところでフィードバックを送ったり、みなさんに状況を共有したりしていく所存です。みなさんも是非WebAssemblyのGitHub Organizationにある各種リポジトリを覗くところから始めてみてはいかがでしょうか?

参考文献(本文中で言及がないもののみ)


  1. 例外として、wasmerはEmscriptenでビルドしたWebAssemblyをコマンドラインアプリケーションとして実行することができます。ただいずれにしても、WebAssemblyを動かすアプリケーション側でEmscriptenが提供しているAPIを真似して実装しなければなりません。↩︎
  2. 「あれ?WebAssemblyにはマルチスレッドのためのプロポーザルが別にあったよね?」と思われた方へ: この「Threads Proposal for WebAssembly」は、並行並列処理において必要となるであろう、共有メモリやアトミックなメモリアクセスのための機能のみをWebAssembly本体に追加するための仕様です。実際にスレッドを作ったりするための機能そのものは、WebAssembly本体に搭載されることはありません。というのも、例えばブラウザのUIのように、WebAssemblyが動作する環境の中には、マルチスレッドで動作できないものもあるからです。スレッドを作ったりするための機能そのものは、WebAssemblyを動かすアプリケーション側が提供して、それをWASIを通じてあくまでもAPIとして利用できるようにしよう、と整理されています。そうすることで、例えばブラウザ上でWebAssemblyがスレッドを作るAPIを呼んだ場合、ブラウザはWeb Workerを作るなどして、あたかもWebAssemblyがスレッドを作ることができるように振る舞えるわけです。Emscriptenのドキュメント曰く、実際にEmscriptenはそうしているみたいですね。↩︎
  3. 私がdebパッケージの存在に気づいたのはこれを書いている今で、すでにtarballを展開した後でした…。↩︎
  4. C++の例外処理機構を空の実装でごまかす(あるいは、後述するWebAssemblyの例外機構で実装してみる)のも一つの選択肢ですが、「C++の例外ハンドラを自作してみる。」を読む限り明らかにsetjmp/longjmpをごまかす方が簡単なのでそちらを選択しました。↩︎
  5. もちろん、システムコールを介しているので実際に実行ファイルを読んで_start関数を呼ぶのはOSの仕事です。↩︎

山本 悠滋

2021年10月11日 月曜日

日本Haskellユーザーグループ(愛称 Haskell-jp)発起人の一人にして、Haskell-jpで一番のおしゃべり。 HaskellとWebAssemblyとプリキュアとポムポムプリンをこよなく愛する。

Related
関連記事