Blazor WebAssemblyでマルチスレッドを有効化してUIフリーズを解消する
2026-04-18
背景と目的
PICOMでは、音を生成しながらピアノロールなどGUIの描画処理を走らせる必要があります。しかし、単純に非同期処理をするだけでは、厳密なマルチスレッド処理にはならず、UIスレッドで音楽の生成をしてしまいます。その結果、音楽生成中にUIがフリーズしていた問題が発生しました。
そこで、WasmEnableThreads を有効化し、音楽生成をバックグラウンドスレッドにオフロードすることで、UI描画と音楽生成を分離する対策を行いました。
Blazor WebAssembly の WasmEnableThreads を有効化し、Channel<T> による Producer-Consumer パターンで音声のストリーミング再生を実現しました。COOP/COEP ヘッダーの設定や、System.Timers.Timer から PeriodicTimer への移行で踏んだ不具合の解決も紹介します。
前提環境
- Blazor WebAssembly
- .NET 10.0
- 音声ライブラリ: SoundMaker v3.0.0(自作の音生成ライブラリです!)
WasmEnableThreads の有効化
まず、.csproj ファイルに以下を追加します。
<WasmEnableThreads>true</WasmEnableThreads>
これにより、Task.Run でバックグラウンドスレッドへの処理オフロードが可能になります。
注意点1:COOP/COEP ヘッダーが必要
ブラウザの SharedArrayBuffer を使用するため、COOP/COEP ヘッダーが必要となります。
このヘッダの指定方法はデプロイ先によって変わるため、本記事では設定方法を割愛します。
マルチスレッド環境下でサイドチャネル攻撃を防ぐには
マルチスレッドプログラミングを実現するには、スレッド間でメモリを共有する必要があります。それを実現する具体的な機能が、SharedArrayBufferというものです。非常に便利な機能なのですが、バックグラウンドでタイマーを実行できるようになるので高精度なタイマーが利用できるようになります。これを悪用し、CPUキャッシュアクセス時間などからデータを推測し窃取するという、サイドチャネル攻撃が可能になるという弱点があります。
この問題を解決するためには、クロスオリジン分離という仕組みが必要になります。
| ヘッダ | 正式名称 | 役割 |
|---|---|---|
| COOP | Cross-Origin-Opener-Policy | 他オリジンのページと同じブラウザプロセスを共有しなくする |
| COEP | Cross-Origin-Embedder-Policy | 他オリジンのリソースを読み込む際にCORSやCORPヘッダを必須にする |
この2つの仕組みを組み合わせることで、「他オリジンのプロセスと物理的に遮断した上で、かつリソースの読み込みに制限をかける」ことができるので、仮に高精度なタイマーを使って攻撃しようとしても、そもそも他オリジンの情報を盗むことが不可能になるという仕組みです。
注意点2:ワークロードのインストールが必要
デフォルトでは有効になっていないため、以下のコマンドでワークロードのインストールが必要です。
dotnet workload install wasm-tools
開発用サーバープロジェクトの追加
Blazor WASM の DevServer は COOP/COEP ヘッダーを自動付与しないため、ASP.NET Core のホストプロジェクトを新規作成しました。そういう設定もありそうなのですが、一旦これでいいかということで妥協しています。
実装例を示します。
// Program.cs
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.Use(async (context, next) =>
{
context.Response.Headers["Cross-Origin-Opener-Policy"] = "same-origin";
context.Response.Headers["Cross-Origin-Embedder-Policy"] = "require-corp";
await next();
});
app.UseBlazorFrameworkFiles();
app.UseStaticFiles();
app.MapFallbackToFile("index.html");
app.Run();
肝となる部分はレスポンスヘッダを設定している処理ですね。ここで、クロスオリジン分離を宣言しています。
音楽生成のバックグラウンドスレッド化
バックグラウンドスレッドで実行すること自体は、普段通りasync/awaitで実装することで実現できます。したがって、ここまで設定できれば普段通り非同期処理を実装するだけとなります。
再生処理ではChannel<T>を利用する
肝心の再生処理です。Channel
を使った
Producer-Consumer パターン
で、バックグラウンド生成しつつストリーミング再生を維持するということをしました。
そもそもなぜストリーミングを使っているか?
単純な話、楽曲の音声を全て生成してから再生しようとすると、再生が始まるまで時間がかかるからです。 少し生成→再生→さらに生成→再生というのをスレッドを分けて実現しようというのが主旨となります。
Producer側の実装
public (ChannelReader<byte[]> Reader, Task GenerationTask) GetSoundsForPlayStreaming(
int seekIndex, CancellationToken cancellationToken = default)
{
// UIスレッドでスナップショット取得
var tempo = EditingMusic.Value.Settings.Tempo;
var trackSnapshots = EditingMusic.Value.Tracks.Select(t => t.Clone()).ToList();
var channel = Channel.CreateBounded<byte[]>(new BoundedChannelOptions(2)
{
FullMode = BoundedChannelFullMode.Wait
});
// (Producer)バックグラウンドで音声をストリーミング生成する
var task = Task.Run(async () =>
{
try
{
foreach (var buffer in SoundGenerator.GetSoundsForPlay(seekIndex, tempo, trackSnapshots))
{
cancellationToken.ThrowIfCancellationRequested();
// ここでチャンネルに書き込む Producer -> Consumer
await channel.Writer.WriteAsync(buffer, cancellationToken);
}
}
finally
{
channel.Writer.Complete();
}
}, cancellationToken);
return (channel.Reader, task);
}
Consumer側の実装
var (reader, generationTask) = Store.GetSoundsForPlayStreaming(Store.SeekIndex, cancellationToken);
// reader経由でチャンネルの内容を取得する
await foreach (var sound in reader.ReadAllAsync(cancellationToken))
{
await App.Instance.PlaySoundAsync(sound);
}
await generationTask;
このように、簡単にバックグラウンドスレッドからUIスレッドへのストリーミングを実現することができます。この機能自体は.NET標準なのでBlazor以外でも使うことができます。
別の不具合が発生!AutoSaver のスレッド安全性修正
再生処理は簡単に改善できましたが、PICOMに搭載していた自動セーブ機能で用いていたタイマーが壊れる不具合が発生しました。
原因は、WasmEnableThreads により System.Timers.Timer のコールバックが実際のバックグラウンドスレッドで実行されるようになり、PropertyChanged イベントチェーンがバックグラウンドから発火して Blazor のレンダリング競合を引き起こしたというものです。UIの更新はUIスレッドで実行しないといけないという制約の影響となります。
修正方法は、 System.Timers.Timer から PeriodicTimer に変更するだけです。
// Before: System.Timers.Timer(コールバックがスレッドプールで実行される)
private async void TimerCallback(object sender, ElapsedEventArgs e)
{
await _saveAction.Invoke();
}
// After: PeriodicTimer(async コンテキストで安全に実行)
private async Task RunAsync(CancellationToken cancellationToken)
{
while (await _timer.WaitForNextTickAsync(cancellationToken))
{
await _saveAction.Invoke();
}
}
WasmEnableThreadsを有効化したメリット・デメリット
何よりも動作が圧倒的に早くなったという点があります。これまでバックグラウンドで音声を生成している際にUIの動作が明らかにカクついていたのですが、これが完全に改善されました。このおかげでUIの描画やデザインを考える際にビジネスロジックのリソースなどを気にせずに実装できるようになりました。
デメリットは今のところ大きくは感じていませんが、まだ歴史の浅い機能ということや、マルチスレッド化によってプログラムが複雑化するという問題はあると思います。コードの秩序を保つためにもどのような仕組みかは文書化しておくなど開発工程的な部分で工夫が必要だと思います(今回の記事もこれが目的です)。
まとめ
- Blazor WebAssembly の
WasmEnableThreadsを有効化すれば、Task.Runで本物のバックグラウンドスレッドが使える SharedArrayBufferを使うため COOP/COEP ヘッダーが必須。開発環境では ASP.NET Core ホストプロジェクトでミドルウェアから設定するのが手軽Channel<T>の Producer-Consumer パターンで、バックグラウンド生成とストリーミング再生を自然に分離できるSystem.Timers.Timerはスレッドプールでコールバックが走るため、WasmEnableThreads有効化後に Blazor のレンダリング競合を起こす。PeriodicTimerに置き換えれば async コンテキストで安全に動作する


