この記事は.NET6アドベントカレンダー24日目の記事です。(4時間遅刻)
三行で説明すると?
- Blazor WebAssemblyを使えばC#コンパイラとランタイムがブラウザ上で動きます
- ただしWebゆえの制約も多く、基本シングルスレッドモデルなのが厳しい
- 想像以上に実装が大変だったのでこれから機能増やします
はじめに
ここまでの開発を振り返りながら物語調で語りつつ、詰まったところと解決策を書いていきます。
Blazor WebAssemblyは何がすごい?
以前からWEBアプリも書いてみたい!と思ってはいたのですが、ASP.NET coreでのWeb開発は「サーバーサイドでC#コードを動かし、Webページを生成する」といった感じのものでした。ローカルで動くアプリケーションを勉強で作ってはみたのですが、
- あくまでページを生成して送りつける、といった感じなので、WPFに慣れている身としては狭さを感じてしまう。
- C#を動かせるサーバーでホスティングする必要がある。安いレンタルサーバーでは無理なのでホスティング費用が多少掛かる。
というのがネックで結局やっていませんでした。
しかし、WebAssemblyという.NETで言うところのILのような仮想アセンブリをブラウザ上で動かす技術・規格を活用し、ブラウザ上でC#コードを実行できる技術が登場しました。WebAssembly自体は標準規格なのでIEを除くほぼ全てのブラウザで実行できるのが目新しいですね。実際
- WPF開発のごとくC#コードとUIをリアルタイムで連携できる。双方向バインディング可能。
- 静的ホスティングで済むので、GitHub Pagesなどでホスティング可能。0円スタートなので経済的。
というのがかなりいい感じです。
きっかけ
9月末にプレゼンのネタにしたくてBlazor WebAssemblyに入門し天気予報アプリを作ったときに...
「これブラウザ上でC#コンパイラ動きそうな気がする」と思ったのが出発点でした。もはや4ヶ月以上前の話ですね。
今のC#コンパイラ(Roslyn)はそれ自体がC#で記述されているので、ブラウザ上でC#コードが動くということはコンパイラも動くかもというわけですね。
コンパイラが動くならブラウザ上に開発環境を作り、そしてC#学習サービスを作りC#erを増やす!という壮大な計画がスタートしました。
作ってみる
コンパイラ部分の実装
NuGetパッケージマネージャから Microsoft.CodeAnalysis.CSharp
パッケージを導入します。
これでRosyln Compiler Platformが提供する様々な機能(コンパイル、構文解析...)が使えます。とりあえず使うのはコンパイルのみですが。
SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(code);
まず構文木を作ります。
var compileOption = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary);
var compile = CSharpCompilation.Create("__HogeAssembly", new[] { syntaxTree, injectCode }, metadataReferences, compileOption);
予めDLLを読み込み metadataReferences
を作成し、コンパイルオプションを compileOption
に詰めます。
最後に Emit
メソッドを呼び、コンパイル結果を Stream
に出力します。WebアプリではファイルIOが原則できませんが、出力先は Stream
として抽象化されているので MemoryStream
に書き込ませることができます。
memoryStream.Seek(0, SeekOrigin.Begin);
Assembly asm = AssemblyLoadContext.Default.LoadFromStream(memoryStream);
Seek
して位置を戻し忘れないように(2敗)しましょう。これで Assembly
が得られ、リフレクションで実行できます。
試す
本当にWebの世界でユーザーコードをコンパイルしたり実行したりできるのか不安でしたが、実際にコンパイルと実行をすることができました。
しかし、コンパイルにはそれなりに時間がかかりますし、ユーザーコードの実行も時間が掛かる...どころか無限に終わらない可能性すらあります。
このような処理は別スレッドに回したいところです。
いつものようにTask.Run()
して...あれ?普通にUIがフリーズするぞ?
Webの世界はシングルスレッド
シングルスレッドの問題点
Webの世界はシングルスレッドです。Blazor WebAssemblyではJSとWasmがともにただ1つのスレッドの上で動いています。
たしかに Task
を await
することはでき、内容によっては非同期処理できているように見えます。しかし、ネットワークIO待ちのときに自主的にスレッド利用権を返すことでの並列的な処理はできても、CPU主体の処理はシングルスレッドな以上並列にはなりません。
また、協調的なマルチタスクはできてもプリエンプティブなマルチタスクにはならないため、ユーザーコードの実行をアプリケーション側から止める手段がありません。
これでは
- コンパイルの間UIかフリーズ
- ユーザーコード実行中もUIがフリーズ
- ユーザーコードが無限ループだった場合アプリケーション自体が制御不能になる
と、とてもまともな開発環境とは言えない状態になってしまいます。
Web Workers API
Webアプリの世界がシングルスレッド設計なので、Blazorの上でどれだけ頑張っても解決しません。Webの世界でマルチスレッドをするには、JSからWeb Workers APIというAPIを利用します。これでJavascriptをマルチスレッドで実行できます。ただし、このAPIでは安全性を確保するために作成したWorker(スレッド)同士は厳密に分離されており
- メモリ領域は原則共有不可。分離環境で実行され、またデータもコピーして渡すか所有権を渡すかしかできない。
- Worker作成元とWorkerの間での非同期での相互通信しか提供される機能がない。
と、制約がつきます。このAPIを用いればマルチスレッドのWebアプリケーションが作成できそうです。幸いにも、このAPIを利用してC#コードをマルチスレッドで実行できるライブラリがあります。
これを使えば前述の問題は解消し、開発環境が作れそうです。これで一応のプロトタイプができたのが2021年9月末でした。
そして.NET6がやってきた
.NET6での劇的なパフォーマンス改善
.NET6では色々な新機能が出ましたが、個人的に一番インパクトが大きいのがBlazor WebAssemblyでのパフォーマンス改善です。最適化やAOTコンパイルが実装され、CPU主体の処理が大きく高速化されました。これは今回の開発環境作成のようなプロジェクトでは見逃せない点です。実際にパフォーマンスを測ったのが以下の記事です。
結論として単純な数学計算で3倍くらいの効果です。他にもバイト配列の受け渡しが効率化され、従来のBase64変換を介さずにJSにバイト配列が渡せるようになりました。この効果も測定してみました。
こちらはマイクロベンチマークですが200倍の差です。
というわけで早速.NET5 から .NET6 へ移行しましょう。
.NET6では BlazorWorker
ライブラリが動作しない...
本記事執筆時点では、前述の救世主であった BlazorWorker
ライブラリが.NET6に対応していません。リポジトリのissueを超意訳すると
「.NET6でランタイムの仕様が変わったっぽくて動かんわ」
とのことです。どうやらまずライブラリをいじって.NET6対応にしなければいけないようです。
車輪の再発明
再発明を決意する
ということでライブラリのソースコードを眺めていたのですが、
- パフォーマンスがよくない
- ソースコードがわかりにくい(個人の感想です
と思ったので車輪の再発明をすることにしました。基本的にやることは以下の3つです。
- Web Workers APIをラップする
- Workerの上に自力でC#ランタイムを立ち上げる
- 親とワーカーの間の通信を提供する
C#ランタイムを自力で立ち上げる方法はどこにも解説されていないのでソースコードを見て自力で頑張ります。
GitHubでソースコードを見て模倣します。例のBlazorWorkerライブラリも、もちろん参照します。
ランタイムを立ち上げるための苦労と知見も記事にしたいのですが、今回は脱線するので省略します。
高速化
Cache API
Cache APIを介してリソースを取得することで高速化が期待できます。fetchしても結局キャッシュに当たるという場面でもCache APIのほうが速いです。C#ランタイムはBlazor WebAssemblyアプリケーションの起動時に自動的にキャッシュされるので、ほぼ100%の確率でCache APIにリソースがあります。
Transferable
Web Workers APIでは、一般の型はコピーして渡されますが Transferable
なオブジェクトはコピーではなく転送されます。
ArrayBuffer
が代表例です。転送とすることでパフォーマンスが改善できますので、これを軸にしましょう。
JSとC#でのメモリ共有
JSからはC#ランタイムの全メモリが参照および書き換えできます。このことを活用することでコピーを回避した相互運用が可能です。
具体的にはC#ランタイムが載ったJSで wasmMemory.buffer
を参照します。これでC#のメモリ領域が ArrayBuffer
として扱えます。
動的コード生成
ワーカーでメソッドを実行するときに毎回リフレクションを発動するのではなく動的コード生成をしてキャッシュします。
IL生成という 黒魔術 高速化テクニックにより、ワーカーでのメソッド呼び出しのコストを極限まで減らします。
結果
WasmのC#ランタイムを直接扱うという完全サポート外のシナリオを行くことになるため、デバッグが大変むずかしいです。
むずかしいというか無理に近いです、常に画面に文句を言いながらデバッグしています。
こんな感じでエラーの内容も発生箇所もスタックトレースも何もわかりません。エラーが発生したという1bitで済みそうな情報だけがわかります。
そのため執筆時点では、まだいくつかのバグが解決しておらず、実装できていない機能もあります。
とはいえ、主要な機能である別スレッドでのメソッド呼び出しは実行できます。一応リポジトリ載っけておきます。
速度
ワーカー起動
Environment | Time |
---|---|
Blazor Task | 235.7ms |
Blazor worker | 1199.3ms |
既存のライブラリより最大で4倍程度速いです。これは.NET6対応による最適化と、Cache APIの利用が寄与しています。
シンプルなメソッドの呼び出し
Environment | First | N=100 | N=256 |
---|---|---|---|
Blazor Task | 88.9ms | 69.9ms | 174.2ms |
Blazor worker | 644.0ms | 1953.3ms | 4951.3ms |
整数の和を求めるメソッドを呼び出すのに掛かる時間を測定します。こちらのテストでは最大20倍程度早くなっています。
不要なコピーを徹底的に回避しているほか、System.Text.Json
名前空間にある新しいシリアライザを活用しUTF-8バイナリ入出力をするなど最適化を尽くしています。
コンソール入出力
コンソールの実装
ユーザーコードが別スレッドでコンパイル・実行できるようになったので、コンソールへの入出力を実装します。
ユーザーコードはワーカーで実行されるため、入出力の要求をUIスレッドに伝播して返します。
標準入出力をリダイレクトすれば、Console.WriteLine
や Console.ReadLine
の先で何が起こるかを変更できるので、ここでうまいこと処理しましょう。UIスレッドに要求を伝播し、HTMLで実装された仮想コンソールでユーザーコードに入力を与えたり出力を受け取ったりするのが目標です。
非同期の壁
しかし、Workerとの通信には非同期APIしか用意されていないのです。もちろん Console.ReadLine
は同期メソッドですので、その内部実装もまた同期でないといけません。しかし、
- 各Worker内ではやはりシングルスレッドなので、
Task.Wait()
はデッドロック確定。 - Worker間で共有オブジェクトは持てないので、ポーリングして待つのも無理。
となかなかに手詰まりな状況が待っています。
ServiceWorker + XmlHttpRequest 戦略
なんとかしてJSで非同期処理を同期待ちしたい。諦めかけていたとき、そんな不可能を可能にしているOSSを見つけました。
で、丁寧に動作原理まで解説されています。
簡単に説明すると、
- XHRでは同期的にネットワーク越しにファイルを取ってくるAPIが残っている。
- Service Workerはすべてのネットワークリクエストに干渉できる。保留にしたり、結果を書き換えたりできる。
- Service Workerは非同期処理が可能である。
という性質を利用し、
- ワーカースレッドはUIスレッドに入出力の要求を投げる(待てないので投げっぱなし)。その後ダミーのURLのへの要求をXHRで同期的に待つ。
- Service WorkerはダミーURLへの要求を検知したら保留にする。
- UIスレッドはユーザーの入力をService Workerに伝達する。
- Service Workerはユーザーの入力を、ダミーURLが返すレスポンスの本文に設定する(改ざん)。
- ワーカースレッドはダミーURLへのリクエストの結果としてユーザーの入力を同期的に受け取ることに成功する。
という流れをとります。
実装
Blazor WebAssemblyで得られるIJSRuntime
を実装した型はIJSInProcessRuntime
も実装し、同期的にjavascriptを実行可能です。
これで同期的にユーザーの入力をユーザーコードに伝達できます。めでたしめでたし。
UIの実装
最後にUIを作ります。コードエディタにはJSのライブラリを活用します。Blazor WebAssemblyではJSのライブラリを普通に使えるので便利です。
使ったのはAtCoderでもおなじみのCodeMirrorというライブラリです。
最後に他のUI要素を組みます。レイアウトに関しては、CSS Grid Layoutで組むのが本当におすすめです。
仕上がり
こんな感じに仕上がりました。まだエディタ機能の作り込みまでできていないので最小限ですが、仮想コンソールを備え入出力が可能です。
執筆時点ではバグが大量にあることが予想されますが、温かい目で見守ってissueを下さい。
試したい方のためにデプロイ先を貼っておきますが、今後も開発を継続するつもりなので記事とはぜんぜん違う or バグで動作しない可能性もあることを断っておきます。
今後実装したい機能
①高度なエディタ機能
せっかくRosylnコンパイラが載っているので、コードのフォーマットや自動補完などを盛り込みたいです。
自分が使ってもよいと思えるようなエディタにするのが目標です。自分が使いたいと思えるものを作るのは結構大事。
②C#入門&ジャッジ機能
AtCoderのようなジャッジ機能を実装したいです。競プロというわけではなく、基本的なC#文法を採点付きで学べるといいかなと。ホスティング費用が現状掛かっていないので、ジャッジ機能を実装しつつもオープンソース&完全無料でC#が学べるサービスが作れそうで、なかなかいい方向性だと思っています。