Blazor WebAssemblyでマルチスレッドを有効化してUIフリーズを解消する

2026-04-18
Contents

背景と目的

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キャッシュアクセス時間などからデータを推測し窃取するという、サイドチャネル攻撃が可能になるという弱点があります。

この問題を解決するためには、クロスオリジン分離という仕組みが必要になります。

ヘッダ正式名称役割
COOPCross-Origin-Opener-Policy他オリジンのページと同じブラウザプロセスを共有しなくする
COEPCross-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 コンテキストで安全に動作する
プロフィール画像
WRITTEN BY
あきぞら

東京都市大学 情報工学部在籍(2027年卒業予定)。
中学時代よりC#および.NETを用いた個人開発をスタート。現在はWebアプリエンジニアとして活動し、国内大手SaaS企業より内定。 培った技術の活用法から、新卒エンジニアとしてのキャリア形成、生産性を高めるITツールの導入まで、ITに興味のある方や現役エンジニアに役立つバラエティ豊かな情報を発信しています。