blazor-university.zh-cn

原文链接:https://blazor-university.com/components/multi-threaded-rendering/invokeasync/

线程安全的使用 InvokeAsync

在我们的代码被非 UI 事件调用的情况下(例如多线程渲染中介绍的那些),如果我们打算操纵状态,我们通常需要实现某种类型的线程锁定/同步。

回顾: 非 UI 事件包括:

为了避免手动编写线程安全代码,编写 WPF 应用程序的人可能会使用 Dispatcher.Invoke 来确保 UI 线程执行代码,而 WinForms 开发人员可能会使用窗体的 Invoke 方法。以这种方式调用的任何代码始终由特定线程(UI 线程)执行,这避免了使用线程同步代码。

StateHasChanged 框架方法,用于告诉 Blazor 重新渲染我们的组件,不允许多个线程同时访问渲染进程。如果辅助线程调用 StateHasChanged,则会引发异常。

System.InvalidOperationException:当前线程未与 Dispatcher 关联。

在 Blazor Server 应用程序中,有一个与每个连接(每个浏览器选项卡)关联的调度程序。当我们使用 InvokeAsync 时,我们正在通过此调度程序执行操作(就像 WPF Dispatcher.Invoke 或 WinForms Control.Invoke)。

在前面介绍的场景之一中调用 StateHasChanged 时(从线程执行代码等),有必要通过 InvokeAsync() 方法调用它。 InvokeAsync 将串行化操作,因此将避免 StateHasChanged 引发异常。

尽管方法将由任意数量的不同线程执行,但在任何给定时刻只有一个线程将访问组件,从而无需围绕共享状态编写线程锁定/同步代码。

InvokeAsync 示例

源代码

为了演示直接从线程执行组件方法与通过 InvokeAsync 执行之间的行为差​​异,我们将创建一个服务器端应用程序,该应用程序将展示多个并发线程如何破坏共享状态。

创建新的 Blazor 服务器端应用程序后,添加一个静态类,该类将存储一个整数值,多个组件/线程可以访问该值。

public static class CounterState
{
  public static int Value { get; set; }
}

显示状态

我们将在组件中显示此状态的值,并每秒检查两次该值。为此,我们将在 /Shared 文件夹中创建一个名为 ShowCounterValue.razor 的组件。

@implements IDisposable
<div>
  Counter value is: @CounterState.Value at @DateTime.UtcNow.ToString("HH:mm:ss")
</div>

@code
{
  private System.Threading.Timer Timer;

  protected override void OnInitialized()
  {
    base.OnInitialized();
    Timer = new System.Threading.Timer(_ =>
    {
      InvokeAsync(StateHasChanged);
    }, null, 500, 500);
  }

  void IDisposable.Dispose()
  {
    Timer?.Dispose();
    Timer = null;
  }
}

注意:如果计时器没有被释放,那么它将在用户会话的整个生命周期内保持活动状态。如果计时器保持活动状态,则不会对组件进行垃圾回收,因为计时器回调通过其 InvokeAsyncStateHasChanged 方法持有对组件的隐式引用。

修改状态

我们现在将创建一个组件,它将递增 CounterState.Value 字段。每个组件将在一个线程中执行一个循环并更新 Value 1000 次。然后我们可以在我们的主页上拥有这个组件的多个实例,以确保多个线程正在更新状态。

注意:这个组件将被传递一个 System.Threading.WaitHandle。组件的线程会被挂起,直到主页面触发这个 WaitHandle,触发所有线程同时开始循环。

/Shared 文件夹中,创建一个名为 IncrementCounter.razor 的新文件。我们将从一些文本开始,以显示该组件存在于页面上,一个接受所需 WaitHandle 的参数,以及一个指示我们是否要使用 InvokeAsync 来增加值的参数。

<div>
  Incrementing...
</div>
@code
{
  [Parameter]
  public bool ShouldUseInvokeAsync { get; set; }

  [Parameter]
  public System.Threading.WaitHandle Trigger { get; set; }

   // More to come
}

为了增加 CounterState.Value,我们将在 OnInitialized 生命周期方法中创建一个线程。我们将立即启动线程,但线程中的第一条指令将暂停自身,直到触发 WaitHandle

protected override void OnInitialized()
{
  var thread = new System.Threading.Thread(_ =>
  {
    Trigger.WaitOne();
    for (int i = 0; i < 1000; i++)
    {
      if (!ShouldUseInvokeAsync)
      {
        CounterState.Value++;
      }
      else
      {
        InvokeAsync(() => CounterState.Value++);
      }
    }
  });

  thread.Start();
}

使用组件演示状态冲突

最后一步是在我们的 Index 页面中创建一些标记,这将创建其中的 5 个组件。

为了使应用程序更有趣,我们还将

  1. 有一个复选框,允许用户指定是否希望通过 InvokeAsync 执行状态更改。

  2. 允许用户重置应用程序的状态并通过单击按钮重试。

如果用户想要使用 InvokeAsync,我们需要在 Index 页面中显示一些组件状态来指示我们当前正在运行测试,以及一个 System.Threading.ManualReset 对象,我们可以将其传递给我们的 IncrementCounter 以触发对其的处理线程。

private bool IsWorking;
private bool UseInvokeAsync;
private System.Threading.ManualResetEvent Trigger = new System.Threading.ManualResetEvent(false);

对于标记,我们需要使用双向绑定IsWorking 字段绑定到 HTML <input> 元素。

<div>
  <input type="checkbox" @bind=UseInvokeAsync id="UseInvokeAsyncCheckbox" />
  <label for="UseInvokeAsyncCheckbox">Use InvokeAsync</label>
</div>

随后是一个按钮,用户可以单击以开始测试运行。在测试运行期间应禁用该按钮。

<div>
  <button @onclick=Start disabled=@IsWorking>Start</button>
</div>

我们还想使用 ShowCounterValue 让我们的用户了解 CounterState.Value 的当前值。

<ShowCounterValue />

最后,对于标记,我们将要创建 5 个 IncrementCounter 组件实例。这些组件只会在测试运行开始后创建,并在运行完成后被丢弃。为此,我们仅在 IsWorkingtrue 时才渲染它们。

@if (IsWorking)
{
  for (int i = 0; i < 5; i++)
  {
    <IncrementCounter Trigger=@Trigger ShouldUseInvokeAsync=@UseInvokeAsync />
  }
}

我们现在必须编写的唯一代码是 Start 方法。这将简单地重置状态,将 IsWorking 设置为 true,触发我们的组件开始递增,然后将 IsWorking 设置为 false

Index 页面现在应该如下所示:

@page "/"
<div>
  <input type="checkbox" @bind=UseInvokeAsync id="UseInvokeAsyncCheckbox" />
  <label for="UseInvokeAsyncCheckbox">Use InvokeAsync</label>
</div>
<div>
  <button @onclick=Start disabled=@IsWorking>Start</button>
</div>
<ShowCounterValue />
@if (IsWorking)
{
  for (int i = 0; i < 5; i++)
  {
    <IncrementCounter Trigger=@Trigger ShouldUseInvokeAsync=@UseInvokeAsync />
  }
}

@code
{
  private bool IsWorking;
  private bool UseInvokeAsync;
  private System.Threading.ManualResetEvent Trigger = new System.Threading.ManualResetEvent(false);

  private async Task Start()
  {
    CounterState.Value = 0;
    IsWorking = true;
    StateHasChanged();

    await Task.Delay(500);
    Trigger.Set();

    await Task.Delay(1000);
    IsWorking = false;
    Trigger.Reset();
  }
}

运行示例应用程序

在没有 InvokeAsync 的情况下运行

运行应用程序,并在第一个测试中取消选中 Use InvokeAsync 复选框。然后单击开始按钮,您将看到类似下图的内容。

1 秒后出现如下图所示的屏幕。请注意,在我的情况下,最终的 CounterState.Value 仅为 1202。考虑到有 5 个组件,每个组件在 1000 次迭代循环中将值递增 1,理想情况下,我们应该将 5000 视为最终值。

使用 InvokeAsync 运行

接下来,勾选复选框并再次单击“开始”按钮。

一秒钟后,其余运行将完成,我们看到了更理想的结果。

总结

在处理 UI 触发事件(按钮点击、导航事件等)时,我们不需要对线程安全做任何特殊考虑。 Blazor 将为我们管理这一点,并确保任何时候只有一个线程在执行组件代码。

当非 UI 事件触发我们的代码时,然后在服务器端 Blazor 应用程序中,此代码将在非同步线程中触发。无法调用 StateHasChanged,并且由于线程竞争条件,对任何共享状态的访问都容易损坏。

注意: Blazor WebAssembly 应用是单线程的,因此不必考虑线程安全。

ComponentBase 类上引入的 Blazor InvokeAsync 将通过同步每个用户连接的线程执行来确保不会发生争用条件。

Blazor 能够通过在用户连接到 Blazor 服务器端应用程序时创建的 Dispatcher 执行代码来确保在任何给定时间只有一个线程正在执行代码。

要考虑的一个可能的复杂情况是 Blazor Dispatcher 不会确保在执行下一个分派的代码之前运行整个代码段。如果分派的所有动作都是同步代码,那么就是这种情况,但是,如果任何分派的操作是异步的,那么该线程将在对某些异步代码(例如 Task.Delay 或 HTTP 请求)执行 await 时放弃其执行时间。

这意味着尽管 Blazor 的 InvokeAsync 可以保证线程在执行中同步,但这并不意味着我们在使用异步资源(例如对 Entity Framework Core 的查询)时可以不使用线程安全代码。

以下由两个单独线程执行的代码将导致多个线程同时尝试使用相同的 Entity Framework Core DbContext,即使通过 InvokeAsync 调用也是如此。

IsLoading = true;
StateHasChanged();

Person[] people = await ApplicationDbContext.Person.ToArrayAsync();
IsLoading = false;

这是因为 await 语句放弃执行并允许 Blazor 让不同的逻辑代码块执行。

在上图中,在步骤 2 中,两个单独的线程已指示 ApplicationDbContext 执行异步操作。因为 Entity Framework Core 不支持线程重入,它会抛出一个异常,告诉我们在任何给定时间只有一个线程可以访问 DbContext

因此,尽管在通过 InvokeAsync 执行的同步代码中共享状态是安全的,但请记住,一旦您的代码引入了 await,它就允许其他线程有机会介入,直到 await 完成。

下一篇 - 渲染树