我们哪个不割呢? 我经常遇到异步代码中的错误,然后自己处理。 为了阻止轮回,我与您分享了有时很难抓住和修复的最典型的门框。

本文的灵感来自斯蒂芬·克拉里(Stephen Clary)的博客 ,该人对竞争,异步,多线程和其他令人生畏的词汇一无所知。 他是《 C#Cookbook中的并发性》一书的作者,该书收集了大量用于竞争的模式。
经典异步死锁
要了解异步死锁,值得弄清楚哪个线程执行使用await关键字调用的方法。
首先,该方法将深入研究异步方法的调用链,直到遇到异步源。 如何确切地实现异步源是超出本文讨论范围的主题。 现在为简单起见,我们假设这是一个在等待结果(例如数据库请求或HTTP请求)时不需要工作流的操作。 此类操作的同步启动意味着在等待系统中的结果时,将至少有一个进入睡眠状态的线程正在消耗资源,但没有做任何有用的工作。
在异步调用中,我们有点中断了异步操作的“之前”和“之后”命令的执行流程,在.NET中,不能保证等待后的代码将与等待前的代码在同一线程中执行。 在大多数情况下,这不是必需的,但是当这种行为对于程序正常运行至关重要时,该怎么办? 需要使用SynchronizationContext
。 这是一种允许您对执行代码的线程施加某些限制的机制。 接下来,我们将处理两个同步上下文( WindowsFormsSynchronizationContext
和AspNetSynchronizationContext
),但是Alex Davis在他的书中写道,.NET中大约有十二个。 关于SynchronizationContext
在此处 , 此处和此处 SynchronizationContext
写得很好,作者已经实现了自己的实现,对此他表示敬意。
因此,一旦代码到达异步源,它将保存同步上下文,该上下文位于SynchronizationContext.Current
的线程静态属性中,然后异步操作将启动并释放当前线程。 换句话说,在等待异步操作完成时,我们不会阻塞单个线程,与同步操作相比,这是异步操作的主要收益。 完成异步操作后,我们必须遵循异步源之后的指令,在这里,为了确定异步操作后在哪个线程中执行代码,我们需要查阅之前保存的同步上下文。 正如他所说,我们将这样做。 他将告诉您在等待之前与代码在同一线程中执行-我们将在同一线程中执行,而不是说-我们将从池中获取第一个线程。
但是,如果在这种特殊情况下,对我们而言重要的是,在线程池中的任何空闲线程中执行await之后的代码,那该怎么办? 您需要使用ConfigureAwait(false)
咒语ConfigureAwait(false)
。 传递给continueOnCapturedContext
参数的false值告诉系统可以使用池中的任何线程。 如果在执行带有await的方法时根本没有同步上下文( SynchronizationContext.Current == null
),例如在控制台应用程序中,会发生什么。 在这种情况下,与在ConfigureAwait(false)
的情况下一样,我们对等待后在其中执行代码的线程没有任何限制,并且系统将从池中获取第一个线程。
那么什么是异步死锁?
WPF和WinForms中的死锁
WPF和WinForms应用程序之间的区别是非常同步的上下文。 WPF和WinForms的同步上下文有一个特殊的线程-用户界面线程。 每个SynchronizationContext
有一个UI线程,并且只有该线程可以与用户界面元素进行交互。 默认情况下,在UI线程中开始工作的代码将在其中执行异步操作后恢复操作。
现在让我们看一个例子:
private void Button_Click(object sender, System.Windows.RoutedEventArgs e) { StartWork().Wait(); } private async Task StartWork() { await Task.Delay(100); var s = "Just to illustrate the instruction following await"; }
调用
StartWork().Wait()
时会发生什么:
- 调用线程(这是用户界面线程)将进入
StartWork
方法并StartWork
await Task.Delay(100)
指令。 - UI线程将启动异步
Task.Delay(100)
操作,并将控制权返回给Button_Click
方法,然后Task
类的Wait()
方法将在等待它。 当调用Wait()
方法时,UI线程将阻塞直到异步操作结束,并且我们希望UI线程一旦完成,将立即开始执行并进一步处理代码,但是,一切都会出错。 - 一旦
Task.Delay(100)
完成,UI线程将首先必须继续执行StartWork()
方法,为此,它恰好需要开始执行的线程。 但是,UI线程现在正在等待操作结果。 StartWork()
: StartWork()
无法继续执行并返回结果,而Button_Click
正在等待相同的结果,并且由于执行是在用户界面线程中启动的事实,因此应用程序只是挂起而没有机会继续工作。
通过
Task.Delay(100)
的调用更改为
Task.Delay(100)
Task.Delay(100).ConfigureAwait(false)
可以非常简单地处理这种情况:
private void Button_Click(object sender, System.Windows.RoutedEventArgs e) { StartWork().Wait(); } private async Task StartWork() { await Task.Delay(100).ConfigureAwait(false); var s = "Just to illustrate the instruction following await"; }
这段代码不会死锁,因为现在池中的线程可以用来完成StartWork()
方法,而不是被阻止的UI线程。 Stephen Clary建议在他的博客的所有“库方法”中使用ConfigureAwait(false)
,但特别强调指出,使用ConfigureAwait(false)
处理死锁不是一个好习惯。 相反,他建议不要使用诸如Wait()
, Result
, GetAwaiter().GetResult()
类的阻塞方法,并尽可能GetAwaiter().GetResult()
所有方法GetAwaiter().GetResult()
为使用async / await(即所谓的“一直异步”原则)。
ASP.NET中的死锁
ASP.NET也有一个同步上下文,但是它的限制略有不同。 它允许您一次仅在每个请求中使用一个线程,并且还要求等待后的代码与等待前的代码在同一线程中执行。
一个例子:
public class HomeController : Controller { public ActionResult Deadlock() { StartWork().Wait(); return View(); } private async Task StartWork() { await Task.Delay(100); var s = "Just to illustrate the code following await"; } }
该代码也会导致死锁,因为在调用StartWork().Wait()
将阻塞唯一允许的线程,并等待StartWork()
操作StartWork()
,并且永远不会结束,因为应继续执行的线程很忙等待中。
所有这些都由相同的ConfigureAwait(false)
修复。
ASP.NET Core中的死锁(实际上不是)
现在,让我们尝试在ASP.NET Core项目中运行来自ASP.NET示例的代码。 如果这样做,我们将看到不会出现死锁。 这是因为ASP.NET Core 没有同步上下文 。 太好了! 现在您可以通过阻塞调用覆盖代码,而不必担心死锁了? 严格来说,是的,但是请记住,这会导致线程在等待时进入睡眠状态,也就是说,线程会消耗资源,但不会做任何有用的工作。
请记住,阻塞调用的使用消除了异步编程将其转变为同步的所有优点 。 是的,有时不使用Wait()
不能编写程序,但是原因必须很严重。
错误使用Task.Run()
创建Task.Run()
方法以在新线程中启动操作。 就像用TAP模式编写的方法一样,它返回Task
或Task<T>
并且初次面对异步/等待的人非常希望将同步代码包装在Task.Run()
并保留此方法的结果。 该代码似乎变得异步了,但是实际上没有任何改变。 让我们看看使用Task.Run()
会发生什么。
一个例子:
private static async Task ExecuteOperation() { Console.WriteLine($"Before: {Thread.CurrentThread.ManagedThreadId}"); await Task.Run(() => { Console.WriteLine($"Inside before sleep: {Thread.CurrentThread.ManagedThreadId}"); Thread.Sleep(1000); Console.WriteLine($"Inside after sleep: {Thread.CurrentThread.ManagedThreadId}"); }); Console.WriteLine($"After: {Thread.CurrentThread.ManagedThreadId}"); }
该代码的结果将是:
Before: 1 Inside before sleep: 3 Inside after sleep: 3 After: 3
此处Thread.Sleep(1000)
是某种同步操作,需要一个线程来完成。 假设我们要使我们的解决方案异步,以便可以使该操作安乐死,我们将其包装在Task.Run()
。
一旦代码到达Task.Run()
方法,就会从线程池中获取另一个线程,并在其中传递我们传递给Task.Run()
的代码。 像一个不错的线程一样,旧线程返回到池中,并等待再次调用它以完成工作。 新线程执行所传输的代码,到达同步操作,同步执行它(等待直到操作完成),然后沿代码继续进行。 换句话说,操作保持同步:与以前一样,我们在执行同步操作期间使用流。 唯一的区别是,在调用Task.Run()
并返回ExecuteOperation()
时,我们花费了时间来切换上下文。 一切都变得更糟了。
应该理解的是,尽管在“ Inside after sleep: 3
和Inside after sleep: 3
这行中我们看到了流的相同ID,但是在这些地方执行上下文完全不同。 ASP.NET比我们更聪明,当将上下文从Task.Run()
代码切换到外部代码时,ASP.NET试图节省资源。 在这里,他决定至少不改变执行流程。
在这种情况下,使用Task.Run()
毫无意义。 相反,Clary 建议使所有操作异步化,也就是说,在我们的示例中,用Task.Delay(1000)
替换Thread.Sleep(1000)
Task.Delay(1000)
,但这当然并非总是可能的。 如果我们使用无法或不希望重写并最终使异步异步的第三方库,但由于某种原因或我们需要异步方法,该怎么办? 最好使用Task.FromResult()
将供应商方法的结果包装在Task中。 当然,这不会使代码异步,但是我们至少会节省上下文切换。
为什么然后使用Task.Run()? 答案很简单:对于需要CPU约束的操作,当您需要保持UI的响应性或并行化计算时。 这里必须说,CPU绑定操作本质上是同步的。 发明Task.Run()
是以异步方式启动同步操作。
异步无效的滥用
为了编写异步事件处理程序,添加了编写返回
void
异步方法的功能。 让我们看看为什么将它们用于其他目的会引起混乱:
- 您不能等待结果。
- 不支持通过try-catch进行异常处理。
- 无法通过
Task.WhenAll()
, Task.WhenAny()
和其他类似方法来组合调用。
在所有这些原因中,最有趣的一点是异常的处理。 事实是,在返回Task
或Task<T>
异步方法中,异常被捕获并包装在Task
对象中,然后将其传递给调用方法。 Clary在她的MSDN文章中写道,由于async-void方法中没有返回值,因此没有任何要包装的异常,它们直接在同步上下文中抛出。 结果是未处理的异常,由于该异常导致进程崩溃,因此有时间将错误写入控制台。 您可以通过订阅AppDomain.UnhandledException
事件来获取和保留此类异常,但是即使在该事件的处理程序中,您也将不再能够停止进程崩溃。 此行为仅对于事件处理程序而言是典型的,但对于通常的方法则不是,因此我们期望通过try-catch进行标准异常处理的可能性。
例如,如果您在ASP.NET Core应用程序中这样编写,则该过程肯定会失败:
public IActionResult ThrowInAsyncVoid() { ThrowAsynchronously(); return View(); } private async void ThrowAsynchronously() { throw new Exception("Obviously, something happened"); }
但是值得将ThrowAsynchronously
方法的返回类型更改为Task
(甚至不添加await关键字),并且标准ASP.NET Core错误处理程序将捕获异常,并且尽管执行该过程,该过程仍将继续存在。
请谨慎使用async-void方法 -它们可能会使您陷入困境。
单线等待
最后一个反模式并不像以前那样可怕。 最重要的是,在例如仅转发另一个异步方法的结果的方法中使用async / await毫无意义,但在using 中可能会使用await 。
代替此代码:
public async Task MyMethodAsync() { await Task.Delay(1000); }
完全有可能(最好是这样)写:
public Task MyMethodAsync() { return Task.Delay(1000); }
为什么行得通? 因为await关键字可以应用于类似Task的对象,而不应用于标有async关键字的方法。 反过来,async关键字只是告诉编译器该方法需要部署到状态机,并且所有返回的值都应包装在一个Task
(或另一个类似Task的对象)中。
换句话说,方法的第一个版本的结果是Task
,等待Task.Delay(1000)
结束后将变为Completed
,而方法的第二个版本的结果是Task
,由Task.Delay(1000)
返回,一旦经过1000毫秒,它将变为Completed
。
如您所见,这两个版本是等效的,但是同时,第一个版本需要更多的资源来创建异步“ body kit”。
Alex Davis写道, 直接调用异步方法的成本可能是调用同步方法的成本的十倍 ,因此有一些需要尝试的地方。
UPD:正如评论正确指出的那样,从单行方法中删除异步/等待会导致负面影响。 例如,引发异常时,引发Task的方法在堆栈中将不可见。 因此,
默认情况下不建议删除默认值 。
克拉里的帖子解析。