WasmLinux: WebブラウザでLinuxカーネルとBusyBoxを動かす(エミュレーションなしで)
WebブラウザでOS動かしてどうすんだよ という根源的な疑問に回答が無いままとりあえずできちゃった。。
※ コマンドが終了してもプロンプトが出ません。Enterを空打ちする必要があります (バグ)
WasmLinuxは、WebAssembly "ネイティブ" なLinux環境です。カーネルもユーザーランドも、WebAssemblyのツールチェインでコンパイルされたWebAssemblyモジュール(をwasm2cでCにしたもの)です。
前回はカーネルしか動いていなかったんですが、今回はブラウザ上で ifconfig lo up
して ping 127.0.0.1
したり top
したり vi
したりできます。BusyBox入ってるので。 ただしまだ実用性は皆無 です。Proof of Conceptって奴ですね。
前回の記事:
今回はMUSL libcを移植してBusyBoxが動くようになった、要するに、 このWebブラウザ上で動くWasmLinux向けのアプリが作れる段階になった というのが更新点です。
SDKとかディストリビューションはそのうち用意しようと思います。ただ、一度作っちゃうとメンテがクッソ大変なので。。
WebVMとかContainer2wasmとは何が違うのか?
この話題は避けて通れないので普段の記事と違って最初に書いておきます。
世間には WebVM や container2wasm のように、既存のCPUをWebAssembly上でカーネルごとエミュレートすることで既存のアプリをそのまま動かそうというソリューションが既にあります。いやまぁ Emscriptenだって昔はARM32向けに生成したLLVM-IRを直接JavaScriptに変換して動いていた んだけど。。
WasmLinuxはこれらと違ってCPUのエミュレーションはせず、 カーネルとユーザーランドの両方をWebAssemblyに直接コンパイルする ことを目指しています。これにより、普通のC/C++アプリケーションをWebAssemblyで動かそうとすると何が足りないのかがより浮き彫りになる。はず。
パフォーマンス的には、ぶっちゃけ大差ないと思います(ブラウザが動くくらい最新のCPUは十分に高速なため)。ただしJITCの段数を減らせるぶん消費電力の面では有利になるかも。
- pros: 一度WasmLinuxに移植すれば、WebAssemblyが動くところならどこでも動く
- cons: Linuxの常識が全く通用しない環境になる ため、どうやっても移植コストはゼロにならないと考えられる -- busyboxはコードの修正は一切不要だったけど、NOMMU環境で動かないアプリはかなり厳しい
- NOMMU環境の厳しさ:
fork
が無い(vfork
のみ)、mmap
でファイルをマップしたり共有メモリを作ったりできない、... - WebAssembly環境の厳しさ: ガチのハーバードアーキテクチャ(命令列がメモリに配置されない)、C言語仕様に死ぬほど厳密、そもそもELFでない、...
- NOMMU環境の厳しさ:
結構consが重いんですよね。。それでも完全に無意味なプロジェクトって程では無いと思っていますが。。(後述)
他に、WASIのPOSIX"風"インターフェースの代わりにLinuxのAPIを直接使えば移植コスト減らせるじゃんという WALI もありますが、WALIは結局動作にLinuxが必要なのでポータビリティの面ではWasmLinuxより一段下がると考えています。Wasm上のLinux ABIという席を争う立場ですね。
やったこと
一応(元)プロなのでやればできる。何のプロだよって感じだけど。。
構成
大まかなプロジェクト構成はデモページ https://wasmlinux-demo.pages.dev/ にも書いていますが、やったことを軸に纏めると:
WasmLinuxは Runner と呼んでいるコンポーネントでカーネルとユーザーランド、外部環境を連携させています。Runnerは普通のC++20アプリで、ブラウザ版はEmscriptenでビルドされます。Runnerの仕事としては:
- Wasm側のインスタンシエート、メモリ確保やスレッド生成等LKLやユーザランドの動作に必要なものの実装
- 一部syscallの実装 -- 主にvforkやclone
- 疑似的なinetdを実装しており、外部からのtelnet接続を受け入れ、WasmLinux環境内でtelnetdを起動してコンソールを提供
といったグルーコード的な役割を果します。
インチキ
デモページの図にもあるように、ブラウザ側のWasmエンジンを直接使わず、LinuxもBusyBoxも 一旦wasm2cでCに変換してから改めてEmscriptenに掛けています 。自分で setjmp/longjmpを実装するのが面倒だったんで。。
あと、このような構成を取ることで、単一のC++プログラムをPC上とブラウザ上の両方で動かすことができてデバッグに有利なんです。。
WASI-libcと同じような実装をすれば、Webブラウザ側のWebAssemblyエンジンで直接実行できるようになるはず。
更に:
- wasm2c が追加するメモリアクセスのbound checkを削除(ブラウザ側のエンジンでも行われるので冗長) -- パフォーマンスよりも.wasmのサイズに効く。これだけで 25MiB → 17MiB になったのでCloudflare Pagesにデモを置けるようになった
- Emscriptenの
std::counting_semaphore
実装が何故か超クッソ激烈に遅かった & Dev toolを開いてる間だけ高速化するという辛い状態だったのでPOSIX semaphoreに置き換え
というワークアラウンドを入れています
Musl libcの移植
足りないヘッダをARM32からコピペして、終わりッ!
... syscall stubは流石に専用のものを実装しています。
あと、Webブラウザの仕様上 pthread_cancel
は実装しようが無いので一旦除いています。Web workerに対して非同期シグナルを送ることはできません。 SIGTERM
で強制的に消すとかは将来実装するかもしれないけど。
前回問題になったような、C関数の引数を適当に増やして呼ぶ(これは未定義挙動)のはMusl libcも同じなのでそこそこの数の対策を入れています。
ユーザースレッド(clone)の実装
clone syscallは Runner側で実装 しています。今のところ fork
APIは実装していないので、Runnerがサポートしているのは pthread_create
等新規スレッドの作成 CLONE_THREAD
のみです。
cloneやforkのようなAPIは普通のC言語関数としては表現できないので、WebAssemblyに持ってくるにはどうしても高レベルな実装を機能別に用意してやる必要があります。なのでカーネル側の実装を直接使うことはできません。
vforkの実装
ブラウザ環境ではMMUを実装できないので、fork
の代わりに vfork
を実装しています。この制約はNOMMU Linux(uClinux)と同じですね。vforkはforkの厳密なサブセットで、プロセスのコピーを伴わないため簡単に実装できます。が、仕様が微妙すぎるので 最新のPOSIXではgetcontextやusleepとかと一緒に廃止 されました。
vforkはGCC拡張であるstatement as expressionとsetjmp/longjmpを使って実現しています。
GCCやClangの statement as expression拡張構文 は、関数型言語のように最後の式が返値となるようなシーケンスを記述できます。C言語マクロではよく do{ ... } while(0);
イディオムが使われますが、それの値を返却できる版ですね。
POSIXではvforkは(というか unistd.h
で宣言されるAPIは全て)Cマクロでも良い[1]ということになっているので、それを利用して:
のようなマクロとしています。ここの run_to_execve
は Runnerに実装されていて 、ユーザーランドのスレッドはそのままに カーネルコンテキストだけ差し替えて 続きを実行し、execve
箇所まで到達したら:
- 子プロセス側はスレッドを作成して executableのエントリポイントを子スレッドで実行する
- 親プロセス側は カーネルコンテキストを元に戻して 、
longjmp
で戻る
という実装になっています。
... ただし、このような setjmp
の使い方は実はCでは違反とされています。 setjmp
の返値を変数にストアするのはダメ です。
まぁTLSとかを経由して渡せば良いのかな多分。
シグナル配送の実装 (不完全)
Webブラウザの仕様上、完全にPOSIX準拠なシグナルは実装できません。というわけで、現時点では syscallの結果をアプリに返す前に、シグナルがあるかチェックして存在する場合はシグナルハンドラを実行する という実装にしています。
これが、冒頭のデモでコマンド終了後にプロンプトが出ない問題に繋っています。つまり、プロセス終了時の SIGCHLD
シグナルで read
が中断されないため、ユーザーが何か入力することでread syscallから返らせないと処理が先に進まなくなってしまっているというわけです(多分)。
LKLの機能ではシグナルを処理できないので、 専用の getsignal
操作を追加しています 。syscallが戻してきた errno
が ERESTART
系 だった場合にRunner側から呼ぶことで、スレッドに対するシグナル配送状態をクリアでき、シグナルハンドラへの関数ポインタを得られるようにしています。
疑似inetdの実装
冒頭のデモのコンソールは、Runnerに内蔵させたtelnetクライアントが出力しています。WasmLinuxシステムにtelnetを掛けるために、inetdに相当する機能をRunner側に持たせることにしました。
RunnerはWin32でも動かしたい & Emscriptenでも動かないといけないということで、専用のI/O抽象化レイヤ(miniio)を用意して:
- POSIXやWin32では libuv(Node.jsと同じI/Oライブラリ)を使用するバックエンド
- Emscripten上ではpthreadを使って実装した ローカル通信専用のバックエンド
をそれぞれリンクすることで、単一のコードで本物のtelnetクライアントのサポートとEmscripten上での擬似的な通信の両方を実現しています。
ちなみに最初に実装したのはlibuvのバックエンドなので、最初の動作確認はPuTTYで行いました。
普通のinetdでは設定ファイルで起動するプロセスを指定できますが、今回の疑似inetdでは起動するプロセスをハードコードしています。
ブラウザ内Telnetクライアントの実装
簡単なTelnetの実装に関してはZennにも良い記事があるんですが、今回はlibtelnetを使った自前の実装 (minitelnet) を用意しました。
MinitelnetはPOSIX風の端末ioctlを使って出力するようにして、xterm-ptyアドオンを使ってxterm-jsに出力しています。xterm-ptyはRubyをブラウザで動かす記事で存在を知りました。
かんそう
カーネルと違ってユーザーランドは、まぁ動いてあたりまえなのであまり苦労は無いかな。。
移植コスト問題
WasmLinuxの設計をするにあたって "Upstreamにメンテナンスコストを掛けさせない" という点を重要視しています。WASIとかWALIはかなり積極的な移植を求めているのに対して、WasmLinuxはBusyBoxを動かすのにソースコードの修正は不要にできた(ただしビルド周りは微調整必要)ので、この点が新しいんじゃないかなと。
この点を推しとかないと、将来Wasm用のLinux ABI標準なりLinuxディストリビューションなりを狙うときに誰が移植のメンテナンスコストを払うのかというのが問題として立ちはだかることになります。Debian/kFreeBSDとか無くなっちゃったし。。
WasmLinuxでDebian/WebAssemblyを狙う ...のは直近では難しいと思いますが、展開の面では意識せざるを得ない所です。残念ながらエコシステムの無いソフトウェアは価値を認められづらいので。WasmLinuxは、少くともEmscriptenやWASIよりはDebianになりやすい。。ようにしていきたいと思います。
というわけで、WasmLinuxの重要なタグラインはデモページにも書いたように Write once(by other folks), run anywhere(i want) です。他人が書いたプログラムを自分が動かしたいところで(苦労なく)動かせるように、システムを設計します。
エミュレーションに勝つには?
既存のアプリを活用するという観点で、WasmLinuxは
- 既存のCPU(x86とかRISC-V)をエミュレーションする
- WASIやEmscripten上に真面目に移植する
といったソリューションとの競争に直面しています。特に前者はWasmLinuxより消費リソースの面を除いて優れているのでどうしたもんかと。。
最もstraightforwardなのは、 WasmLinuxの存在意義はWebAssemblyの進化のためと開き直る ことでしょう。実際、WasmLinuxを突き詰めることで、WebAssemblyに既存のC/C++を移植するために必要な機能性を洗い出していくことが可能だと思います。
あとは、Linuxアプリの中間配付フォーマットとしての立場を狙うことも考えています。つまり、現代は有力なCPUアーキテクチャがx64、arm64、RISC-Vの3種存在するという、ちょっと前までのx64一辺倒の時代から変化しているので、パッケージとして配付後ターゲットCPU形式に 再度 コンパイルできるフォーマットに需要があるのではないか。
単に3回コンパイルしてそれぞれ配付すれば良いじゃん ...というのはごもっともなんですが、WebAssemblyからの再変換とすることでメモリレイアウト等が同一であることを保証できるので、x64で実行していたプロセスをARMで再開する といった、アーキテクチャ混在コンピューティングの可能性[2]を拓けるのではないかと期待しています。例えばAndroidみたいなプラットフォームのNDKにどう。。?ただ、以前AppleはApple WatchやAppleTVのようなIoT製品ではLLVM-IR(bitcode)納品を義務付けてたのに止めちゃったので、あんまり流行らないのかな。。
更に グラフィックスAPIのバインディングを用意する のも強い気がしていて、特に前Unity WebGLをネイティブ移植したとき に作成したWebGLのC言語バインディングをWasmLinuxに持ってこれると、そのEGL/OpenGL ES2スタブを使って既存のOpenGL ES2なアプリを移植できるようになります。 ...いやまぁEmscriptenでもできるんですけど、WasmLinuxはEmscriptenよりはLinuxらしい環境なので。。エミュレーターでも原理的には可能ですが、たぶんそういうところに真面目に取り組むところは居ないんじゃないかなと予想。
次の一手
前回の記事では次の一手をBusyBoxの実行に定め、今回はそれを達成しました。次の目標としては、コンパイラ + CMake + LLDB を動かして、マイコンの開発環境をWeb上に再現したい所。ただ、それはちょっと遠いので、最初は素のSDL2を動かす(OpenGL ES2やOpenALのバインディングを用意する)ところまで行けないもんかなと。
Discussion