Task.Run继续在同一个线程上导致死锁

考虑以下我将要同步等待的异步方法。 等一下,我知道。 我知道它被认为是不好的做法并导致死锁 ,但我完全意识到这一点并采取措施通过使用Task.Run包装代码来防止死锁。

private async Task BadAssAsync() { HttpClient client = new HttpClient(); WriteInfo("BEFORE AWAIT"); var response = await client.GetAsync("http://google.com"); WriteInfo("AFTER AWAIT"); string content = await response.Content.ReadAsStringAsync(); WriteInfo("AFTER SECOND AWAIT"); return content; } 

这个代码肯定会死锁(在具有SyncronizationContext的环境中,如在ASP.NET之类的单个线程上调度任务),如果这样调用: BadAssAsync().Result

我面临的问题是即使使用这种“安全”包装器,它仍然偶尔会出现死锁。

  private T Wait1(Func<Task> taskGen) { return Task.Run(() => { WriteInfo("RUN"); var task = taskGen(); return task.Result; }).Result; } 

这些“WriteInfo”行有目的。 这些调试行让我看到它偶尔发生的原因是Task.Run中的代码,由一些神秘的Task.Run ,由开始提供请求的同一个线程执行。 这意味着将AspNetSynchronizationContext作为SyncronizationContext并且肯定会死锁。

这是调试输出:

  ***(工作正常)
 START:TID:17;  SCTX:System.Web.AspNetSynchronizationContext;  SCHEDULER:System.Threading.Tasks.ThreadPoolTask​​Scheduler
跑:TID:45;  SCTX: SCHEDULER:System.Threading.Tasks.ThreadPoolTask​​Scheduler
在AWAIT之前:TID:45;  SCTX: SCHEDULER:System.Threading.Tasks.ThreadPoolTask​​Scheduler
之后:TID:37;  SCTX: SCHEDULER:System.Threading.Tasks.ThreadPoolTask​​Scheduler
在第二次之后:TID:37;  SCTX: SCHEDULER:System.Threading.Tasks.ThreadPoolTask​​Scheduler

 ***(僵局)
 START:TID:48;  SCTX:System.Web.AspNetSynchronizationContext;  SCHEDULER:System.Threading.Tasks.ThreadPoolTask​​Scheduler
跑:TID:48;  SCTX:System.Web.AspNetSynchronizationContext;  SCHEDULER:System.Threading.Tasks.ThreadPoolTask​​Scheduler
在AWAIT之前:TID:48;  SCTX:System.Web.AspNetSynchronizationContext;  SCHEDULER:System.Threading.Tasks.ThreadPoolTask​​Scheduler 

注意, Task.Run()代码在TID = 48的同一个线程上继续。

问题是为什么会发生这种情况? 为什么Task.Run在同一个线程上运行代码,允许SyncronizationContext仍然有效?

以下是WebAPI控制器的完整示例代码: https ://pastebin.com/44RP34Ye和完整的示例代码。

UPDATE。 以下是更短的控制台应用程序代码示例,它可以重现问题的根本原因 – 在等待的调用线程上调度Task.Run委托。 怎么可能?

 static void Main(string[] args) { WriteInfo("\n***\nBASE"); var t1 = Task.Run(() => { WriteInfo("T1"); Task t2 = Task.Run(() => { WriteInfo("T2"); }); t2.Wait(); }); t1.Wait(); } 
  BASE:TID:1;  SCTX: SCHEDULER:System.Threading.Tasks.ThreadPoolTask​​Scheduler
 T1:TID:3;  SCTX: SCHEDULER:System.Threading.Tasks.ThreadPoolTask​​Scheduler
 T2:TID:3;  SCTX: SCHEDULER:System.Threading.Tasks.ThreadPoolTask​​Scheduler 

我们和我的一位好朋友通过检查堆栈跟踪和读取.net 参考源来解决这个问题 。 很明显,问题的根本原因是Task.Run的有效负载正在对任务调用Wait的线程上执行。 事实certificate,这是由TPL进行的性能优化,以便不会激活额外的线程并防止宝贵的线程无所事事。

以下是Stephen Toub撰写的一篇文章,描述了这种行为: https : //blogs.msdn.microsoft.com/pfxteam/2009/10/15/task-wait-and-inlining/ 。

等待可能只是阻止一些同步原语,直到目标任务完成,在某些情况下,它正是它的作用。 但是阻塞线程是一个昂贵的冒险,因为一个线程占用了大量的系统资源,并且一个被阻塞的线程是自重的,直到它能够继续执行有用的工作。 相反,Wait倾向于执行有用的工作而不是阻塞,并且它的指尖有用:正在等待的任务。 如果正在等待的任务已经开始执行,则Wait必须阻止。 但是,如果它尚未开始执行,则Wait可能能够将目标任务从其排队的调度程序中拉出,并在当前线程上内联执行。

课程:如果你真的需要同步等待异步工作,那么使用Task.Run的技巧是不可靠的。 您必须将SyncronizationContext归零,等待,然后返回SyncronizationContext

在IdentityServer的内部,我发现了 另一种有效的技术 。 这个问题非常接近于不可靠的技术。 您将在最后找到源代码或此答案。

对于这种技术的魔力应该Unwrap()方法。 在内部调用Task>它会创建一个新的“ promise任务 ”,一旦两者(我们执行的和嵌套的)任务完成,它就会完成。

这样做的原因并不会产生死锁的可能性很简单 – 承诺任务不是内联的主题 ,这是有意义的,因为内联没有“工作”。 反过来,这意味着我们阻止当前线程并让缺省调度程序( ThreadPoolTaskScheduler )在没有SynchronizationContext情况下在新线程中完成工作。

 internal static class AsyncHelper { private static readonly TaskFactory _myTaskFactory = new TaskFactory(CancellationToken.None, TaskCreationOptions.None, TaskContinuationOptions.None, TaskScheduler.Default); public static void RunSync(Func func) { _myTaskFactory.StartNew(func).Unwrap().GetAwaiter().GetResult(); } public static TResult RunSync(Func> func) { return _myTaskFactory.StartNew(func).Unwrap().GetAwaiter().GetResult(); } } 

此外,还有一个Task.Run的签名,它隐式执行Unwrap ,这导致下面的最短安全实现。

 T SyncWait(Func> f) => Task.Run(f).Result;