65.9K
CodeProject 正在变化。 阅读更多。
Home

Blazor 中的异步编程

starIconstarIconstarIconstarIconstarIcon

5.00/5 (5投票s)

2020 年 8 月 11 日

CPOL

9分钟阅读

viewsIcon

45322

Blazor 中异步编程指南

引言

本文深入探讨了 Blazor 中的异步编程。我不敢自称专家:这只是我最近的经验和知识获取的总结。其中有一些原创内容,但大部分内容都是从其他作者的作品中汲取的。文章底部列出了我发现有用并在此文中引用的文章、博客和其他资料的链接。

这是对 2020 年 11 月发表的早期文章的重大修订,侧重于实际应用而非理论。

Blazor 应用程序依赖于远程数据库和服务,需要处理延迟和延迟。理解和使用异步方法是 Blazor 程序员需要掌握的一项关键技能。

你对异步编程了解多少?

我们大多数人认为自己了解异步编程是什么。我就是带着这种错觉开始开发 Blazor 应用程序的。很快我就痛苦地意识到自己的知识是多么肤浅。是的,我当然知道它是什么,并且可以大致解释它。但要真正编写结构良好且行为规范的代码呢?接下来我上了痛苦的一课,学会了谦逊。

那么,什么是异步编程?

简单来说,异步编程让我们能够多任务处理——就像边开车边和乘客聊天一样。Microsoft Docs 网站上有一个非常好的解释,描述了如何制作并行的热任务或顺序的温和早餐

我们何时应该使用它?

在三种主要情况下,异步进程比单个顺序进程具有显著优势:

  1. 处理器密集型操作 - 例如复杂的数学计算
  2. I/O 操作 - 任务被卸载到同一计算机上的子系统或在远程计算机上运行
  3. 改善用户界面体验

在处理器密集型操作中,您需要多个处理器或核心。将大部分处理交给这些核心,程序就可以在主进程上与 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` 无法完成,因为线程被阻塞了。除非你真正了解自己在做什么——如果你了解的话,你可能就不会阅读本文了——**不要使用它们**。如果你认为你需要使用它们,请重新考虑你的设计。

建议

  1. **全程异步和等待**。不要混合同步和异步方法。从底层——数据或进程接口——开始,一路通过数据和业务/逻辑层直到 UI,全程编写异步代码。Blazor 组件实现了异步和同步事件,因此如果您的基础库提供了异步接口,就没有理由使用同步。
  2. 只将处理器密集型任务分配给线程池。不要因为可以就将普通任务分配给线程池。
  3. 不要在你的库中使用 `Task.Run()`。将这个决定尽可能地保留在应用程序代码中。使你的库与上下文无关。
  4. 永远不要在你的库中阻塞。这似乎很明显,但是……如果你绝对必须阻塞,请在前台进行。
  5. 始终使用 `async` 和 `await`,不要尝试花哨。
  6. 如果你的库同时提供 `async` 和 `sync` 调用,请将它们分开编写。“一次编写”的最佳实践不适用于这里。如果你不想在某个时候自找麻烦,**千万不要**从一个调用另一个!
  7. 仅在基于类的事件处理程序中使用 `async void`。绝不在其他任何地方使用。

有用的资源和知识来源

历史

  • 2020 年 8 月 11 日:初始版本
© . All rights reserved.