Blazor 中的异步编程





5.00/5 (5投票s)
Blazor 中异步编程指南
引言
本文深入探讨了 Blazor 中的异步编程。我不敢自称专家:这只是我最近的经验和知识获取的总结。其中有一些原创内容,但大部分内容都是从其他作者的作品中汲取的。文章底部列出了我发现有用并在此文中引用的文章、博客和其他资料的链接。
这是对 2020 年 11 月发表的早期文章的重大修订,侧重于实际应用而非理论。
Blazor 应用程序依赖于远程数据库和服务,需要处理延迟和延迟。理解和使用异步方法是 Blazor 程序员需要掌握的一项关键技能。
你对异步编程了解多少?
我们大多数人认为自己了解异步编程是什么。我就是带着这种错觉开始开发 Blazor 应用程序的。很快我就痛苦地意识到自己的知识是多么肤浅。是的,我当然知道它是什么,并且可以大致解释它。但要真正编写结构良好且行为规范的代码呢?接下来我上了痛苦的一课,学会了谦逊。
那么,什么是异步编程?
简单来说,异步编程让我们能够多任务处理——就像边开车边和乘客聊天一样。Microsoft Docs 网站上有一个非常好的解释,描述了如何制作并行的热任务或顺序的温和早餐。
我们何时应该使用它?
在三种主要情况下,异步进程比单个顺序进程具有显著优势:
- 处理器密集型操作 - 例如复杂的数学计算
- I/O 操作 - 任务被卸载到同一计算机上的子系统或在远程计算机上运行
- 改善用户界面体验
在处理器密集型操作中,您需要多个处理器或核心。将大部分处理交给这些核心,程序就可以在主进程上与 UI 交互,更新进度并处理用户交互。在同一处理器上进行多任务处理没有任何好处。程序不需要更多的球来玩杂耍,只需要更多的杂耍者。
另一方面,I/O 操作不需要多个处理器。它们将请求分派给子系统或远程服务并等待响应。现在是多任务处理节省了时间——同时设置和监视多个任务并等待它们完成。等待时间取决于运行时间最长的任务,而不是所有任务的总和。
如果所有操作都按顺序运行,那么每当任务运行时,用户界面都会被锁定。异步任务会释放 UI 进程。UI 可以在任务运行时与用户交互。
在 Blazor 中,我们主要关注 I/O 和 UI 操作。任何严重的处理器密集型操作都应由服务处理。
任务、线程、调度、上下文
这里有一篇David Deley 的优秀文章,它比我在这篇文章的原始版本中解释得更好。我不会赘述。如果你想了解幕后发生了什么,请阅读它。
Blazor,与桌面应用程序一样,有一个 `SynchronisationContext` UI 线程。所有 UI 代码都必须在此上下文中运行。Blazor 服务器有 `SynchronisationContext` 和一个线程池。Web Assembly 只有一个线程——这是浏览器施加的限制。将来可能会改变,但目前,`Task.Run` 在 Web Assembly 中无法达到您预期的效果。它会阻塞该线程并导致死锁。
UI 中的异步
Blazor UI 由事件驱动。初始渲染事件,然后是按钮点击、数据输入等。
组件事件
组件事件既有同步版本,也有异步版本。`OnInitialized` 和 `OnInitialisedAsync`——通常缩写为 `OnInitialised{Async}`。您应该使用哪个?我的观点,这是个人观点而不是最佳实践,就是忘记同步版本。从一开始就使用异步。如果您打算从某个地方获取数据,那几乎肯定会涉及异步行为。
`OnInitializedAsync` 的标准模式是
protected async override Task OnInitializedAsync()
{
// sync or async code
await base.OnInitializedAsync();
}
protected override Task OnInitializedAsync()
{
// some sync code
return Task.CompletedTask;
}
`ComponentBase` 实现是
protected virtual Task OnInitializedAsync()
=> Task.CompletedTask;
在我的 `Blazor.Database` 仓库中,`RecordFormBase` 的实现看起来像
protected async override Task OnInitializedAsync()
{
// Get the record - code separated out so can be called
// outside the `OnInitializedAsync` event
await LoadRecordAsync();
await base.OnInitializedAsync();
}
`OnParametersSet{Async}` 和 `OnAfterRender{Async}` 也适用相同的模式。
请注意,每个事件的同步版本会在异步版本之前被调用并完成。
组件渲染事件
在组件进程中,了解渲染事件何时发生非常重要。主要渲染发生在 `OnParametersSet{Async}` 事件完成后。然而,如果(且仅当)`OnInitializedAsync` 在完成之前让出控制权,则会发生初始渲染。这提供了在组件初始化过程中显示“正在加载”消息/组件/通知的机会。
以下简单的页面演示了这一点
@page "/testasync"
<div>
<h3>@_message</h3>
</div>
@code {
private string _message = "Starting";
protected async override Task OnInitializedAsync()
{
_message = "Sync Code running";
await Task.Delay(2000);
_message = "Async Code completed";
}
}
UI 事件
UI 事件源自用户。我们这里将以按钮的鼠标点击为例。
这是一个简单的 Razor 页面组件。
@page "/asyncbuttons"
<div class="container m-2 px-3 p-t bg-light">
<div class="row pt-2">
<div class="col-12">
<h3>Event Buttons</h3>
</div>
</div>
<div class="row pt-2">
<div class="col-6">
@value1
</div>
<div class="col-6">
<button class="btn btn-warning" @onclick="this.OnClick">Click</button>
</div>
</div>
<div class="row pt-2">
<div class="col-6">
@value1
</div>
<div class="col-6">
<button class="btn btn-warning" @onclick="(e) => this.OnClick(e)">Click</button>
</div>
</div>
</div>
@code {
private string value1 = "notset";
private void OnClick(MouseEventArgs e)
{
value1 = "Onclick started";
// run some synchronous code
value1 = "Onclick complete";
}
}
这些工作正常。现在我们引入一个 `Task`。
@code {
private async void Onclick(MouseEventArgs e)
{
value1 = "Onclick started";
await DoSomethingAsync();
value1 = "Onclick complete";
}
private Task DoSomethingAsync()
{
Task.Yield();
return Task.CompletedTask;
}
}
这同样有效。最后,让我们让 `DoSomethingAsync` 以真正的 `async` 方式操作并让出控制权。
@code {
private async void OnClick(MouseEventArgs e)
{
value1 = "Onclick started";
await DoSomethingAsync();
value1 = "Onclick complete";
}
private async Task DoSomethingAsync()
{
await Task.Yield();
}
}
现在 `Value1` 只显示 **Onclick started**。第二次更新未显示。在 `OnClick` 末尾的代码中设置一个断点。`value1` 设置为 **Onclick complete**,但 UI 显示的是之前的值。
现在诱惑是在末尾调用 `StateHasChanged` 来解决问题。它会起作用,但你只是掩盖了真正的问题。那么发生了什么?
Blazor 将 `OnClick` 事件作为异步操作加载到 `SynchronisationContext` 队列中,看起来像这样
Await {UIEvent code as Task};
Invoke(StateHasChanged);
在示例一和示例二中,看看 `OnClick` 返回了什么——一个 `void`。加载到 `SynchronisationContext` 上的事件没有任何可等待的东西。
- 在第一个代码块中,所有代码都是同步的,因此在 UI 更新之前运行完成。
- 在第二个代码块中,我们可能将一些东西封装在 Task 中,但它都是同步的,所以再次运行完成——调用 `Task.Yield()` 而没有 `await` 会启动它,但不会等待它。
- 在最终的代码块中,存在一个对 `await` 的正确让出。这会返回到 `SynchronisationContext` 中排队的 UI 事件代码。没有任务可等待,因此它会运行完成,在 `DoSomethingAsync` 完成之前重新渲染组件。`Task.Yield()` 会将自身和任何后续代码重新调度为 UI 事件之后 `SynchronisationContext` 队列中的新 `Task`,从而允许 UI 事件任务首先完成。
这个问题可以通过将事件处理程序更改为返回 `Task` 来解决。
@code {
private async Task OnClick(MouseEventArgs e)
{
value1 = "Onclick started";
await DoSomethingAsync();
value1 = "Onclick complete";
}
}
现在 UI 事件任务有了可等待的对象,并且只有当事件处理程序 `Task` 完成时才会重新渲染。UI 事件任务仍然会让出给 `SynchronisationContext` 队列,让它继续处理其他任务。
您经常看到这种模式。它有些过度,只是将一个 `Task` 封装在另一个 `Task` 中。
<button class="btn btn-warning" @onclick="async (e) => await this.OnClick(e)">Click</button>
组件事件和事件回调
考虑这段代码:
<MyComponent @onclick="() => OnClick()">Hello<MyComponent>
组件不是 HTML 元素。除非您创建了 `EventCallback`,否则 `MyComponent` 上没有 `OnClick` 事件。
下面的代码展示了通过组件实现完全异步行为的代码模式。组件中的 `BtnClick` 使用 `InvokeAsync` 调用 `EventCallback`。在父组件中,注册到 `OnClick` `EventCallback` 的委托会将一个可等待的 `Task` 传回给组件。全程异步。
UIButton.razor
<button class="btn btn-warning" @onclick="this.BtnClick">@ChildContent</button>
@code {
[Parameter] public EventCallback<MouseEventArgs> OnClick { get; set; }
[Parameter] public RenderFragment ChildContent { get; set; }
private async Task BtnClick(MouseEventArgs e)
=> await OnClick.InvokeAsync(e);
}
Test.razor
<div class="row pt-2">
<div class="col-6">
@value5
</div>
<div class="col-6">
<UIButton OnClick="OnclickComponent">click me</UIButton>
</div>
</div>
private string value5 = "notset";
private async Task OnclickComponent(MouseEventArgs e)
{
value5 = "Onclick started";
await Task.Delay(2000);
await DoSomethingAsync();
value5 = "Onclick complete";
}
服务事件
UI 中另一个事件源是组件订阅的服务事件。最常见的是数据更改通知——通常是列表或数据对象。
这些事件的基本模式如下:
private async void OnRecordChange(object sender, EventArgs e)
{
// Do something
await this.InvokeAsync(StateHasChanged);
}
在这种情况下,处理程序声明为 `void`。代码像这样被调用:`RecordChanged?.Invoke(this, EventArgs.Empty)`。没有 `await`,也没有期望返回值。`OnRecordChange` 是顶层事件。它可能需要运行异步代码并等待某些操作,因此可以声明为 `async`。该事件也位于组件渲染过程之外,因此如果 UI 需要更新(例如列表更改),则需要调用 `StateHasChanged`。
`InvokeAsync` 是 `ComponentBase` 的一个方法,看起来像这样
protected Task InvokeAsync(Func<Task> workItem)
=> _renderHandle.Dispatcher.InvokeAsync(workItem);
protected Task InvokeAsync(Action workItem)
=> _renderHandle.Dispatcher.InvokeAsync(workItem);
`_renderHandle` 在组件由 `RenderTreeBuilder` 附加到 `RenderTree` 时传递给组件。`InvokeAsync` 使用提供的 `SynchronisationContext` `Dispatcher` 调用 `StateHasChanged`,确保传入的 `Func` 或 `Action` 在 UI 线程上运行。
服务中的异步
服务中的异步代码取决于您尝试做什么。我将在这里介绍两种非常常见的用法:
EF 数据库操作
Entity Framework 数据库操作都可以异步运行。下面是一个 `dataservice` 中调用 `DbContext` 以获取列表的标准调用。注意 `ToListAsync` 异步获取列表并返回一个 `Task`。
public override async Task<List<TRecord>> GetRecordListAsync<TRecord>()
=> await this.DBContext
.CreateDbContext()
.GetDbSet<TRecord>()
.ToListAsync() ?? new List<TRecord>();
而 `UpdateContext` 是异步的,并返回一个 `Task`。
public override async Task<DbTaskResult> UpdateRecordAsync<TRecord>(TRecord record)
{
var context = this.DBContext.CreateDbContext();
context.Entry(record).State = EntityState.Modified;
return await this.UpdateContext(context);
}
API 调用操作
上面两个相同操作的 API 调用如下所示。`GetFromJsonAsync`、`PostAsJsonAsync` 和 `ReadFromJsonAsync` 都是 `async` 的。
public override async Task<List<TRecord>> GetRecordListAsync<TRecord>()
=> await this.HttpClient.GetFromJsonAsync<List<TRecord>>
($"/api/{GetRecordName<TRecord>()}/list");
public override async Task<DbTaskResult> UpdateRecordAsync<TRecord>(TRecord record)
{
var response = await this.HttpClient.PostAsJsonAsync<TRecord>
($"/api/{GetRecordName<TRecord>()}/update", record);
var result = await response.Content.ReadFromJsonAsync<DbTaskResult>();
return result;
}
这些代码片段来自一系列文章和仓库。
阻塞和死锁
在某个时候,您将面临死锁。异步代码要么总是锁定,要么在负载下锁定。在 Blazor 中,这表现为页面锁定。灯亮着,但没有人响应。您已经终止了运行 SPA 实例的应用程序进程。唯一的办法是重新加载页面 (F5)。
常见原因是阻塞代码——应用程序线程上的程序执行被暂停,等待队列中更靠后的任务完成。暂停会阻塞它正在等待的代码的执行。死锁。将任务移动到线程池,任务完成,阻塞解除。但是,UI 不会更新。将代码移到任务池以解除应用程序线程的阻塞不是解决方案。阻塞线程池线程也不是。在负载下,应用程序可能会阻塞所有可用线程。
这里有一些经典的阻塞代码——在本例中,是 UI 中的按钮点击事件。
public void ButtonClicked()
{
var task = this.SomeService.GetAListAsync();
task.Wait();
}
还有更多
public void GetAListAsync()
{
var task = myDataContext.somedataset.GetListAsync();
var ret = task.Result;
}
`Task.Wait()` 和 `task.Result` 是阻塞操作。它们会停止线程上的执行并等待 `task` 完成。`Task` 无法完成,因为线程被阻塞了。除非你真正了解自己在做什么——如果你了解的话,你可能就不会阅读本文了——**不要使用它们**。如果你认为你需要使用它们,请重新考虑你的设计。
建议
- **全程异步和等待**。不要混合同步和异步方法。从底层——数据或进程接口——开始,一路通过数据和业务/逻辑层直到 UI,全程编写异步代码。Blazor 组件实现了异步和同步事件,因此如果您的基础库提供了异步接口,就没有理由使用同步。
- 只将处理器密集型任务分配给线程池。不要因为可以就将普通任务分配给线程池。
- 不要在你的库中使用 `Task.Run()`。将这个决定尽可能地保留在应用程序代码中。使你的库与上下文无关。
- 永远不要在你的库中阻塞。这似乎很明显,但是……如果你绝对必须阻塞,请在前台进行。
- 始终使用 `async` 和 `await`,不要尝试花哨。
- 如果你的库同时提供 `async` 和 `sync` 调用,请将它们分开编写。“一次编写”的最佳实践不适用于这里。如果你不想在某个时候自找麻烦,**千万不要**从一个调用另一个!
- 仅在基于类的事件处理程序中使用 `async void`。绝不在其他任何地方使用。
有用的资源和知识来源
- David Deley Async/Await 解释
- 异步编程 - Microsoft
- Stephen Cleary - Task 导览及其他文章
- Eke Peter - 理解异步,避免 C# 中的死锁
- Stephen Cleary - MSDN - 异步编程最佳实践
- 许多 StackOverflow 问题的答案
历史
- 2020 年 8 月 11 日:初始版本