Blazor Serverの@codeブロックはスレッド増殖する?SignalRとCircuitで分かる実行モデルと最適化

Blazor Server(Blazor Web App のサーバー対話モード)を触っていると、「.razor の @code はクライアントごとにスレッドが立って動いているの?」という疑問がよく出ます。結論は“増えるのは接続と状態であって、スレッドが1対1で増えるわけではない”です。本記事では SignalR・Circuit・同期/非同期の観点から、実行モデルとスケールさせる設計ポイントを具体例付きで整理します。

目次

結論:Blazor Server の @code は「クライアント数だけスレッドが増える」仕組みではない

Blazor Server のコンポーネント(.razor)に書く @code ブロックは、クリックや入力変更などのイベントに反応してサーバー側で実行されます。しかしこれは、クライアントごとに専用の OS スレッドを作って常駐させるモデルではありません。

  • 固定スレッドは持たない:処理は .NET のスレッドプールが担当し、必要なときにスレッドが割り当てられて返却されます。
  • 安全性は「直列化」で担保:同じユーザー(同じ Circuit)内の UI 更新が競合しにくいよう、フレームワークが実行を順番に流す仕組みを持ちます。
  • 詰まりやすいのは「同期ブロック」:スレッドが増えない代わりに、同期的に止める実装があるとスレッドプールが枯渇しやすく、同時接続で急に遅くなります。

以降は、「何が増えて、どこで直列化され、どこで詰まるのか」を、Blazor Server の用語(SignalR / Circuit / Dispatcher)で腹落ちするように説明します。

なぜ「クライアントごとにスレッド」と誤解されやすいのか

誤解が生まれる理由はシンプルで、Blazor Server はサーバー側に UI 状態を持つからです。状態を持つ=何かが常駐していそう、という連想で「専用スレッドがいるのでは?」となりがちです。実際には常駐しているのはスレッドではなく、主に次の要素です。

誤解しがちな対象実際に増えるものどう理解すると正しい?
「1ユーザー=1スレッド」SignalR 接続と Circuit(状態)スレッドは使い回し、状態はユーザーごとに保持
「@code はずっと動いている」イベントが来たときだけ処理が走るイベント駆動+差分レンダリング
「UI の安全性=ロックだらけ」同一 Circuit の更新を直列化“UIスレッドっぽい”実行モデルで競合を減らす

この「状態は保持するが、スレッドは保持しない」が Blazor Server の肝です。

まず押さえるべき:SignalR 接続と Circuit(サーキット)

Blazor Server の基本単位は Circuit です。ざっくり言うと、ブラウザ(タブ)とサーバーの対話セッションで、コンポーネントの状態(フィールド値、DI で注入した Scoped サービスなど)をサーバーのメモリ上に保持します。クライアントとの通信は SignalR を使い、イベント通知や差分レンダリング結果の送受信を行います。

「クライアント1人」と言っても、同じブラウザでタブを2つ開けば Circuit も2つになるのが基本です(タブ単位のセッションとして捉えるのが分かりやすいです)。

増えるものBlazor Server での実体スケール時に効くポイント
接続数SignalR の接続(多くは WebSocket)ネットワーク/接続上限、プロキシや LB の WebSocket 設定、タイムアウト
状態Circuit 内のコンポーネントインスタンス、Scoped サービスなど(サーバーメモリ)1接続あたりのメモリ量、状態を肥大化させない設計
実行コンテキストCircuit ごとの Dispatcher(同期コンテキスト的な直列化)UI 更新の競合を減らす一方、同期ブロックはその Circuit を詰まらせる
スレッド固定ではなくスレッドプールで使い回しブロックするとスレッドプール枯渇→全体が遅くなる

Blazor Server と Blazor WebAssembly の違い(スレッド観点で混同しない)

「スレッドの話」を整理するうえで、Blazor Server と Blazor WebAssembly(WASM)を混同しないのが大切です。同じ “Blazor” でも、どこで @code が動くかが根本的に違います。

観点Blazor ServerBlazor WebAssembly
@code が動く場所サーバー(.NET)ブラウザ(WebAssembly 上の .NET)
通信SignalR でイベント/差分を往復通常の HTTP/API 呼び出し(必要なときだけ)
UI 状態の置き場所サーバーメモリ(Circuit)クライアント側(ブラウザメモリ)
スケールの主戦場サーバーの接続数・メモリ・同期ブロッククライアント性能(端末差)・初回DLサイズ
「大量アクセス」の考え方同時接続が増えるほどサーバー資源が増加サーバーはAPIの負荷中心(UI状態は持たない)

「同時接続が非常に多い」「サーバーに UI 状態を持たせたくない」などの要件が強い場合は、Blazor WASM(または SSR + 最小限の対話)を選ぶ判断もあります。逆に「業務アプリで LAN 内、端末の制約が強い、C# で素早く UI を作りたい」などでは Blazor Server が刺さりやすいです。

内部の流れ:クリックから画面更新まで(スレッド視点)

「スレッドがどう動くか」を掴むには、イベント発生からレンダリングまでの流れを追うのが近道です。

  1. ブラウザでイベント発生(クリック/入力など)
  2. SignalR でサーバーへイベント送信
  3. サーバーはスレッドプール上で受信処理(SignalR のパイプライン)
  4. 該当 Circuit の Dispatcher に「このイベント処理」をポスト
  5. Dispatcher が Circuit 内の処理を順番に実行(直列化)
  6. 状態が変われば差分レンダリングを行い、差分を SignalR で返す

ここで大事なのは、イベントを受け取る瞬間のスレッドと、コンポーネントのコードを実行する瞬間のスレッドは固定ではないという点です。スレッドプールのスレッドは都度割り当てられ、待ちが発生すれば返却されます。だから「クライアント数だけスレッド常駐」にはなりません。

「シングルスレッド的に動く」の正体:SynchronizationContext ではなく Dispatcher と考える

質問でよく出るのが「ASP.NET Core の SynchronizationContext のもとで動いている?」という表現です。ここは誤解ポイントで、ASP.NET Core は既定で古典的な SynchronizationContext(UIアプリのようなもの)を持ちません。一方 Blazor Server は、コンポーネントの状態更新を安全に扱うため、Circuit ごとにDispatcher(同期コンテキスト的な役割)を持ち、処理を直列化します。

結果として、コンポーネントの状態変更やレンダリングは「UI スレッドに近い感覚」で扱えます。ただしそれは、

  • 専用スレッドが1本ある
  • そのスレッドに固定で処理が乗る

という意味ではなく、同一 Circuit の処理を“順番に実行させる仕組みがある”という意味です。スレッドというより「実行キュー」と捉えると、設計判断(重い処理の切り離し等)がしやすくなります。

await を挟むとどうなる?「並列」ではなく「割り込み可能な直列」

Blazor Server は同一 Circuit を直列化しますが、await を挟むと挙動が少し“UIっぽく”なります。イベントハンドラが await で待機に入ると、その間はスレッドを占有しません。その結果、同一 Circuit でも別のイベントが先に処理されることがあります(WinForms/WPF の async と同じ感覚です)。

つまり Blazor Server の実行は、

  • 同時に複数スレッドで走る「並列実行」ではなく
  • 1本のキュー上で、await のタイミングで別の仕事に切り替わる「割り込み可能な直列」

と捉えるのが実用的です。だからこそ、後述する「後から終わった処理が上書きする」問題(検索や連打で起きる)への対策が重要になります。

スレッドが増えない代わりに増えるもの:メモリと状態(ここがスケールの本丸)

「クライアント数=スレッド数」ではない一方で、クライアント数が増えると確実に増えるのが Circuit と状態です。Circuit が持つものを、もう少し具体的に整理します。

Circuit にぶら下がる代表要素増え方起こりやすい問題実務的な対策
コンポーネントインスタンスユーザー/タブごとに保持大きなコレクションや大きい DTO を保持してメモリ増ページング/仮想化、必要最小限の状態、再取得可能な設計
Scoped サービス(DI)Circuit スコープで基本1つScoped が「ユーザー数分」作られる前提を忘れて肥大化重いキャッシュは共有(Singleton)へ、状態は外部(DB/Cache)へ
イベント購読・Timer購読/タイマーがユーザー数分Dispose 漏れでメモリリーク、CPU の無駄な定期処理IDisposable/IAsyncDisposable 実装、購読解除を徹底
接続(SignalR)接続数に比例プロキシ設定不備で切断が増え、再接続が負荷にWebSocket 有効化、適切なタイムアウト、切断時のUX設計

負荷試験で「CPU が余っているのに遅い」「ピークで突然落ちる」場合、スレッドより先にメモリと接続が効いていることが多いです。スレッドモデルを理解すると、ここを優先して改善できるようになります。

やってはいけない:同期ブロックが「その Circuit」を止め、やがて全体も遅くする

Blazor Server のイベント処理は Circuit 内で直列化されます。ここでイベントハンドラの中で同期ブロック(スレッドを止める処理)をすると、その Circuit の処理列が詰まり、画面が固まったように見える現象が起きやすくなります。

よくあるNG何が起きる?推奨アプローチ
Thread.Sleep(...)スレッドを占有し UI 更新も止まるawait Task.Delay(...) に置き換える
Task.Result / Task.Wait()デッドロックや応答停止の原因になりやすい呼び出し元まで async/await を伝播させる
巨大なループ/重い同期計算を直実行同一 Circuit の他操作が全部待たされるCPU バウンドは分離(短いなら Task.Run、重いならキュー化)
同期 I/O(ファイル/DB/HTTP の同期API)スレッドプール枯渇の誘因、全体のスループット低下非同期 API(例:ReadAsync, ExecuteReaderAsync)を使う

ポイントは「ユーザーが増えるほど同期ブロックの痛みが増幅する」ことです。少人数では目立たなくても、同時接続が増えた瞬間にスレッドプールが詰まり、別ユーザーの応答まで遅くなるケースがあります。Blazor Server は“スレッドを大切に使う設計”が、そのままスケーラビリティに直結します。

重い処理の正しい切り離し:async/await と Task.Run の使い分け

Blazor Server を安定運用するには、「I/O バウンド」と「CPU バウンド」を分けて考えるのがコツです。

処理の種類例基本方針ありがちな誤用
I/O バウンドDB/HTTP/ファイル読み書き最初から最後まで非同期APIで await「とりあえず Task.Run」で包む(意味が薄い)
CPU バウンド画像処理、暗号化、巨大な集計UI更新から切り離し、必要なら Task.Run / キュー化同時に投げすぎて CPU 飽和、逆に遅くなる

I/O バウンドは「素直に非同期」

HTTP や DB の待ち時間は、非同期 I/O を使えばスレッドを占有しません。Blazor Server のスケールではここが最重要です。

@code {
    private WeatherForecast[]? forecasts;

    protected override async Task OnInitializedAsync()
    {
        // 非同期I/O:待っている間スレッドを占有しない
        forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("weatherforecast");
    }
}

CPU バウンドは「UI 列車から降ろす」

CPU を食う処理を UI の直列キューに乗せたままだと、同じ Circuit の操作が全部待たされます。短時間で済むならまだしも、数百ms〜数秒以上かかる処理は分離を検討します。

@code {
    private string? result;
    private bool isBusy;

    private async Task OnClickAsync()
    {
        isBusy = true;
        result = null;
        await InvokeAsync(StateHasChanged); // 先に「処理中」を描画

        // CPUバウンド処理をスレッドプールへ(短時間向け)
        var value = await Task.Run(() => ExpensiveCpuWork());

        result = value;
        isBusy = false;
    }

    private string ExpensiveCpuWork()
    {
        Thread.SpinWait(5_000_000);
        return DateTime.Now.ToString("O");
    }
}

注意:Task.Run は「スレッドを増やす魔法」ではありません。同時アクセスが多い環境で乱発すると、CPU 飽和・スレッドプールの過増・コンテキストスイッチ増加で逆効果になり得ます。重い処理が頻繁に走るなら、次の「キュー + バックグラウンド」方式が安定します。

より安定する:キュー + BackgroundService で負荷を平準化する

「ユーザー操作をトリガーに重い処理を走らせたいが、UI は軽く保ちたい」場合、Blazor Server ではキューに積んでバックグラウンドで処理する設計が強いです。UI 側のイベントハンドラは“受付だけしてすぐ返す”のが基本になります。

方式向いているケースメリット注意点
Task.Run短時間のCPU処理、同時数が少ない実装が簡単ピークで不安定になりやすい
キュー + BackgroundService重い処理/長時間処理、同時実行を制御したい負荷を平準化、リトライや監視がしやすい結果通知/進捗管理の設計が必要

概念が伝わる最小例として、Channel を使った「処理依頼キュー」とバックグラウンドワーカーを示します。

using System.Threading.Channels;

public record JobRequest(Guid Id, string Payload);

public class JobQueue
{
    private readonly Channel<JobRequest> _channel = Channel.CreateUnbounded<JobRequest>();

    public ValueTask EnqueueAsync(JobRequest job, CancellationToken ct = default)
        => _channel.Writer.WriteAsync(job, ct);

    public IAsyncEnumerable<JobRequest> DequeueAllAsync(CancellationToken ct)
        => _channel.Reader.ReadAllAsync(ct);
}

public class JobWorker : BackgroundService
{
    private readonly JobQueue _queue;
    public JobWorker(JobQueue queue) => _queue = queue;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var job in _queue.DequeueAllAsync(stoppingToken))
        {
            await ProcessAsync(job, stoppingToken);
        }
    }

    private Task ProcessAsync(JobRequest job, CancellationToken ct)
    {
        // TODO: 実処理(DB/外部API/CPU計算など)
        return Task.Delay(500, ct);
    }
}

UI 側は「キューに積むだけ」に徹します。

@inject JobQueue Queue

@code {
    private async Task StartJobAsync()
    {
        await Queue.EnqueueAsync(new JobRequest(Guid.NewGuid(), "data"));
    }
}

この形にすると、同時アクセスが増えてもバックグラウンド側の並列度を制御しやすく、サーバー全体が破綻しにくくなります。結果通知は、DB に状態を書いてクライアントが取りに来る、SignalR で進捗をプッシュする、など要件に応じて選べます。

スレッド競合は起こらない?それでも実装で注意すべき典型パターン

Blazor Server は同一 Circuit の処理を直列化するため、典型的な「複数スレッドが同時に同じフィールドへ書き込む競合」は起きにくいです。とはいえ、実務で事故りやすいのは次の3つです。

別スレッドから UI 状態を触ってしまう(Timer / イベント / Task.Run など)

タイマーや外部イベント、バックグラウンド処理は UI の実行コンテキスト外で発火することがあります。そのままコンポーネントの状態を触ると例外や不整合の原因になります。UI更新が必要なら InvokeAsync で Circuit 側へ戻すのが基本です。

@code {
    private System.Timers.Timer? _timer;
    private int count;

    protected override void OnInitialized()
    {
        _timer = new System.Timers.Timer(1000);
        _timer.Elapsed += async (_, __) =>
        {
            await InvokeAsync(() =>
            {
                count++;
                StateHasChanged();
            });
        };
        _timer.Start();
    }

    public void Dispose()
    {
        _timer?.Dispose();
    }
}

非同期処理の完了順が逆転し、古い結果で上書きされる

検索ボックスの入力に応じて API を叩くような場面では、遅いリクエストが後から完了し、最新結果を上書きしてしまうことがあります。対策として「前回リクエストのキャンセル」は効果的です。

@code {
    private CancellationTokenSource? _cts;
    private string? keyword;
    private string[] results = Array.Empty<string>();

    private async Task OnKeywordChangedAsync(string value)
    {
        keyword = value;

        _cts?.Cancel();
        _cts?.Dispose();
        _cts = new CancellationTokenSource();

        try
        {
            results = await SearchAsync(keyword, _cts.Token);
        }
        catch (OperationCanceledException)
        {
            // キャンセルは正常系として無視
        }
    }

    private async Task<string[]> SearchAsync(string? key, CancellationToken ct)
    {
        await Task.Delay(300, ct); // 例:疑似I/O
        return string.IsNullOrWhiteSpace(key)
            ? Array.Empty<string>()
            : new[] { $"hit: {key}" };
    }
}

Dispose 漏れで「ユーザー数分」リークする

Blazor Server はユーザー数分の状態を持つため、Dispose 漏れが接続数に比例して効いてきます。Timer、イベント購読、IDbContextFactory ではない DbContext の保持、外部リソース(ソケット/ファイル)などは特に注意です。コンポーネントでリソースを持つ場合は、IDisposable / IAsyncDisposable を実装し、確実に解放しましょう。

大量アクセスを想定した設計チェックリスト(そのままレビューに使える)

  • 同期ブロック禁止:Thread.Sleep、.Result、.Wait()、同期I/O を排除
  • 非同期 I/O 徹底:DB/HTTP/ファイルは async API に統一し、呼び出し元まで async/await を伝播
  • 状態は軽く:巨大リストはページング/仮想化、バイナリや巨大DTOを保持しない
  • Scoped の肥大化注意:ユーザー数分のメモリになる前提で設計(キャッシュは共有/外部化)
  • 長時間処理はキュー化:バックグラウンドへ逃がし、同時実行数を制御
  • UI 更新は Dispatcher へ:Timer/イベント/バックグラウンドから触るなら InvokeAsync
  • 切断・再接続の前提を持つ:ネットワーク瞬断は起きる。UX(再試行/再読み込み/通知)を含めて設計
  • スケールアウトは「状態をどこに置くか」から:Blazor Server はサーバーに状態を持つため、ロードバランサ配下ではセッションアフィニティ(同一ユーザーを同一サーバーへ)を前提に設計し、構成(SignalR の扱い)を確認

切断時の Circuit 保持を調整したい場合(設定例)

一時的な回線切断(Wi-Fi 切り替え等)があると、再接続を許容するために Circuit を一定時間保持します。ただし保持しすぎるとメモリを圧迫します。環境に合わせて保持時間や保持数を調整したい場合は、CircuitOptions を設定します。

using Microsoft.AspNetCore.Components.Server.Circuits;

builder.Services.Configure<CircuitOptions>(options =>
{
    // 例:切断後に Circuit を保持する時間
    options.DisconnectedCircuitRetentionPeriod = TimeSpan.FromMinutes(3);

    // 例:保持する切断 Circuit の最大数
    options.DisconnectedCircuitMaxRetained = 100;
});

ここは「短くすれば良い」ではなく、ユーザー体験(再接続のしやすさ)とサーバー資源(メモリ)のトレードオフです。想定同時接続・回線環境・運用ポリシーに合わせて決めるのが現実的です。

よくある質問(FAQ)

同時に複数のイベントが来たら、本当に順番に処理される?

同一 Circuit 内では、状態更新とレンダリングが破綻しないよう基本的に直列で処理されます。ただし await を挟むと、その待機中に別イベントが先に処理されることがあります。並列スレッドで同時に走るというより、キュー上で実行が切り替わるイメージです。

ConfigureAwait(false) は使っていい?

ライブラリ層では有効なことがありますが、コンポーネント側で安易に使うと「UI更新が必要なタイミングで UI の実行コンテキストに戻らない」状況を作りやすいです。UI(コンポーネント)層では基本は素直に await し、必要なら InvokeAsync で戻す、という方針が安全です。

結局、@code の処理が重くても他ユーザーには影響しない?

まず「その Circuit の画面が固まる」影響は本人に出ますが、同期ブロックや同期I/O が積み重なるとスレッドプールを圧迫し、結果として他ユーザーの応答にも波及する可能性があります。多人数を想定するなら、UIをブロックしない実装(非同期I/O、キュー化、処理の分離)が重要です。

まとめ:@code は「スレッド」ではなく「Circuit の実行モデル」で理解すると設計が楽になる

Blazor Server の @code は「クライアント数だけスレッドが増える」仕組みではありません。増えるのは Circuit(接続と状態)であり、実行自体は .NET のスレッドプールが担います。一方で、コンポーネントの状態更新は Circuit 単位で直列化されるため、UIスレッドに近い感覚で扱えます。

この特性を踏まえ、同期ブロックを避け、非同期 I/O を徹底し、重い処理は分離・キュー化する。これだけで、同時接続が増えたときの“詰まり方”が大きく変わります。Blazor Server を本番で伸ばすなら、スレッド数ではなく「接続・状態・直列化」を中心に設計を見直すのが近道です。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次