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

Task.Run continues on the same thread causing deadlock

考虑以下我将同步等待的异步方法。等一下,我知道。我知道这被认为是不好的做法 causes deadlocks, but I'm fully conscious of that and taking measures to prevent deadlocks via wrapping code with .

    private async Task<string> 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.

我面临的问题是,即使使用这个 "safe" 包装器,它仍然偶尔会死锁。

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

            var task = taskGen();

            return task.Result;
        }).Result;
    }

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

调试输出如下:

*** (worked fine)
START: TID: 17; SCTX: System.Web.AspNetSynchronizationContext; SCHEDULER: System.Threading.Tasks.ThreadPoolTaskScheduler
RUN: TID: 45; SCTX: <null> SCHEDULER: System.Threading.Tasks.ThreadPoolTaskScheduler
BEFORE AWAIT: TID: 45; SCTX: <null> SCHEDULER: System.Threading.Tasks.ThreadPoolTaskScheduler
AFTER AWAIT: TID: 37; SCTX: <null> SCHEDULER: System.Threading.Tasks.ThreadPoolTaskScheduler
AFTER SECOND AWAIT: TID: 37; SCTX: <null> SCHEDULER: System.Threading.Tasks.ThreadPoolTaskScheduler

*** (deadlocked)
START: TID: 48; SCTX: System.Web.AspNetSynchronizationContext; SCHEDULER: System.Threading.Tasks.ThreadPoolTaskScheduler
RUN: TID: 48; SCTX: System.Web.AspNetSynchronizationContext; SCHEDULER: System.Threading.Tasks.ThreadPoolTaskScheduler
BEFORE AWAIT: TID: 48; SCTX: System.Web.AspNetSynchronizationContext; SCHEDULER: System.Threading.Tasks.ThreadPoolTaskScheduler

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

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

以下是 WebAPI 控制器的完整示例代码:https://pastebin.com/44RP34Ye and full sample code here

更新。 这是重现问题根本原因的较短的控制台应用程序代码示例——在等待的调用线程上安排 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: <null> SCHEDULER: System.Threading.Tasks.ThreadPoolTaskScheduler
T1: TID: 3; SCTX: <null> SCHEDULER: System.Threading.Tasks.ThreadPoolTaskScheduler
T2: TID: 3; SCTX: <null> SCHEDULER: System.Threading.Tasks.ThreadPoolTaskScheduler

我们和我的一个好朋友通过 inspecting stack traces and reading .net reference source 解决了这个问题。很明显,问题的根本原因是 Task.Run 的有效负载正在任务上调用 Wait 的线程上执行。事实证明,这是 TPL 进行的性能优化,目的是不启动额外的线程并防止宝贵的线程无所事事。

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

Wait could simply block on some synchronization primitive until the target Task completed, and in some cases that’s exactly what it does. But blocking threads is an expensive venture, in that a thread ties up a good chunk of system resources, and a blocked thread is dead weight until it’s able to continue executing useful work. Instead, Wait prefers to execute useful work rather than blocking, and it has useful work at its finger tips: the Task being waited on. If the Task being Wait’d on has already started execution, Wait has to block. However, if it hasn’t started executing, Wait may be able to pull the target task out of the scheduler to which it was queued and execute it inline on the current thread.

教训:如果你真的需要同步等待异步工作,Task.Run 的技巧并不可靠。您必须将 SyncronizationContext 清零,等待,然后 return SyncronizationContext 返回。

在 IdentityServer I 的内部found另一种有效的技术。它非常接近问题中规定的不可靠技术。您将在末尾或此答案中找到源代码。

这项技术的神奇之处应归功于 Unwrap() method. Internally when called against Task<Task<T>> it creates a new "promise task”,它会在两个(我们执行的任务和嵌套的任务)任务完成后立即完成。

之所以可行并且不会产生死锁的可能性很简单——承诺任务是 no subjects for inlining,这是有道理的,因为没有要内联的 "work"。反过来,这意味着我们阻塞当前线程并让默认调度程序 (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<Task> func)
    {
        _myTaskFactory.StartNew(func).Unwrap().GetAwaiter().GetResult();
    }

    public static TResult RunSync<TResult>(Func<Task<TResult>> func)
    {
        return _myTaskFactory.StartNew(func).Unwrap().GetAwaiter().GetResult();
    }
}

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

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