顶层请求上的ConfigureAwait(false)

29

我正在尝试弄清楚是否应该在顶层请求上使用ConfigureAwait(false)。阅读这篇来自该主题某种权威的文章:

http://blog.stephencleary.com/2012/07/dont-block-on-async-code.html

...他建议像这样做:

public async Task<JsonResult> MyControllerAction(...)
{
    try
    {
        var report = await _adapter.GetReportAsync();
        return Json(report, JsonRequestBehavior.AllowGet);
    }
    catch (Exception ex)
    {
        return Json("myerror", JsonRequestBehavior.AllowGet);  // really slow without configure await
    }
}

public async Task<TodaysActivityRawSummary> GetReportAsync()
{           
    var data = await GetData().ConfigureAwait(false);

    return data
}

...它建议在除了顶层调用之外的每个await上使用ConfigureAwait(false)。 然而,这样做后,我的异常需要几秒钟才能返回给调用方,相比于不使用它并立刻返回结果。

MVC控制器操作调用异步方法的最佳实践是什么?我应该在控制器本身中使用ConfigureAwait,还是只在使用await请求数据等的服务调用中使用它?如果我在顶层调用上不使用它,那么等待几秒钟来处理异常似乎是有问题的。我不需要HttpContext,我已经看到其他帖子说如果不需要上下文就始终使用ConfigureAwait(false)。

更新: 我在我的调用链中漏掉了ConfigureAwait(false),导致异常无法立即返回。但是,与发布的问题一样,是否应该在顶层使用ConfigureAwait(false)。


1
你能否发布一个最小化、可重现的示例,以展示时间差异? - Stephen Cleary
@Stepen Cleary,我意识到我的链中某处缺少了ConfiguredAwait。对于造成的混淆我很抱歉。现在异常会立即返回。但我仍在努力弄清楚为什么它应该从顶层省略。也许我错过了什么,但我不认为博客真正解释了这一点。 - KingOfHypocrites
@StephenCleary也并不清楚如果只有一个等待而不是一系列等待你会做什么,如果您仍然在顶层使用ConfigureAwait(false)。例如将某些内容推送到服务总线,这种情况下您正在调用第三方API,并且这是在链中唯一可见的await。 - KingOfHypocrites
2个回答

32

这是一个高流量的网站吗?可能的解释之一是,在不使用ConfigureAwait(false)时,您遇到了ThreadPool饥饿现象。如果没有使用ConfigureAwait(false)await继续通过AspNetSynchronizationContext.Post排队,其实现可归结为this

Task newTask = _lastScheduledTask.ContinueWith(_ => SafeWrapCallback(action));
_lastScheduledTask = newTask; // the newly-created task is now the last one

在这里,使用ContinueWith而不使用TaskContinuationOptions.ExecuteSynchronously(我猜测是为了使连续操作真正异步并减少低堆栈条件的机会)。因此,它从ThreadPool获取一个空闲线程来执行连续操作。理论上,它可能会发生在与await的前置任务完成的线程相同的线程上,但很可能是另一个线程。
此时,如果ASP.NET线程池饥饿(或必须增长以容纳新线程请求),您可能会遇到延迟。值得注意的是,线程池由两个子池组成:IOCP线程和工作线程(请查看thisthis获取一些额外的细节)。您的GetReportAsync操作可能会在IOCP线程子池上完成,该子池似乎没有饥饿。另一方面,在工作线程子池上运行ContinueWith连续操作,在您的情况下似乎正在饥饿。
如果一直使用ConfigureAwait(false),那么这种情况不会发生。在这种情况下,所有的await连续体都将在相应的前置任务结束时同步运行在同一线程上,无论是IOCP还是工作线程。
您可以比较有和没有使用ConfigureAwait(false)的情况下的线程使用情况。我希望当不使用ConfigureAwait(false)时,这个数字会更大:
catch (Exception ex)
{
    Log("Total number of threads in use={0}",
        Process.GetCurrentProcess().Threads.Count);
    return Json("myerror", JsonRequestBehavior.AllowGet);  // really slow without configure await
}

您还可以尝试增加ASP.NET线程池的大小(仅用于诊断目的,而非最终解决方案),以查看是否确实存在所描述的情况:

<configuration>
  <system.web>
    <applicationPool 
        maxConcurrentRequestsPerCPU="6000"
        maxConcurrentThreadsPerCPU="0" 
        requestQueueLimit="6000" />
  </system.web>
</configuration>

更新以回应评论:

我意识到我在链中漏掉了一个ContinueAwait。现在即使顶层不使用ConfigureAwait(false),在抛出异常时也可以正常工作。

这表明您的代码或正在使用的第三方库可能正在使用阻塞构造(Task.ResultTask.WaitWaitHandle.WaitOne,可能带有一些添加的超时逻辑)。您是否寻找过这些?尝试来自此更新底部的Task.Run建议。此外,我仍然会进行线程计数诊断,以排除线程池饥饿/卡顿。

那么你是说如果我即使在顶层使用ContinueAwait,我也会失去异步的全部好处吗?

不,我不是这么说的。async的整个目的是避免在等待某些东西时阻塞线程,并且无论是否添加ContinueAwait(false)都可以实现该目标。

我想说的是,不使用ConfigureAwait(false)可能会引入冗余的上下文切换(通常意味着线程切换),如果线程池正在满负荷工作,则可能会成为ASP.NET中的问题。尽管如此,在服务器可扩展性方面,冗余的线程切换仍然比阻塞的线程要好。
公平地说,使用ContinueAwait(false)也可能导致冗余的上下文切换,特别是如果在调用链中使用不一致的话。
话虽如此,ContinueAwait(false)经常被错误地用作解决由异步代码阻塞引起的死锁问题的方法。这就是为什么我建议在整个代码库中查找这些阻塞构造的原因。

然而,问题仍然存在,即是否应该在顶层使用ConfigureAwait(false)。

我希望Stephen Cleary能更好地解释这一点,但这是我的想法。

总是有一些“超级顶级”的代码调用你的顶级代码。例如,在UI应用程序的情况下,它是框架代码,调用async void事件处理程序。在ASP.NET的情况下,它是异步控制器的BeginExecute。这是“超级顶级代码”的责任,确保一旦您的异步任务完成,继续运行(如果有的话)在正确的同步上下文中运行。这不是您的任务代码的责任。例如,可能根本没有继续,就像使用快速且不关心恢复上下文的async void事件处理程序一样。

因此,在您的顶级方法中,如果您不关心await继续的上下文,请尽快使用ConfigureAwait(false)

此外,如果您正在使用一个已知为上下文不敏感但仍可能不一致地使用 ConfigureAwait(false) 的第三方库,您可能希望将调用包装在 Task.Run 或类似 WithNoContext 的东西中。您可以这样做来提前从上下文中获取异步调用链。
var report = await Task.Run(() =>
    _adapter.GetReportAsync()).ConfigureAwait(false);
return Json(report, JsonRequestBehavior.AllowGet);

这样做会引入额外的线程切换,但如果在GetReportAsync或其子调用中不一致地使用ConfigureAwait(false),则可能会节省更多此类线程切换的时间。它还可以作为解决由调用链内的阻塞构造引起的潜在死锁的一种方法(如果有)。请注意,在ASP.NET中,HttpContext.Current不是唯一使用AspNetSynchronizationContext流传的静态属性。例如,还有Thread.CurrentThread.CurrentCulture。确保您真的不关心上下文丢失。
更新以回答评论:
“为了获得加分,也许您可以解释ConfigureAwait(false)的影响……没有保存哪个上下文……只是HttpContext还是类对象的局部变量等。”
所有异步方法的本地变量都会跨越await而保留,隐式的this引用也是如此——这是设计如此。实际上,它们被捕获到一个编译器生成的异步状态机结构中,因此从技术上讲,它们并不驻留在当前线程的堆栈上。在某种程度上,这类似于C#委托如何捕获本地变量。事实上,await继续回调本身就是一个传递给ICriticalNotifyCompletion.UnsafeOnCompleted(由等待对象实现;对于Task,它是TaskAwaiter;使用ConfigureAwait,它是ConfiguredTaskAwaitable)的委托。

另一方面,大多数的全局状态(静态/TLS变量、静态类属性)并不会自动地在等待中流动。实际上被流动的内容取决于特定的同步上下文。在没有同步上下文的情况下(或使用ConfigureAwait(false)时),唯一与之保留的全局状态是由ExecutionContext流动的内容。微软的Stephen Toub在他的一篇文章中对此进行了很好的阐述:"ExecutionContext vs SynchronizationContext"。他提到了SecurityContextThread.CurrentPrincipal,这对于安全非常重要。除此之外,我不知道有任何正式记录和完整列表列出由ExecutionContext流动的全局状态属性。

您可以查看 ExecutionContext.Capture 源代码 以了解更多关于流动的具体内容,但是您不应该依赖于这个特定的实现。相反,您可以始终创建自己的全局状态流逻辑,使用像 Stephen Cleary 的 AsyncLocal(或 .NET 4.6 AsyncLocal<T>)之类的东西。
或者,为了达到极致,您也可以完全放弃 ContinueAwait 并创建一个自定义的等待器,例如像这样的 ContinueOnScope。这将允许精确控制要在哪个线程/上下文上继续执行以及要流动的状态。

1
关于这个问题:“我仍然不清楚为什么Stephen说不要在顶层使用它。” 我认为这是因为在顶层,您通常会异步地处理数据/结果。例如,您可能会更新UI,并希望在正确的上下文中在UI线程上发生这种情况。显然,这不适用于您的MVC控制器方法。@StephenCleary 可能想要介入并纠正我。 - noseratio - open to work
1
@KingOfHypocrites,很高兴能帮到你。我忘了一件事,检查一下你的web.config文件中是否有<add key="aspnet:UseTaskFriendlySynchronizationContext" value="true" />。默认情况下应该会有,但最好还是检查一下。 - noseratio - open to work
1
谢谢你的提示。我注意到这个设置缺失后进行了一些研究,看起来根据StephenCleary的说法,在.NET 4.5中默认已启用此功能。http://firstbestanswer.com/question/2drgnv/aspnet-vs-mvc-vs-webapi-usetaskfriendlysynchronizationcontext.html - KingOfHypocrites

24
然而,问题仍然存在,即是否应该在顶层使用ConfigureAwait(false)。
对于ConfigureAwait(false)的经验法则是:只要您的方法其余部分不需要上下文,请使用它。
在ASP.NET中,“上下文”实际上没有明确定义。它确实包括像HttpContext.Current、用户主体和用户文化等内容。
所以,问题实际上是:“Controller.Json是否需要ASP.NET上下文?” 当然可能Json不关心上下文(因为它可以从自己的控制器成员编写当前响应),但另一方面,它确实执行“格式设置”,这可能需要恢复用户文化。
我不知道Json是否需要上下文,但没有明确说明,通常我假设任何调用ASP.NET代码的调用都可能依赖于上下文。因此,为了安全起见,我不会在我的控制器代码的顶级中使用ConfigureAwait(false)。

感谢您的澄清! - KingOfHypocrites

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接