ASP.NET Core Blazor の同期コンテキスト

Note

これは、この記事の最新バージョンではありません。 現在のリリースについては、この記事の .NET 8 バージョンを参照してください。

重要

この情報はリリース前の製品に関する事項であり、正式版がリリースされるまでに大幅に変更される可能性があります。 Microsoft はここに示されている情報について、明示か黙示かを問わず、一切保証しません。

現在のリリースについては、この記事の .NET 8 バージョンを参照してください。

Blazor では、同期コンテキスト (SynchronizationContext) を使用して、1 つの実行の論理スレッドを強制します。 コンポーネントのライフサイクル メソッドと、Blazor によって発生するイベント コールバックは、同期コンテキストで実行されます。

Blazor のサーバー側の同期コンテキストでは、ブラウザーの WebAssembly モデル (シングル スレッド) と厳密に一致するように、シングルスレッド環境のエミュレートが試行されます。 このエミュレーションのスコープは個々の回線のみです。つまり、2 つの異なる回線を並列で実行できます。 回線内のどの時点でも、作業は 1 つのスレッドでのみ実行され、1 つの論理スレッドであるという印象になります。 同じ回線内で 2 つの操作は同時に実行されません。

スレッドをブロックする呼び出しを避ける

一般に、コンポーネントでは次のメソッドは呼び出さないでください。 次のメソッドでは実行スレッドがブロックされます。そのため、基になる Task が完了するまで、アプリの動作が再開されなくなります。

Note

このセクションに示されているスレッド ブロック メソッドを使用する Blazor ドキュメントの例では、推奨されるコーディング ガイダンスとしてではなく、デモンストレーション目的でのみメソッドを使用しています。 たとえば、いくつかのコンポーネント コードのデモでは、Thread.Sleep を呼び出して、実行時間の長いプロセスをシミュレートします。

状態を更新するために外部でコンポーネント メソッドを呼び出す

タイマーやその他の通知などの外部イベントに基づいてコンポーネントを更新する必要がある場合は、InvokeAsync メソッドを使用します。これにより、Blazor の同期コンテキストにコードの実行がディスパッチされます。 たとえば、リッスンしているコンポーネントに、更新状態について通知できる次の ''通知サービス'' を考えてみます。 Update メソッドは、アプリ内のどこからでも呼び出すことができます。

TimerService.cs:

namespace BlazorSample;

public class TimerService(NotifierService notifier, 
    ILogger<TimerService> logger) : IDisposable
{
    private int elapsedCount;
    private readonly static TimeSpan heartbeatTickRate = TimeSpan.FromSeconds(5);
    private readonly ILogger<TimerService> logger = logger;
    private readonly NotifierService notifier = notifier;
    private PeriodicTimer? timer;

    public async Task Start()
    {
        if (timer is null)
        {
            timer = new(heartbeatTickRate);
            logger.LogInformation("Started");

            using (timer)
            {
                while (await timer.WaitForNextTickAsync())
                {
                    elapsedCount += 1;
                    await notifier.Update("elapsedCount", elapsedCount);
                    logger.LogInformation("ElapsedCount {Count}", elapsedCount);
                }
            }
        }
    }

    public void Dispose()
    {
        timer?.Dispose();

        // The following prevents derived types that introduce a
        // finalizer from needing to re-implement IDisposable.
        GC.SuppressFinalize(this);
    }
}
public class TimerService : IDisposable
{
    private int elapsedCount;
    private readonly static TimeSpan heartbeatTickRate = TimeSpan.FromSeconds(5);
    private readonly ILogger<TimerService> logger;
    private readonly NotifierService notifier;
    private PeriodicTimer? timer;

    public TimerService(NotifierService notifier,
        ILogger<TimerService> logger)
    {
        this.notifier = notifier;
        this.logger = logger;
    }

    public async Task Start()
    {
        if (timer is null)
        {
            timer = new(heartbeatTickRate);
            logger.LogInformation("Started");

            using (timer)
            {
                while (await timer.WaitForNextTickAsync())
                {
                    elapsedCount += 1;
                    await notifier.Update("elapsedCount", elapsedCount);
                    logger.LogInformation($"elapsedCount: {elapsedCount}");
                }
            }
        }
    }

    public void Dispose()
    {
        timer?.Dispose();
    }
}
public class TimerService : IDisposable
{
    private int elapsedCount;
    private readonly static TimeSpan heartbeatTickRate = TimeSpan.FromSeconds(5);
    private readonly ILogger<TimerService> logger;
    private readonly NotifierService notifier;
    private PeriodicTimer? timer;

    public TimerService(NotifierService notifier,
        ILogger<TimerService> logger)
    {
        this.notifier = notifier;
        this.logger = logger;
    }

    public async Task Start()
    {
        if (timer is null)
        {
            timer = new(heartbeatTickRate);
            logger.LogInformation("Started");

            using (timer)
            {
                while (await timer.WaitForNextTickAsync())
                {
                    elapsedCount += 1;
                    await notifier.Update("elapsedCount", elapsedCount);
                    logger.LogInformation($"elapsedCount: {elapsedCount}");
                }
            }
        }
    }

    public void Dispose()
    {
        timer?.Dispose();
    }
}
using System;
using System.Timers;
using Microsoft.Extensions.Logging;

public class TimerService : IDisposable
{
    private int elapsedCount;
    private readonly ILogger<TimerService> logger;
    private readonly NotifierService notifier;
    private Timer timer;

    public TimerService(NotifierService notifier, ILogger<TimerService> logger)
    {
        this.notifier = notifier;
        this.logger = logger;
    }

    public void Start()
    {
        if (timer is null)
        {
            timer = new();
            timer.AutoReset = true;
            timer.Interval = 10000;
            timer.Elapsed += HandleTimer;
            timer.Enabled = true;
            logger.LogInformation("Started");
        }
    }

    private async void HandleTimer(object source, ElapsedEventArgs e)
    {
        elapsedCount += 1;
        await notifier.Update("elapsedCount", elapsedCount);
        logger.LogInformation($"elapsedCount: {elapsedCount}");
    }

    public void Dispose()
    {
        timer?.Dispose();
    }
}
using System;
using System.Timers;
using Microsoft.Extensions.Logging;

public class TimerService : IDisposable
{
    private int elapsedCount;
    private readonly ILogger<TimerService> logger;
    private readonly NotifierService notifier;
    private Timer timer;

    public TimerService(NotifierService notifier, ILogger<TimerService> logger)
    {
        this.notifier = notifier;
        this.logger = logger;
    }

    public void Start()
    {
        if (timer is null)
        {
            timer = new Timer();
            timer.AutoReset = true;
            timer.Interval = 10000;
            timer.Elapsed += HandleTimer;
            timer.Enabled = true;
            logger.LogInformation("Started");
        }
    }

    private async void HandleTimer(object source, ElapsedEventArgs e)
    {
        elapsedCount += 1;
        await notifier.Update("elapsedCount", elapsedCount);
        logger.LogInformation($"elapsedCount: {elapsedCount}");
    }

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

NotifierService.cs:

namespace BlazorSample;

public class NotifierService
{
    public async Task Update(string key, int value)
    {
        if (Notify != null)
        {
            await Notify.Invoke(key, value);
        }
    }

    public event Func<string, int, Task>? Notify;
}
public class NotifierService
{
    public async Task Update(string key, int value)
    {
        if (Notify != null)
        {
            await Notify.Invoke(key, value);
        }
    }

    public event Func<string, int, Task>? Notify;
}
public class NotifierService
{
    public async Task Update(string key, int value)
    {
        if (Notify != null)
        {
            await Notify.Invoke(key, value);
        }
    }

    public event Func<string, int, Task>? Notify;
}
using System;
using System.Threading.Tasks;

public class NotifierService
{
    public async Task Update(string key, int value)
    {
        if (Notify != null)
        {
            await Notify.Invoke(key, value);
        }
    }

    public event Func<string, int, Task> Notify;
}
using System;
using System.Threading.Tasks;

public class NotifierService
{
    public async Task Update(string key, int value)
    {
        if (Notify != null)
        {
            await Notify.Invoke(key, value);
        }
    }

    public event Func<string, int, Task> Notify;
}

サービスを登録します。

  • クライアント側の開発では、サービスをクライアント側の Program ファイルにシングルトンとして登録します。

    builder.Services.AddSingleton<NotifierService>();
    builder.Services.AddSingleton<TimerService>();
    
  • サーバー側の開発では、サービスをサーバー Program ファイルにスコープ済みとして登録します。

    builder.Services.AddScoped<NotifierService>();
    builder.Services.AddScoped<TimerService>();
    

NotifierService を使用して、コンポーネントを更新します。

Notifications.razor:

@page "/notifications"
@implements IDisposable
@inject NotifierService Notifier
@inject TimerService Timer

<PageTitle>Notifications</PageTitle>

<h1>Notifications Example</h1>

<h2>Timer Service</h2>

<button @onclick="StartTimer">Start Timer</button>

<h2>Notifications</h2>

<p>
    Status:
    @if (lastNotification.key is not null)
    {
        <span>@lastNotification.key = @lastNotification.value</span>
    }
    else
    {
        <span>Awaiting notification</span>
    }
</p>

@code {
    private (string key, int value) lastNotification;

    protected override void OnInitialized()
    {
        Notifier.Notify += OnNotify;
    }

    public async Task OnNotify(string key, int value)
    {
        await InvokeAsync(() =>
        {
            lastNotification = (key, value);
            StateHasChanged();
        });
    }

    private void StartTimer()
    {
        _ = Task.Run(Timer.Start);
    }

    public void Dispose() => Notifier.Notify -= OnNotify;
}

ReceiveNotifications.razor:

@page "/receive-notifications"
@implements IDisposable
@inject NotifierService Notifier
@inject TimerService Timer

<h1>Receive Notifications</h1>

<h2>Timer Service</h2>

<button @onclick="StartTimer">Start Timer</button>

<h2>Notifications</h2>

<p>
    Status:
    @if (lastNotification.key is not null)
    {
        <span>@lastNotification.key = @lastNotification.value</span>
    }
    else
    {
        <span>Awaiting notification</span>
    }
</p>

@code {
    private (string key, int value) lastNotification;

    protected override void OnInitialized()
    {
        Notifier.Notify += OnNotify;
    }

    public async Task OnNotify(string key, int value)
    {
        await InvokeAsync(() =>
        {
            lastNotification = (key, value);
            StateHasChanged();
        });
    }

    private void StartTimer()
    {
        _ = Task.Run(Timer.Start);
    }

    public void Dispose()
    {
        Notifier.Notify -= OnNotify;
    }
}

ReceiveNotifications.razor:

@page "/receive-notifications"
@implements IDisposable
@inject NotifierService Notifier
@inject TimerService Timer

<h1>Receive Notifications</h1>

<h2>Timer Service</h2>

<button @onclick="StartTimer">Start Timer</button>

<h2>Notifications</h2>

<p>
    Status:
    @if (lastNotification.key is not null)
    {
        <span>@lastNotification.key = @lastNotification.value</span>
    }
    else
    {
        <span>Awaiting notification</span>
    }
</p>

@code {
    private (string key, int value) lastNotification;

    protected override void OnInitialized()
    {
        Notifier.Notify += OnNotify;
    }

    public async Task OnNotify(string key, int value)
    {
        await InvokeAsync(() =>
        {
            lastNotification = (key, value);
            StateHasChanged();
        });
    }

    private void StartTimer()
    {
        _ = Task.Run(Timer.Start);
    }

    public void Dispose()
    {
        Notifier.Notify -= OnNotify;
    }
}

ReceiveNotifications.razor:

@page "/receive-notifications"
@implements IDisposable
@inject NotifierService Notifier
@inject TimerService Timer

<h1>Receive Notifications</h1>

<h2>Timer Service</h2>

<button @onclick="StartTimer">Start Timer</button>

<h2>Notifications</h2>

<p>
    Status:
    @if (lastNotification.key is not null)
    {
        <span>@lastNotification.key = @lastNotification.value</span>
    }
    else
    {
        <span>Awaiting notification</span>
    }
</p>

@code {
    private (string key, int value) lastNotification;

    protected override void OnInitialized()
    {
        Notifier.Notify += OnNotify;
    }

    public async Task OnNotify(string key, int value)
    {
        await InvokeAsync(() =>
        {
            lastNotification = (key, value);
            StateHasChanged();
        });
    }

    private void StartTimer()
    {
        Timer.Start();
    }

    public void Dispose()
    {
        Notifier.Notify -= OnNotify;
    }
}

ReceiveNotifications.razor:

@page "/receive-notifications"
@implements IDisposable
@inject NotifierService Notifier
@inject TimerService Timer

<h1>Receive Notifications</h1>

<h2>Timer Service</h2>

<button @onclick="StartTimer">Start Timer</button>

<h2>Notifications</h2>

<p>
    Status:
    @if (lastNotification.key != null)
    {
        <span>@lastNotification.key = @lastNotification.value</span>
    }
    else
    {
        <span>Awaiting notification</span>
    }
</p>

@code {
    private (string key, int value) lastNotification;

    protected override void OnInitialized()
    {
        Notifier.Notify += OnNotify;
    }

    public async Task OnNotify(string key, int value)
    {
        await InvokeAsync(() =>
        {
            lastNotification = (key, value);
            StateHasChanged();
        });
    }

    private void StartTimer()
    {
        Timer.Start();
    }

    public void Dispose()
    {
        Notifier.Notify -= OnNotify;
    }
}

前の例の場合:

  • タイマーは、Blazor の _ = Task.Run(Timer.Start) との同期コンテキストの外部で開始されます。
  • NotifierService はコンポーネントの OnNotify メソッドを呼び出します。 InvokeAsync を使用して、正しいコンテキストに切り替え、レンダリングをキューに登録します。 詳しくは、「ASP.NET Core Razor コンポーネントのレンダリング」をご覧ください。
  • コンポーネントでは IDisposable を実装します。 OnNotify デリゲートのサブスクライブは Dispose メソッドで解除されます。このメソッドは、コンポーネントが破棄されたときにフレームワークによって呼び出されます。 詳しくは、「ASP.NET Core Razor コンポーネントのライフサイクル」をご覧ください。
  • Blazor の同期コンテキスト外で NotifierService からコンポーネントの OnNotify メソッドが呼び出されます。 InvokeAsync を使用して、正しいコンテキストに切り替え、レンダリングをキューに登録します。 詳しくは、「ASP.NET Core Razor コンポーネントのレンダリング」をご覧ください。
  • コンポーネントでは IDisposable を実装します。 OnNotify デリゲートのサブスクライブは Dispose メソッドで解除されます。このメソッドは、コンポーネントが破棄されたときにフレームワークによって呼び出されます。 詳しくは、「ASP.NET Core Razor コンポーネントのライフサイクル」をご覧ください。

重要

バックグラウンド スレッドからトリガーされるイベントを Razor コンポーネントで定義している場合、ハンドラーが登録された時点で実行コンテキスト (ExecutionContext) を取り込んで復元することが必要になることがあります。 詳細については、「InvokeAsync(StateHasChanged) を呼び出すと、ページが既定のカルチャにフォールバックする (dotnet/aspnetcore #28521)」を参照してください。

キャッチされた例外をバックグラウンド TimerService からコンポーネントにディスパッチして、例外を通常のライフサイクル イベント例外と同様に処理する方法については、「Razor コンポーネントのライフサイクル外でキャッチされた例外を処理する」セクションを参照してください。

Razor コンポーネントのライフサイクル外でキャッチされた例外を処理する

Razor コンポーネントで ComponentBase.DispatchExceptionAsync を使うと、コンポーネントのライフサイクル呼び出し履歴の外部でスローされた例外を処理できます。 これにより、コンポーネントのコードでは、ライフサイクル メソッドの例外であるかのように例外を処理できます。 その後、Blazor のエラー処理メカニズム (エラー境界など) で例外を処理できます。

メモ

ComponentBase.DispatchExceptionAsync は、ComponentBase から継承した Razor コンポーネント ファイル (.razor) で使われます。 implement IComponent directly コンポーネントを作成する場合は、RenderHandle.DispatchExceptionAsync を使います。

Razor コンポーネントのライフサイクル外でキャッチされた例外を処理するには、DispatchExceptionAsync に例外を渡し、その結果を待機します。

try
{
    ...
}
catch (Exception ex)
{
    await DispatchExceptionAsync(ex);
}

前述のアプローチの一般的なシナリオは、コンポーネントが非同期操作を開始するが Task を待機しない場合です。これは、"ファイア アンド フォーゲット" パターンと呼ばれることがよくあります。メソッドが "ファイア" (開始) され、メソッドの結果が "フォーゲット" (破棄) されるからです。 操作が失敗した場合、次のいずれかの目的のために、そのエラーをコンポーネントでコンポーネント ライフサイクルの例外として扱うようにすることができます。

  • コンポーネントをエラー状態にする (たとえば、エラー境界をトリガーするため)。
  • エラー境界がない場合は回線を終了します。
  • ライフサイクル例外の場合と同じログをトリガーする。

次の例では、ユーザーが [レポートの送信] ボタンを選ぶと、レポートを送信するバックグラウンド メソッド ReportSender.SendAsync がトリガーされます。 ほとんどの場合、コンポーネントは非同期呼び出しの Task を待機し、UI を更新して操作が完了したことを示します。 次の例で、SendReport メソッドは Task を待機せず、結果をユーザーに報告しません。 コンポーネントは SendReportTask を意図的に破棄しているので、非同期エラーは通常のライフサイクル呼び出し履歴の外部で発生します。したがって Blazor には認識されません。

<button @onclick="SendReport">Send report</button>

@code {
    private void SendReport()
    {
        _ = ReportSender.SendAsync();
    }
}

エラーをライフサイクル メソッドの例外のように扱うには、次の例で示すように、DispatchExceptionAsync を使って例外をコンポーネントに明示的にディスパッチします。

<button @onclick="SendReport">Send report</button>

@code {
    private void SendReport()
    {
        _ = SendReportAsync();
    }

    private async Task SendReportAsync()
    {
        try
        {
            await ReportSender.SendAsync();
        }
        catch (Exception ex)
        {
            await DispatchExceptionAsync(ex);
        }
    }
}

別のアプローチでは、Task.Run を利用します。

private void SendReport()
{
    _ = Task.Run(async () =>
    {
        try
        {
            await ReportSender.SendAsync();
        }
        catch (Exception ex)
        {
            await DispatchExceptionAsync(ex);
        }
    });
}

実際のデモでは、「状態を更新するために、コンポーネント メソッドを外部で呼び出す」タイマー通知の例を実装します。 Blazor アプリでは、タイマー通知の例から次のファイルを追加し、セクションの説明に従って Program ファイルにサービスを登録します。

  • TimerService.cs
  • NotifierService.cs
  • Notifications.razor

この例では、Razor コンポーネントのライフサイクルの外部でタイマーを使っています。通常、ハンドルされない例外は、エラー境界などの Blazor のエラー処理メカニズムによって処理されません。

まず、TimerService.cs のコードを変更して、コンポーネントのライフサイクルの外部に人為的な例外を作成します。 TimerService.cswhile ループで、elapsedCount が値 2 になったときに例外をスローします。

if (elapsedCount == 2)
{
    throw new Exception("I threw an exception! Somebody help me!");
}

アプリのメイン レイアウトにエラー境界を配置します。 <article>...</article> マークアップを次のマークアップで置き換えます。

MainLayout.razor:

<article class="content px-4">
    <ErrorBoundary>
        <ChildContent>
            @Body
        </ChildContent>
        <ErrorContent>
            <p class="alert alert-danger" role="alert">
                Oh, dear! Oh, my! - George Takei
            </p>
        </ErrorContent>
    </ErrorBoundary>
</article>

エラー境界が静的 MainLayout コンポーネントにのみ適用される Blazor Web アプリでは、境界は静的なサーバー側レンダリング (静的 SSR) フェーズ中にのみアクティブになります。 境界は、コンポーネント階層の下位にあるコンポーネントが対話型であるという理由だけでアクティブになるわけではありません。 MainLayout コンポーネントと、コンポーネント階層の下位にある残りのコンポーネントに対して広範にインタラクティビティを有効にするには、App コンポーネント (Components/App.razor) の HeadOutlet および Routes コンポーネント インスタンスの対話型レンダリングを有効にします。 次の例では、対話型サーバー (InteractiveServer) レンダリング モードを採用しています。

<HeadOutlet @rendermode="InteractiveServer" />

...

<Routes @rendermode="InteractiveServer" />

この時点でアプリを実行すると、経過カウントが値 2 に達したときに例外がスローされます。 ただし、UI は変わりません。 エラー境界にはエラー内容が表示されません。

例外をタイマー サービスから Notifications コンポーネントにディスパッチするために、コンポーネントに次の変更が加えられます。

  • try-catch ステートメントでタイマーを開始します。 try-catch ブロックの catch 句では、ExceptionDispatchExceptionAsync に渡して結果を待機することで、例外がコンポーネントにディスパッチされます。
  • StartTimer メソッドで、Task.RunAction デリゲートで非同期タイマー サービスを開始し、返された Task を意図的に破棄します。

Notifications コンポーネント (Notifications.razor) の StartTimer メソッド:

private void StartTimer()
{
    _ = Task.Run(async () =>
    {
        try
        {
            await Timer.Start();
        }
        catch (Exception ex)
        {
            await DispatchExceptionAsync(ex);
        }
    });
}

タイマー サービスが実行され、カウントが 2 に達すると、例外が Razor コンポーネントにディスパッチされます。これによってエラー境界がトリガーされ、MainLayout コンポーネントの <ErrorBoundary> のエラー コンテンツが次のように表示されます。

Oh, dear! Oh, my! - George Takei