.NET ConfigureAwait FAQ (翻译)

  阅读了 https://devblogs.microsoft.com/dotnet/configureawait-faq/,感觉其对于 .NET 异步编程有非常有意义的指导,对于进一步学习和理解 .NET 异步编程非常友邦做,所以进行翻译以供参考学习。

 

  七年多前,.NET 在语言和库中加入了 async/await 。在这段时间里,它像野火一样燎原,不仅在 .NET 生态系统中流行开来,还被无数其他语言和框架所效仿。在 .NET 中,它也得到了大量的改进,包括利用异步的附加语言构造、提供异步支持的 API,并从根本上改进了 async/await 运行的基础架构(特别是 .NET Core 中的性能和诊断功能改进)。

  不过,async/await 的一个方面仍然存在问题,那就是 ConfigureAwait。在本篇文章中,我希望能回答其中的许多问题。我希望这篇文章从头到尾都具有可读性,同时也是一个常见问题列表(FAQ),可供今后参考。

  要真正了解 ConfigureAwait,我们需要从更早的时候开始...

什么是 SynchronizationContext?

  System.Threading.SynchronizationContext 文档指出,它 “为在各种同步模型中传播同步上下文提供了基本功能”。这样的描述并不明显。

  对于 99.9% 的用例来说,SynchronizationContext 只是一个提供虚拟 Post 方法的类型,该方法接收一个异步执行的委托(SynchronizationContext 上还有其他各种虚拟成员,但它们用得很少,与本讨论无关)。基础类型的 Post 字面上只是调用 ThreadPool.QueueUserWorkItem 来异步调用所提供的委托。但是,派生类型会覆盖 Post,以便在最合适的时间、最合适的地点执行委托。

  例如,Windows 窗体有一个 SynchronizationContext 派生类型,该类型重载了 Post,使其与 Control.BeginInvoke 的功能等效;这意味着对其 Post 方法的任何调用都将导致委托在稍后的某个时刻在与相关控件(又称 “UI 线程”)关联的线程上被调用。Windows 窗体依赖于 Win32 消息处理,并在用户界面线程上运行一个 “消息循环”,该线程只需等待新消息的到来即可进行处理。这些消息可能是鼠标移动和点击、键盘输入、系统事件、可调用的委托等。因此,如果给定了 Windows 窗体应用程序 UI 线程的 SynchronizationContext 实例,要在 UI 线程上执行委托,只需将其传递给 Post 即可。

  Windows Presentation Foundation(WPF)也是如此。它有自己的 SynchronizationContext 派生类型,其中的 Post 重载同样可以将委托 “marshals ”到 UI 线程(通过 Dispatcher.BeginInvoke),在这种情况下,委托是由 WPF Dispatcher 而不是 Windows Forms 控件管理的。

  而对于 Windows RunTime(WinRT)。它有自己的 SynchronizationContext 派生类型,具有 Post 覆盖功能,还能通过其 CoreDispatcher 将委托队列到 UI 线程。

这不仅仅是 “在用户界面线程上运行此委托”。任何人都可以实现一个带有 Post 的同步上下文(SynchronizationContext),它可以做任何事情。例如,我可能并不关心委托在哪个线程上运行,但我希望确保任何 Post 到我的 SynchronizationContext 的委托都能在一定程度的并发性下执行。我可以使用类似这样的自定义 SynchronizationContext 来实现这一目标:

internal sealed class MaxConcurrencySynchronizationContext : SynchronizationContext
{
    private readonly SemaphoreSlim _semaphore;

    public MaxConcurrencySynchronizationContext(int maxConcurrencyLevel) =>
        _semaphore = new SemaphoreSlim(maxConcurrencyLevel);

    public override void Post(SendOrPostCallback d, object state) =>
        _semaphore.WaitAsync().ContinueWith(delegate
        {
            try { d(state); } finally { _semaphore.Release(); }
        }, default, TaskContinuationOptions.None, TaskScheduler.Default);

    public override void Send(SendOrPostCallback d, object state)
    {
        _semaphore.Wait();
        try { d(state); } finally { _semaphore.Release(); }
    }
}

 

  事实上,单元测试框架 xunit 提供的 SynchronizationContext(同步上下文)与此非常相似,它用于限制与可并发运行的测试相关的代码量。

  所有这一切的好处与任何抽象的好处都是一样的:它提供了一个单一的 API,可用于对委托进行队列,以便按照实现创建者的意愿进行处理,而无需了解该实现的细节。因此,如果我正在编写一个库,而我想去做一些工作,然后将一个委托队列回原始位置的 “上下文”,我只需要抓取它们的 SynchronizationContext,并将其保留下来,然后当我完成我的工作时,在该上下文上调用 Post 来移交我想调用的委托。我不需要知道,对于 Windows 窗体,我应该抓取一个控件并使用它的 BeginInvoke;或者对于 WPF,我应该抓取一个 Dispatcher 并使用它的 BeginInvoke;或者对于 xunit,我应该以某种方式获取它的上下文并对其进行队列;我只需要抓取当前的 SynchronizationContext 并在稍后使用它。为此,SynchronizationContext 提供了一个 Current 属性,因此要实现上述目标,我可以编写如下代码:

public void DoWork(Action worker, Action completion)
{
    SynchronizationContext sc = SynchronizationContext.Current;
    ThreadPool.QueueUserWorkItem(_ =>
    {
        try { worker(); }
        finally { sc.Post(_ => completion(), null); }
    });
}

  希望从当前环境公开自定义上下文的框架会使用 SynchronizationContext.SetSynchronizationContext 方法。

什么是任务调度器?

  SynchronizationContext 是 “调度程序 ”的一般抽象。个别框架有时会有自己的调度程序抽象,System.Threading.Tasks 也不例外。当任务由委托支持,可以排队和执行时,它们就与 System.Threading.Tasks.TaskScheduler 关联。正如 SynchronizationContext 提供了一个虚拟的 Post 方法来对委托的调用进行排队(实现随后通过典型的委托调用机制调用委托),TaskScheduler 也提供了一个抽象的 QueueTask 方法(实现随后通过 ExecuteTask 方法调用该任务)。

  TaskScheduler.Default 返回的默认调度程序是线程池,但也可以派生自 TaskScheduler 并重写相关方法,以实现在何时何地调用任务的任意行为。例如,核心库包括 System.Threading.Tasks.ConcurrentExclusiveSchedulerPair 类型。该类的实例公开了两个 TaskScheduler 属性,一个称为 ExclusiveScheduler,另一个称为 ConcurrentScheduler。调度到 ConcurrentScheduler 的任务可以并发运行,但必须遵守在构建 ConcurrentExclusiveSchedulerPair 时提供给它的限制(类似于前面显示的 MaxConcurrencySynchronizationContext),而且当调度到 ExclusiveScheduler 的任务运行时,ConcurrentScheduler 任务不会运行,每次只允许运行一个独占任务......这样,它的行为就非常像读写锁。

  与 SynchronizationContext 一样,TaskScheduler 也有一个 Current 属性,用于返回 “当前 ”TaskScheduler。但与 SynchronizationContext 不同的是,没有设置当前调度程序的方法。当前调度程序是与当前运行的任务相关联的调度程序,而调度程序是作为启动任务的一部分提供给系统的。因此,举例来说,这个程序将输出 “True”,因为 StartNew 使用的 lambda 是在 ConcurrentExclusiveSchedulerPair 的 ExclusiveScheduler 上执行的,并且会看到 TaskScheduler.Current 被设置为该调度程序:

using System;
using System.Threading.Tasks;

class Program
{
    static void Main()
    {
        var cesp = new ConcurrentExclusiveSchedulerPair();
        Task.Factory.StartNew(() =>
        {
            Console.WriteLine(TaskScheduler.Current == cesp.ExclusiveScheduler);
        }, default, TaskCreationOptions.None, cesp.ExclusiveScheduler).Wait();
    }
}

  有趣的是,TaskScheduler 提供了一个静态 FromCurrentSynchronizationContext 方法,该方法可创建一个新的 TaskScheduler,使用其 Post 方法对任务进行排队,以便在 SynchronizationContext.Current 返回的任务上排队运行。

SynchronizationContext 和 TaskScheduler 与 await 有什么关系?

  考虑编写一个带有按钮的 UI 应用程序。点击按钮后,我们希望从一个网站下载一些文本,并将其设置为按钮的内容。按钮只能从拥有它的用户界面线程中访问,因此当我们成功下载了新的日期和时间文本并想将其存储回按钮的内容时,我们需要从拥有控件的线程中进行操作。否则就会出现以下异常:

System.InvalidOperationException: 'The calling thread cannot access this object because a different thread owns it.'

  如果是手动编写,我们可以使用 SynchronizationContext(如前所述)将 “内容 ”的设置传送回原始上下文,例如通过 TaskScheduler(任务调度程序):

private static readonly HttpClient s_httpClient = new HttpClient();

private void downloadBtn_Click(object sender, RoutedEventArgs e)
{
    s_httpClient.GetStringAsync("http://example.com/currenttime").ContinueWith(downloadTask =>
    {
        downloadBtn.Content = downloadTask.Result;
    }, TaskScheduler.FromCurrentSynchronizationContext());
}

  或直接使用 SynchronizationContext:

private static readonly HttpClient s_httpClient = new HttpClient();

private void downloadBtn_Click(object sender, RoutedEventArgs e)
{
    SynchronizationContext sc = SynchronizationContext.Current;
    s_httpClient.GetStringAsync("http://example.com/currenttime").ContinueWith(downloadTask =>
    {
        sc.Post(delegate
        {
            downloadBtn.Content = downloadTask.Result;
        }, null);
    });
}

  不过,这两种方法都明确使用了回调。相反,我们希望用 async/await 来自然地编写代码:

private static readonly HttpClient s_httpClient = new HttpClient();

private async void downloadBtn_Click(object sender, RoutedEventArgs e)
{
    string text = await s_httpClient.GetStringAsync("http://example.com/currenttime");
    downloadBtn.Content = text;
}

  这就 “just works”,成功地在 UI 线程上设置了内容,因为就像上面手动实现的版本一样,等待任务默认会关注 SynchronizationContext.Current 和 TaskScheduler.Current。在 C# 中等待任何任务时,编译器会转换代码以询问(通过调用 GetAwaiter)“awaitable”(此处为任务)“awaiter”(此处为 TaskAwaiter<string>)。该等待者负责连接回调(通常称为 “继续”),当等待对象完成时,回调将回调到状态机,并使用回调注册时捕获的上下文/调度程序来完成。虽然所使用的代码并不完全相同(还进行了额外的优化和调整),但差不多是这样的:

object scheduler = SynchronizationContext.Current;
if (scheduler is null && TaskScheduler.Current != TaskScheduler.Default)
{
    scheduler = TaskScheduler.Current;
}

  换句话说,它会首先检查是否设置了同步上下文(SynchronizationContext),如果没有,则检查是否存在非默认的任务调度程序(TaskScheduler)。如果找到了,当回调准备好被调用时,它就会使用捕获的调度程序;否则,它一般只会在完成等待任务的操作中执行回调。

ConfigureAwait(false) 的作用是什么?

  ConfigureAwait 方法并不特殊:编译器或运行时都不会以任何特殊方式识别它。它只是一个返回结构体(ConfiguredTaskAwaitable)的方法,该结构体封装了调用它的原始任务以及指定的布尔值。请记住,await 可以用于任何暴露正确模式的类型。通过返回不同的类型,这意味着当编译器访问实例 GetAwaiter 方法(模式的一部分)时,它是根据 ConfigureAwait 返回的类型而不是直接根据任务来访问的,这就提供了一个钩子,可以通过这个自定义的 awaiter 来改变 await 的行为方式。

  具体来说,等待从 ConfigureAwait 返回的类型(continueOnCapturedContext: false)而不是直接等待任务,最终会影响前面所示的如何捕获目标上下文/调度程序的逻辑。这实际上使之前显示的逻辑变得更像这样:

object scheduler = null;
if (continueOnCapturedContext)
{
    scheduler = SynchronizationContext.Current;
    if (scheduler is null && TaskScheduler.Current != TaskScheduler.Default)
    {
        scheduler = TaskScheduler.Current;
    }
}

  换句话说,通过指定 false,即使当前上下文或调度程序可以回调,它也会假装没有。

为什么我要使用 ConfigureAwait(false)?

  ConfigureAwait(continueOnCapturedContext: false) 用于避免在原始上下文或调度程序上强制调用回调。这样做有几个好处:

  提高性能:队列回调而不是直接调用回调是有代价的,这一方面是因为会涉及额外的工作(通常是额外的分配),另一方面是因为这意味着我们无法在运行时采用某些优化(当我们确切知道如何调用回调时,我们可以进行更多优化,但如果将回调交给抽象的任意实现,我们有时会受到限制)。对于非常热的路径,即使是检查当前同步上下文(SynchronizationContext)和当前任务调度器(TaskScheduler)(两者都涉及访问线程状态)的额外成本,也会增加可衡量的开销。如果 await 之后的代码实际上不需要在原始上下文中运行,那么使用 ConfigureAwait(false) 就可以避免所有这些开销:它不需要进行不必要的排队,可以利用所有可以利用的优化,还可以避免不必要的线程静态访问。

  避免死锁:考虑一个对网络下载结果使用 await 的库方法。您调用该方法并同步阻塞等待其完成,例如使用 .Wait() 或 .Result 或 .GetAwaiter().GetResult() 来关闭返回的任务对象。现在考虑一下,如果您在当前同步上下文(SynchronizationContext)中调用该方法,而当前同步上下文将其上可运行的操作数量限制为 1,无论是显式地通过类似前面所示的 MaxConcurrencySynchronizationContext,还是隐式地通过只有一个线程可使用的上下文(如 UI 线程),都会发生什么情况。因此,我们在这一个线程上调用方法,然后阻塞它,等待操作完成。该操作启动网络下载并等待下载。默认情况下,等待任务会捕获当前的 SynchronizationContext,因此它会捕获当前的 SynchronizationContext,当网络下载完成后,它会将调用剩余操作的回调队列回 SynchronizationContext。但是,唯一能处理排队回调的线程目前正被你的代码阻塞,等待操作完成。而在处理回调之前,该操作不会完成。死锁!即使上下文没有将并发限制为 1,但当资源以任何方式受到限制时,也会出现这种情况。想象一下同样的情况,只不过使用的是最大并发同步上下文(MaxConcurrencySynchronizationContext),其限制为 4。 我们并没有只调用一次操作,而是向该上下文排队调用了 4 次,每次调用后都会阻塞,等待调用完成。现在,在等待异步方法完成时,我们仍然阻塞了所有资源,而唯一能让这些异步方法完成的条件是,它们的回调能被这个已经完全消耗掉的上下文处理。这又是一个死锁!如果库方法使用了 ConfigureAwait(false),就不会将回调排队返回到原始上下文,从而避免了死锁情况。

为什么要使用 ConfigureAwait(true)?

  你不会这么做的,除非你纯粹是为了表明你故意不使用 ConfigureAwait(false)(例如为了消除静态分析警告或类似警告)。ConfigureAwait(true)没有任何意义。在比较 await task 和 await task.ConfigureAwait(true) 时,它们在功能上是相同的。如果你在生产代码中看到 ConfigureAwait(true),可以删除它,不会有任何不良影响。

  ConfigureAwait 方法接受一个布尔值,因为在某些特殊情况下,你需要传递一个变量来控制配置。但 99% 的使用情况是使用硬编码的 false 参数值,即 ConfigureAwait(false)。

何时应该使用 ConfigureAwait(false)?

  这取决于:您执行的是应用级代码还是通用库代码?

  在编写应用程序时,您通常希望使用默认行为(这也是默认行为的原因)。如果应用程序模型/环境(如 Windows 窗体、WPF、ASP.NET Core 等)发布了自定义的 SynchronizationContext,那么几乎可以肯定它有一个非常好的理由:它为关心同步上下文的代码提供了一种与应用程序模型/环境进行适当交互的方式。因此,如果您在 Windows 窗体应用程序中编写事件处理程序、在 xunit 中编写单元测试、在 ASP.NET MVC 控制器中编写代码,无论应用程序模型是否确实发布了 SynchronizationContext,您都希望在 SynchronizationContext 存在时使用它。这意味着默认情况下/ConfigureAwait(true)。您只需简单地使用 await,回调/持续操作就会正确地发布回原始上下文(如果存在的话)。由此得出的一般指导原则是:如果编写应用程序级代码,请勿使用 ConfigureAwait(false)。回想一下本文章前面的 Click 事件处理程序代码示例:

private static readonly HttpClient s_httpClient = new HttpClient();

private async void downloadBtn_Click(object sender, RoutedEventArgs e)
{
    string text = await s_httpClient.GetStringAsync("http://example.com/currenttime");
    downloadBtn.Content = text;
}

  DownloadBtn.Content = text 的设置需要在原始上下文中完成。如果代码违反了这一准则,在不应该使用 ConfigureAwait(false) 的情况下使用了它:

private static readonly HttpClient s_httpClient = new HttpClient();

private async void downloadBtn_Click(object sender, RoutedEventArgs e)
{
    string text = await s_httpClient.GetStringAsync("http://example.com/currenttime").ConfigureAwait(false); // bug
    downloadBtn.Content = text;
}

  将导致不良行为。依赖 HttpContext.Current 的经典 ASP.NET 应用程序中的代码也是如此;使用 ConfigureAwait(false),然后尝试使用 HttpContext.Current 很可能会导致问题。

  相比之下,通用库之所以 “通用”,部分原因在于它们不关心使用环境。您可以在网络应用程序、客户端应用程序或测试中使用它们,这并不重要,因为库代码与可能使用的应用程序模型无关。不可知性还意味着它不会做任何需要以特定方式与应用程序模型交互的事情,例如,它不会访问 UI 控件,因为通用库对 UI 控件一无所知。既然我们不需要在任何特定环境中运行代码,我们就可以避免将续程/回调强制返回到原始上下文,我们可以通过使用 ConfigureAwait(false)来做到这一点,并获得其带来的性能和可靠性优势。这就引出了一个普遍的指导原则:如果你正在编写通用库代码,请使用 ConfigureAwait(false)。举例来说,这就是为什么您会看到 .NET Core 运行时库中的每一个(或几乎每一个)await 都在每一个 await 上使用 ConfigureAwait(false);除了少数例外情况,如果不使用 ConfigureAwait(false),则很可能是需要修复的错误。例如,这个 PR 修复了 HttpClient 中一个缺失的 ConfigureAwait(false) 调用。

  当然,与所有指南一样,也会有例外情况,在某些地方它并不合理。例如,在通用程序库中,一个较大的例外情况(或至少是需要考虑的类别)是,这些程序库的 API 需要委托才能调用。在这种情况下,库的调用者传递的可能是应用程序级的代码,由库来调用,这实际上使库的那些 “通用 ”假设变得毫无意义。例如,考虑 LINQ 的 Where 方法的异步版本,如  public static async IAsyncEnumerable<T> WhereAsync(this IAsyncEnumerable<T> source, Func<T, bool> predicate) 。这里的 predicate 是否需要调用回调用者的原始 SynchronizationContext?这取决于 WhereAsync 的实现,这也是它可能选择不使用 ConfigureAwait(false) 的原因。

  即使有这些特殊情况,总体指导仍然有效,而且是一个很好的出发点:如果你正在编写通用库/应用程序模型无关代码,请使用 ConfigureAwait(false),否则就不要使用。

ConfigureAwait(false) 是否能保证回调不会在原始上下文中运行?

  但这并不意味着在 await task.ConfigureAwait(false) 之后的代码不会在原始上下文中运行。这是因为已完成的 awaitables 上的 await 只是同步运行过 await,而不是强制将任何内容排队返回。因此,如果你等待一个在等待时已经完成的任务,无论你是否使用了 ConfigureAwait(false),紧随其后的代码都将继续在当前线程的任何上下文中执行。

只在我的方法中的第一个 await 上使用 ConfigureAwait(false),而不在其他 await 上使用,这样可以吗?

  一般来说,不会。请参见前面的常见问题。如果 await 任务.ConfigureAwait(false)涉及的任务在等待时已经完成(这种情况实际上非常常见),那么 ConfigureAwait(false) 就没有意义了,因为线程会继续在之后的方法中执行代码,并且仍在之前的相同上下文中。

  一个值得注意的例外情况是,如果你知道第一个 await 将始终异步完成,并且被等待的事物将在没有自定义 SynchronizationContext 或 TaskScheduler 的环境中调用其回调。例如,.NET 运行时库中的 CryptoStream 希望确保其潜在的计算密集型代码不会作为调用者同步调用的一部分运行,因此它使用了自定义 awaiter,以确保第一个等待之后的所有内容都在线程池线程上运行。不过,即使在这种情况下,你也会注意到下一个 await 仍然使用了 ConfigureAwait(false);从技术上讲,这并不是必须的,但它让代码审查变得更容易,因为否则每次查看这段代码时,就不需要分析为什么不使用 ConfigureAwait(false)了。

能否使用 Task.Run 来避免使用 ConfigureAwait(false)?

  是的,如下示例代码:

Task.Run(async delegate
{
    await SomethingAsync(); // won't see the original context
});

  在 SomethingAsync() 上调用 ConfigureAwait(false) 将是无效的,因为传递给 Task.Run 的委托将在线程池线程上执行,堆栈上没有更高的用户代码,因此 SynchronizationContext.Current 将返回空值。此外,Task.Run 还隐式地使用了 TaskScheduler.Default,这意味着在委托中查询 TaskScheduler.Current 也将返回 Default。这意味着无论是否使用了 ConfigureAwait(false),await 都将表现出相同的行为。此外,它也不保证该 lambda 内部的代码会做什么。如下代码:

Task.Run(async delegate
{
    SynchronizationContext.SetSynchronizationContext(new SomeCoolSyncCtx());
    await SomethingAsync(); // will target SomeCoolSyncCtx
});

  SomethingAsync 中的代码实际上就会将 SynchronizationContext.Current 视为 SomeCoolSyncCtx 实例,并且该等待和 SomethingAsync 中任何未配置的等待都会返回到该实例。因此,要使用这种方法,你需要了解你正在排队的所有代码可能会做什么,也可能不会做什么,它的操作是否会妨碍你的操作。

  这种方法的代价是需要创建/队列一个额外的任务对象。这对您的应用程序或库来说可能重要,也可能不重要,这取决于您对性能的敏感度。

  此外,请记住,这些技巧可能会带来更多问题,并产生其他意想不到的后果。例如,有人编写了静态分析工具(如 Roslyn 分析器)来标记未使用 ConfigureAwait(false) 的等待,如 CA2007。如果你启用了这样的分析器,但又为了避免使用 ConfigureAwait 而使用了这样的技巧,那么分析器很有可能会标记它,从而给你带来更多的工作。因此,也许你会因为分析器的嘈杂而禁用它,而现在你最终会遗漏代码库中其他本应使用 ConfigureAwait(false) 的地方。

我能否使用 SynchronizationContext.SetSynchronizationContext 来避免使用 ConfigureAwait(false)?

  不,也许吧。这取决于所涉及的代码。

  有些开发人员是这样编写代码的:

Task t;
SynchronizationContext old = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(null);
try
{
    t = CallCodeThatUsesAwaitAsync(); // awaits in here won't see the original context
}
finally { SynchronizationContext.SetSynchronizationContext(old); }
await t; // will still target the original context

  希望它能让 CallCodeThatUsesAwaitAsync 中的代码将当前上下文视为空。确实如此。因此,如果这段代码运行在某个自定义的 TaskScheduler 上,CallCodeThatUsesAwaitAsync 中的等待(且未使用 ConfigureAwait(false))仍将看到并队列回该自定义 TaskScheduler。

  所有注意事项与之前的 Task.Run 相关常见问题解答中的一样:这种变通方法会产生影响,而且 try 内的代码也可以通过设置不同的上下文(或调用非默认 TaskScheduler 的代码)来挫败这些尝试。

  对于这种模式,您还需要注意一个细微的变化:

SynchronizationContext old = SynchronizationContext.Current;
SynchronizationContext.SetSynchronizationContext(null);
try
{
    await t;
}
finally { SynchronizationContext.SetSynchronizationContext(old); }

  看到问题所在了吗?这有点难看,但也有可能造成很大影响。我们无法保证 await 最终会在原始线程上调用回调/继续,这意味着将 SynchronizationContext 重置回原始线程可能不会真正发生在原始线程上,这可能会导致该线程上的后续工作项看到错误的上下文(为了解决这个问题,编写良好的应用程序模型在设置自定义上下文时通常会添加代码,以便在调用任何进一步的用户代码前手动重置上下文)。即使它碰巧运行在同一线程上,也可能要过一段时间才能运行,因此上下文在一段时间内不会得到适当恢复。如果运行在不同的线程上,最终可能会将错误的上下文设置到该线程上。诸如此类,不一而足。这与理想状态相去甚远。

我正在使用 GetAwaiter().GetResult(),我需要使用 ConfigureAwait(false) 吗?

  ConfigureAwait 只影响回调。具体来说,awaiter 模式要求 awaiter 公开 IsCompleted 属性、GetResult 方法和 OnCompleted 方法(可选择 UnsafeOnCompleted 方法)。ConfigureAwait 只影响 {Unsafe}OnCompleted 的行为,因此如果你只是直接调用 awaiter 的 GetResult() 方法,那么无论是在 TaskAwaiter 还是在 ConfiguredTaskAwaitable.ConfiguredTaskAwaiter 上进行调用,行为上都不会有任何区别。因此,如果您在代码中看到 task.ConfigureAwait(false).GetAwaiter().GetResult(),您可以将其替换为 task.GetAwaiter().GetResult()(同时也要考虑您是否真的想这样阻塞)。

我知道我运行的环境永远不会有自定义同步上下文或自定义任务调度程序,我可以不使用 ConfigureAwait(false)吗?

  也许吧,这取决于你对 “从不 ”这部分有多大把握。正如之前的常见问题中提到的,您正在使用的应用程序模型没有设置自定义同步上下文,也没有在自定义任务调度程序上调用您的代码,但这并不意味着其他用户或库代码不会这样做。因此,您需要确保情况并非如此,或者至少认识到可能存在的风险。

我听说在 .NET Core 中不再需要 ConfigureAwait(false),是真的吗?

  错。在 .NET Core 上运行时需要它,原因与在 .NET Framework 上运行时完全相同。这方面没有任何变化。

  不过,变化的是某些环境是否发布了自己的 SynchronizationContext。特别是,.NET Framework 上的经典 ASP.NET 有自己的 SynchronizationContext,而 ASP.NET Core 则没有。这意味着在 ASP.NET Core 应用程序中运行的代码默认不会看到自定义的 SynchronizationContext,从而减少了在这种环境中运行 ConfigureAwait(false) 的需要。

  但这并不意味着永远不会出现自定义 SynchronizationContext 或 TaskScheduler。如果某些用户代码(或您的应用程序使用的其他库代码)设置了自定义上下文并调用了您的代码,或者在调度到自定义 TaskScheduler 的任务中调用了您的代码,那么即使在 ASP.NET Core 中,您的等待也可能会看到非默认上下文或调度器,从而导致您想要使用 ConfigureAwait(false)。当然,在这种情况下,如果您避免同步阻塞(在 Web 应用程序中无论如何都应避免这样做),如果您不介意在这种有限情况下的少量性能开销,您可能可以不使用 ConfigureAwait(false)。

在对 IAsyncEnumerable 进行 “await foreach ”时,能否使用 ConfigureAwait?

  是的,请参阅 MSDN Magazine 这篇文章中的示例。

  await foreach 与一种模式绑定,因此,虽然它可以用于枚举 IAsyncEnumerable<T>,但也可以用于枚举暴露正确 API 表面区域的东西。.NET运行时库包含一个关于 IAsyncEnumerable<T> 的 ConfigureAwait 扩展方法,该方法返回一个封装了 IAsyncEnumerable<T> 和布尔值的自定义类型,并公开了正确的模式。当编译器生成对枚举器的 MoveNextAsync 和 DisposeAsync 方法的调用时,这些调用会指向返回的已配置枚举器结构类型,并以所需的配置方式执行等待。

在 “等待使用” IAsyncDisposable 时,能否使用 ConfigureAwait?

  是的,不过有一个小麻烦。

  与上一个常见问题中描述的 IAsyncEnumerable<T> 一样,.NET 运行时库在 IAsyncDisposable 上公开了一个 ConfigureAwait 扩展方法,await 使用者只要实现了适当的模式(即公开了一个适当的 DisposeAsync 方法),就会很高兴地使用它:

await using (var c = new MyAsyncDisposableClass().ConfigureAwait(false))
{
    ...
}

  这里的问题是,c 的类型现在不是 MyAsyncDisposableClass,而是 System.Runtime.CompilerServices.ConfiguredAsyncDisposable,也就是从 IAsyncDisposable 上的 ConfigureAwait 扩展方法返回的类型。

  要解决这个问题,你需要多写一行:

var c = new MyAsyncDisposableClass();
await using (c.ConfigureAwait(false))
{
    ...
}

  现在,c 的类型又变成了所需的 MyAsyncDisposableClass。这也会增加 c 的作用域;如果这有影响,可以用大括号将整个代码包起来。

我使用了 ConfigureAwait(false),但我的 AsyncLocal 仍在 await 之后流向代码,这是一个错误吗?

  不,这是预料之中的。AsyncLocal<T> 数据作为 ExecutionContext 的一部分流动,而 ExecutionContext 与 SynchronizationContext 是分开的。除非您使用 ExecutionContext.SuppressFlow() 显式禁用了 ExecutionContext 流量,否则 ExecutionContext(以及 AsyncLocal<T> 数据)将始终在等待中流动,无论是否使用了 ConfigureAwait 来避免捕获原始的 SynchronizationContext。更多信息,请参阅本博文

.NET(C#)能否帮助我避免在库中明确使用 ConfigureAwait(false)?

  库开发人员有时会对需要使用 ConfigureAwait(false) 表示不满,并要求提供侵入性较小的替代方法。

  目前还没有任何替代方案,至少没有内置在语言/编译器/运行时中。不过,关于这种解决方案的建议有很多,例如:

  1. https://github.com/dotnet/csharplang/issues/645
  2. https://github.com/dotnet/csharplang/issues/2542
  3. https://github.com/dotnet/csharplang/issues/2649 
  4. https://github.com/dotnet/csharplang/issues/2746

  如果这对您很重要,或者您觉得自己有新的有趣的想法,我鼓励您在这些讨论或新的讨论中发表您的想法。

posted @ 2024-07-19 10:38  Starts_2000  阅读(30)  评论(0编辑  收藏  举报