这是一个高流量的网站吗?可能的解释之一是,在不使用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线程和工作线程(请查看
this和
this获取一些额外的细节)。您的
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);
}
您还可以尝试增加ASP.NET线程池的大小(仅用于诊断目的,而非最终解决方案),以查看是否确实存在所描述的情况:
<configuration>
<system.web>
<applicationPool
maxConcurrentRequestsPerCPU="6000"
maxConcurrentThreadsPerCPU="0"
requestQueueLimit="6000" />
</system.web>
</configuration>
更新以回应评论:
我意识到我在链中漏掉了一个ContinueAwait。现在即使顶层不使用ConfigureAwait(false),在抛出异常时也可以正常工作。
这表明您的代码或正在使用的第三方库可能正在使用阻塞构造(Task.Result
,Task.Wait
,WaitHandle.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"。他提到了SecurityContext
和Thread.CurrentPrincipal
,这对于安全非常重要。除此之外,我不知道有任何正式记录和完整列表列出由ExecutionContext
流动的全局状态属性。
您可以查看
ExecutionContext.Capture
源代码 以了解更多关于流动的具体内容,但是您不应该依赖于这个特定的实现。相反,您可以始终创建自己的全局状态流逻辑,使用像 Stephen Cleary 的
AsyncLocal
(或 .NET 4.6
AsyncLocal<T>
)之类的东西。
或者,为了达到极致,您也可以完全放弃
ContinueAwait
并创建一个自定义的等待器,例如像这样的
ContinueOnScope
。这将允许精确控制要在哪个线程/上下文上继续执行以及要流动的状态。