优秀的编程知识分享平台

网站首页 > 技术文章 正文

【译】ConfigureAwait FAQ(configgenerator翻译)

nanyue 2024-10-25 13:20:13 技术文章 3 ℃

.NET 在数年前就在语言和库中添加了 async/await。在那段时间里,它像野火一样蔓延开来,不仅在 .NET 生态系统中,而且在无数其他语言和框架中被复制。在 .NET 中也看到了大量的改进,包括利用异步的额外语言构造、提供异步支持的 API 以及在基础设施中实现 async/await (特别是在 .NET Core 中性能和诊断支持方面的改进)。

然而,async/await 中的 ConfigureAwait,引发了一些疑问。在这篇文章中,我希望能回答其中的许多问题。我希望这篇文章从头到尾都是易读的,同时也是一个常见问题的列表,可以作为将来的参考。

要真正理解 ConfigureAwait,我们需从更基础的一些东西说起。

SynchronizationContext 是什么?

SynchronizationContext 在 MSDN 中描述为:“提供了在各种同步模型中传播同步上下文的基本功能。”

对于99.9%的用例,SynchronizationContext 只是一个类型,它提供了一个 virtual Post 方法,提供了一个异步执行的委托。(SynchronizationContext 有各种各样的其他虚拟成员,但他们很少使用本文不做讨论)。基类型的 Post 实际上只是调用 ThreadPool.QueueUserWorkItem 异步调用提供的委托。但是,派生类型会重写 Post 以使该委托能够在最合适的地方和最合适的时间执行。

例如,Windows Form 有一个 WindowsFormsSynchronizationContext 派生类型,它重写了 Post 方法,内部其实就是 Control.BeginInvoke。

这意味着,对其 Post 方法的任何调用都将导致该委托在与相关控件关联的线程(又名“UI线程”)上稍后的某个点被调用。Windows Forms 依赖于 Win32 消息处理,并在 UI 线程上运行一个“消息循环”,该线程只是坐着等待新消息到达处理。这些消息可以用于鼠标移动和单击、键盘输入、系统事件、可调用的委托等。因此,给定 Windows Form 应用程序 UI 线程的 SynchronizationContext 实例,要获得一个在那个 UI 线程上执行的委托,只需要将它传递给 Post 即可。

WPF 也是如此。DispatcherSynchronizationContext 派生类型的 Post 内部其实是 Dispatcher.BeginInvoke,在这种情况下由一个 WPF Dispatcher 管理而不是一个 Windows Forms Control。

WinRT 也类似。它有自己的 SynchronizationContext 派生类型,带有一个 Post 重载,该重载通过 CoreDispatcher 将委托排队到 UI 线程。

这不仅仅是“在 UI 线程上运行这个委托”。任何人都可以实现 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,它使用它来限制并发运行的测试相关联的代码数量。

namespace Xunit.Sdk{ /// <summary> /// An implementation of <see cref="SynchronizationContext"/> which runs work on custom threads /// rather than in the thread pool, and limits the number of in-flight actions. /// </summary> public class MaxConcurrencySyncContext : SynchronizationContext, IDisposable { bool disposed = false; readonly ManualResetEvent terminate = new ManualResetEvent(false); readonly List<XunitWorkerThread> workerThreads; readonly ConcurrentQueue<Tuple<SendOrPostCallback, object, object>> workQueue = new ConcurrentQueue<Tuple<SendOrPostCallback, object, object>>(); readonly AutoResetEvent workReady = new AutoResetEvent(false);
/// <summary> /// Initializes a new instance of the <see cref="MaxConcurrencySyncContext"/> class. /// </summary> /// <param name="maximumConcurrencyLevel">The maximum number of tasks to run at any one time.</param> public MaxConcurrencySyncContext(int maximumConcurrencyLevel) { workerThreads = Enumerable.Range(0, maximumConcurrencyLevel) .Select(_ => new XunitWorkerThread(WorkerThreadProc)) .ToList(); }
/// <summary> /// Gets a flag indicating whether maximum concurrency is supported. /// </summary> public static bool IsSupported => ExecutionContextHelper.IsSupported;
/// <inheritdoc/> public void Dispose() { if (disposed) return;
disposed = true; terminate.Set();
foreach (var workerThread in workerThreads) { workerThread.Join(); workerThread.Dispose(); }
terminate.Dispose(); workReady.Dispose(); }
/// <inheritdoc/> public override void Post(SendOrPostCallback d, object state) { // HACK: DNX on Unix seems to be calling this after it's disposed. In that case, // we'll just execute the code directly, which is a violation of the contract // but should be safe in this situation. if (disposed) Send(d, state); else { var context = ExecutionContextHelper.Capture(); workQueue.Enqueue(Tuple.Create(d, state, context)); workReady.Set(); } }
/// <inheritdoc/> public override void Send(SendOrPostCallback d, object state) { d(state); }
[SecuritySafeCritical] void WorkerThreadProc() { while (true) { if (WaitHandle.WaitAny(new WaitHandle[] { workReady, terminate }) == 1) return;
Tuple<SendOrPostCallback, object, object> work; while (workQueue.TryDequeue(out work)) { // Set workReady() to wake up other threads, since there might still be work on the queue (fixes #877) workReady.Set(); if (work.Item3 == ) // Fix for #461, so we don't try to run on a execution context RunOnSyncContext(work.Item1, work.Item2); else ExecutionContextHelper.Run(work.Item3, _ => RunOnSyncContext(work.Item1, work.Item2)); } } }
[SecuritySafeCritical] void RunOnSyncContext(SendOrPostCallback callback, object state) { var oldSyncContext = Current; SetSynchronizationContext(this); callback(state); SetSynchronizationContext(oldSyncContext); } }}

这些派生类都比较类似:SynchronizationContext 提供了一个单独的 API,可用于对委托进行排队,以便按用户需求进行处理,而不需要知道实现的细节。所以,如果我正在写一个库,我想开始做一些工作,然后将一个委托排队回到原始位置的“context”,我只需要抓住他们的 SynchronizationContext,持有它,然后当我完成工作后,在那个context上调用Post来传递我想要调用的委托。我不需要知道,对于 Windows 窗体,我应该获取一个 Control 并使用它的 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(), ); } });}

框架使用 SynchronizationContext.SetSynchronizationContext 方法,通过 Current 暴露一个自定义上下文。

TaskScheduler 是什么?

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

调度器通过 TaskScheduler.Default 返回的默认调度器是线程池,但是可以从 TaskScheduler 派生并覆盖相关的方法,从而实现何时何地调用 Task 的行为。例如,核心库包括 System.Threading.Tasks.ConcurrentExclusiveSchedulerPair 类型。这个类的实例暴露了两个 TaskScheduler 属性,一个称为 ExclusiveScheduler,另一个称为 ConcurrentScheduler。任务调度到 ConcurrentScheduler 的中运行,则可以同时运行,但有并发限制,受制于 ConcurrentExclusiveSchedulerPair。(类似于前面说的 MaxConcurrencySynchronizationContext)。当 Task 被调度到 ExclusiveScheduler 中运行时,没有任何 ConcurrentScheduler 的 Task 会运行,一次只允许运行一个排他性 Task……在这种情况下,它的行为非常类似于一个 reader/writer-lock。

与 SynchronizationContext 一样,TaskScheduler 也有一个 Current 属性,它返回当前的 TaskScheduler。然而,与 SynchronizationContext 不同的是,它没有设置当前调度器的方法。作为替代,当前调度器是与当前运行的 Task 相关联的调度器,并且作为启动 Task 的一部分提供给系统的调度器。下面程序将输出“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 应用程序。单击按钮时,我们希望从 web 站点下载一些文本,并将其设置为按钮的内容。该按钮只能从拥有它的 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; }, ); });}

但是,这两种方法都显式地使用回调,下面使用 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;}

这“刚刚好”,成功地在 UI 线程上设置了 Content,因为就像上面手动实现的版本一样,await 一个 Task 在默认情况下会注意到 SynchronizationContext.Current 和 TaskScheduler.Current。当你在 C# 中 await 任何东西时,编译器会通过调用 GetAwaiter 将代码转换为“awaitable”类型,在本例中是 Task,所以转换为“awaiter”,在本例中是 TaskAwaiter。awaiter负责连接回调(通常称为“continuation”),当等待的对象完成时它将回调到状态机中,它使用在注册回调时捕获的 context/scheduler 来执行此操作。

虽然不是完全使用的代码(有额外的优化和调整),它是这样的:

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

换句话说,它首先检查是否有 SynchronizationContext 集,如果没有,是否有一个非默认的 TaskScheduler 在运行。如果它找到一个,当回调准备被调用时,它将使用捕获调度程序;否则,它通常只执行回调,作为完成等待的任务的操作的一部分。

ConfigureAwait(false) 做了什么?

ConfigureAwait 方法并不特殊:编译器或运行时都不会以任何特殊的方式识别它。它只是一个简单方法,返回一个 struct(ConfiguredTaskAwaitable),该 struct 包装了调用它的原始任务,就像 Boolean 一样被调用。请记住,await 可以用于任何暴露正确模式的类型。通过返回一个不同的类型,这意味着当编译器访问实例 GetAwaiter 方法(模式的一部分),它是在 ConfigureAwait 返回的类型之外执行的,而不是直接在任务之外执行,并提供了一个钩子,通过这个定制的 awaiter 来改变等待的行为。

具体来说,等待从 ConfigureAwait(continueOnCapturedContext: false) 返回的类型,而不是直接等待 Task ,最终会影响前面显示的如何捕获目标 context/scheduler 的逻辑。它有效地使前面展示的逻辑更像这样:

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

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

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

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

性能改进

如果不是调用它而是要排队进行回调,就会产生更高的成本,因为有额外的工作(通常是额外的分配),也因为这意味着某些优化我们在运行时不能使用(我们可以做更多的优化,当我们知道如何调用回调,但是如果是交给任意一个抽象的实现,我们能做的就很有限了)。对于非常热的路径,甚至检查当前 SynchronizationContext 和当前 TaskScheduler(两者都涉及线程的静态访问)的额外成本也会增加可测量的开销。如果 await 后的代码实际上并不需要运行在原来的上下文下,使用 ConfigureAwait(false) 可以避免所有这些成本:它不需要不必要的排队,它可以利用所有的优化,而且可以避免不必要的线程静态访问。

避免死锁

考虑一种使用 await 某个网络下载结果的库方法。您可以调用这个方法并同步地阻塞它,等待它完成,比如在返回的 Task 对象上使用 .Wait() 或 .Result或.GetAwaiter().GetResult()。现在考虑如果你调用它时当前 SynchronizationContext 是限制数量的操作可以运行数量为1,不管是明确地通过类似 MaxConcurrencySynchronizationContext 设置,还是隐式地使用只有一个线程可以被使用上下文,例如一个UI线程。因此,调用该线程上的方法,然后阻塞它,等待操作完成。该操作启动了网络下载并等待它。因为在默认情况下,等待 Task 将捕获当前的 SynchronizationContext,所以它就这样做了,当网络下载完成时,它会返回到 SynchronizationContext,这个回调将调用剩余的操作。但是唯一能够处理队列回调的线程目前被阻塞等待操作完成的代码阻塞。直到回调处理完毕,该操作才会完成。死锁!即使上下文没有将并发限制为1,但是当资源受到任何形式的限制时,这也是这样的。想象一下同样的情况,除了使用限制为4的 MaxConcurrencySynchronizationContext。与只对操作进行一次调用不同,我们将对上下文4次调用进行排队,每个调用都进行调用并阻塞等待它完成。在等待异步方法完成时,我们仍然阻塞了所有的资源,而允许这些异步方法完成的唯一事情是,它们的回调是否能被这个已经被完全消耗的上下文处理。再次,死锁!如果库方法使用 ConfigureAwait(false),它就不会将回调排队回原始上下文,从而避免了死锁的情况。

为什么我想要使用 ConfigureAwait(true)?

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

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

何时应该使用 ConfigureAwait(false)?

这取决于:你是在实现应用程序级代码 app-level code 还是通用类库代码?

在编写应用程序时,通常需要默认行为(这就是为什么它是默认行为)。如果一个应用程序模型/环境(如Windows Forms, WPF, ASP. net)发布一个自定义的 SynchronizationContext,几乎可以肯定有一个很好的理由:它为关心同步上下文的代码提供了一种方式来与应用模型/环境进行适当的交互。如果你在 Windows 窗体应用程序中写一个事件处理程序,在 xunit 中写一个单元测试,在 ASP.NET MVC 控制器中写代码,无论 app 模型是否实际上发布了一个 SynchronizationContext,你都想使用那个 SynchronizationContext 如果它存在的话。这意味着默认是 ConfigureAwait(true)。只需简单地使用 await,就可以正确地将回调/延续发送回原始上下文(如果存在的话)。这就导致了以下的一般指导:如果您正在编写应用程序级别(app-level code)的代码,不要使用 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;}

ASP.NET 应用依赖于 HttpContext.Current;使用 ConfigureAwait(false),然后尝试使用 HttpContext.Current 很可能会导致问题。

相反,通用库是“通用的”,部分原因是它们不关心使用它们的环境。您可以从 web 应用程序、客户端应用程序或测试中使用它们,这并不重要,因为库代码与它可能使用的应用程序模型无关。不关心也意味着它不会做任何需要以特定方式与应用模型交互的事情,例如它不会访问UI控件,因为一个通用库对UI控件一无所知。因为这样我们就不需要在任何特定的环境中运行代码,所以我们可以避免强制延续/回调到原始上下文,我们可以通过使用 ConfigureAwait(false) 来做到这一点,并获得它带来的性能和可靠性好处。这就导致了以下的一般指导:如果您正在编写通用库代码 general-purpose library code,请使用 ConfigureAwait(false)。这就是为什么 .NET Core 运行时类库中的每个 await(或者几乎每个)都使用 ConfigureAwait(false);除了少数例外,剩下的很可能是一个需要修复的错误。例如,修复了 HttpClient中 缺失的 ConfigureAwait(false) 调用。

当然,与所有指导一样,也有例外。例如,通用库中较大的例外之一(或者至少是需要思考的类别)是当这些库具有接受委托调用的 api 时。在这种情况下,库的调用者传递潜在的由库调用的应用级代码,从而有效地呈现了通用库应该“通用”的假设。例如,考虑 LINQ 的 Where 方法的异步版本, public static async IAsyncEnumerable WhereAsync(this IAsyncEnumerable source, Func predicate)。这里的 predicate 需要在调用者的原始 SynchronizationContext 上回调吗?这要由 WhereAsync 的实现来决定,这也是它选择不使用 ConfigureAwait(false) 的一个原因。

即使在这些特殊的情况下,一般的指导仍然是一个很好的起点:如果您正在编写通用的库 general-purpose library/应用程序模型无关的代码 app-model-agnostic code,那么使用 ConfigureAwait(false),否则就不需要。

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

不。ConfigureAwait (false) 可以保证它不会被排队回原来的上下文中,但这并不意味着 await task.ConfigureAwait(false) 之后的代码不会在原来的上下文中运行。这是因为对已经完成的可等待对象的等待只是在 await 过程中同步运行,而不是强迫任何对象排队返回。因此,如果 await 一个已经完成的任务,无论是否使用 ConfigureAwait(false),紧随其后的代码都将继续在当前线程上执行,无论上下文是否仍然是当前的。

是否可以只在方法中第一个await 处使用 ConfigureAwait(false),而不是在其余的中使用?

一般来说,不行。参见前面的内容,如果等待 await task.ConfigureAwait(false) 包含一个在等待的时候已经完成的任务(这实际上是非常常见的),然后使用 ConfigureAwait (false) 将毫无意义,随着线程继续执行后面的代码上下文还是之前的上下文。

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

我可以使用 Task.Run 来避免使用 ConfigureAwait(false) 吗?

是的,如果你这样写:

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

SomethingAsync() 的 ConfigureAwait(false) 调用将是 Nop,因为委托传递给 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 的实例,这两个 await 和在 SomethingAsync 任何未配置的 await 将回到它。因此,要使用这种方法,您需要了解您正在排队的所有代码可能做什么,也可能不做什么,以及它的操作是否会妨碍您的操作。

这种方法的代价是需要创建/排队额外的 task 对象。这对你的应用程序或库来说可能重要,也可能无关紧要,这取决于你的性能敏感性。

还要记住,这些技巧可能会导致比它们本身价值更多的问题,并产生其他意想不到的后果。例如,静态分析工具(如 Roslyn 分析程序)已经被用来标记不使用 ConfigureAwait(false) 的等待,比如 CA2007。如果您启用了这样的分析器,但是使用了这样的技巧来避免使用 ConfigureAwait,那么分析器很有可能会标记它,从而给您带来更多的工作。因此,您可能会因为分析器的噪音而禁用它,而现在您在代码库中丢失了实际上应该使用 ConfigureAwait(false) 的其他位置。

我可以使用 SynchronizationContext.SetSynchronizationContext 来避免使用 ConfigureAwait(false)?

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

一些开发人员这样写代码:

Task t;SynchronizationContext old = SynchronizationContext.Current;SynchronizationContext.SetSynchronizationContext();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 内的代码看到当前上下文为空。它会的。但是,上面的操作不会影响 await 在 TaskScheduler.Current 中看到的内容。因此,如果这段代码运行在一些自定义 TaskScheduler上, CallCodeThatUsesAwaitAsync 内的 await(并没有使用 ConfigureAwait(false))仍将看到当前上下文并且排队返回到自定义 TaskScheduler。

所有相同的警告也适用于之前的 Task.Run 相关的 FAQ:这样的解决方案有其潜在的影响,并且 try 中的代码还可以通过设置不同的上下文(或使用非默认的TaskScheduler调用代码)来阻止这些意图。

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

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

看出这个问题了吗?它很难发现,但影响很大。这里无法保证 await 最终会调用回调/延续原来的线程,这意味着 SynchronizationContext 回到最初的重置实际上可能不会发生在原始的线程,这可能会导致后续工作项在线程看到错误的上下文(要解决这个问题,现有精心编写的应用程序模型,并编写一个自定义上下文,添加一个方法,用于在进一步调用用户代码之前手动复位上下文)(to counteract this, well-written app models that set a custom context generally add code to manually reset it before invoking any further user code)。即使它碰巧在同一个线程上运行,也可能需要一段时间才能完成,因此上下文在一段时间内无法适当地恢复。如果它在不同的线程上运行,它可能会在该线程上设置错误的上下文,等等,这与想象的相去甚远。

我在用 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() 替换它(还要考虑是否真的希望这样阻塞)。

我知道我运行在一个永远不会有自定义 SynchronizationContext 或自定义 TaskScheduler 的环境中。我可以跳过使用 ConfigureAwait(false) 吗?

也许吧。这取决于你对“从不”的那部分有多肯定。正如在前面的常见问题中提到的,仅仅因为你工作的应用模型没有设置自定义 SynchronizationContext,也没有在自定义 TaskScheduler 上调用你的代码,并不意味着其他用户或库代码没有这样做。所以你需要确定事实并非如此,或者至少认识到可能存在的风险。

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

假的。在 .NET Core 上运行时需要它,原因和在 .NET Framework 上运行时是一样的。在这方面没有什么改变。

但是,发生变化的是某些环境是否发布了它们自己的 SynchronizationContext。统的 ASP.NET 在 .NET Framework 上有自己的 SynchronizationContext,而 ASP.NET Core 则不然。这意味着在 ASP.NET Core 中运行的代码默认不会看到自定义的 SynchronizationContext,这减少了在这样的环境中运行 ConfigureAwait(false) 的需要。

但是,这并不意味着永远不会出现自定义的 SynchronizationContext 或 TaskScheduler。如果一些用户代码(或你的应用程序正在使用的其他库代码)设置了一个自定义上下文并调用你的代码,或者在调度到自定义 TaskScheduler 的 Task 中调用你的代码,那么即使在 ASP.NET Core 中你的 await 可能会看到一个非默认的上下文或调度程序,这会导致你想要使用 ConfigureAwait(false)。当然,在这种情况下,如果您避免同步阻塞(无论如何都应该避免在 web 应用程序中这样做),如果您不介意在这种有限的情况下的小的性能开销,您可能不需要使用ConfigureAwait(false)。

当我在 await foreach IAsyncEnumerable 时,我可以使用 ConfigureAwait 吗?

是的。参见 MSDN 杂志的这篇文章中的示例(https://docs.microsoft.com/en-us/archive/msdn-magazine/2019/november/csharp-iterating-with-async-enumerables-in-csharp-8)。

await foreach 绑定到一个模式,因此,虽然它可以用于枚举 IAsyncEnumerable,但它也可以用于枚举公开正确 API 。. NET 运行时库包括 IAsyncEnumerable 上的一个 ConfigureAwait 扩展方法,该方法返回一个自定义类型,该类型封装了 IAsyncEnumerable和一个 Boolean 值,并公开了正确的模式。当编译器生成对枚举器的 MoveNextAsync 和 DisposeAsync 方法的调用时,这些调用是对返回的已配置枚举器结构类型的调用,然后编译器以所需的配置方式执行等待。

当我在 await using IAsyncDisposable 时,我可以使用 ConfigureAwait 吗?

是的,不过有点小麻烦。

和前面 FAQ 中描述的 IAsyncEnumerable 一样,.NET 运行时库在 IAsyncDisposable 上公开了一个 ConfigureAwait 扩展方法,并且 await using 将愉快地与之工作,因为它实现了适当的模式(即公开一个适当的 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 之后流到代码中,这是 bug 吗?

不,这是意料之中的。AsyncLocal数据流作为 ExecutionContext 的一部分,它与 SynchronizationContext 是分开的。除非您使用 ExecutionContext.SuppressFlow() 显式禁用了 ExecutionContext 流,否则无论是否使用 ConfigureAwait 来避免捕获原始的 SynchronizationContext, ExecutionContext(以及 AsyncLocal数据)都将始终在 await 中流动。更多信息,请看这篇博客文章 https://devblogs.microsoft.com/pfxteam/executioncontext-vs-synchronizationcontext。

语言能帮助我避免需要在我的库中显式地使用 ConfigureAwait(false) 吗?

库开发人员有时会对需要使用 ConfigureAwait(false) 表示失望,并要求使用侵入性更小的替代方案。

目前还没有,至少没有内置到语言/编译器/运行时中。然而,对于这样的解决方案可能会是什么样子,有许多建议,例如:

https://github.com/dotnet/csharplang/issues/645, https://github.com/dotnet/csharplang/issues/2542, https://github.com/dotnet/csharplang/issues/2649,

https://github.com/dotnet/csharplang/issues/2746.

如果这对你很重要,或者你觉得你在这里有新的有趣的想法,我鼓励你在这些或新的讨论中贡献你的想法。

原文链接

https://devblogs.microsoft.com/dotnet/configureawait-faq/?utm_source=vs_developer_news&utm_medium=referral









Tags:

最近发表
标签列表