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

Eug*_*kov 8 .net c# multithreading deadlock asynchronous

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

    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;
    }
Run Code Online (Sandbox Code Playgroud)

这段代码肯定会死锁(与SyncronizationContext环境-它就像ASP.NET单个线程调度任务),如果调用这样的:BadAssAsync().Result

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

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

            var task = taskGen();

            return task.Result;
        }).Result;
    }
Run Code Online (Sandbox Code Playgroud)

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

这是调试输出:

***(工作正常)
开始:TID:17;SCTX:System.Web.AspNetSynchronizationContext;调度程序:System.Threading.Tasks.ThreadPoolTask​​Scheduler
运行:TID:45;SCTX:<null> 调度程序:System.Threading.Tasks.ThreadPoolTask​​Scheduler
等待之前:TID:45;SCTX:<null> 调度程序:System.Threading.Tasks.ThreadPoolTask​​Scheduler
等待后:TID:37;SCTX:<null> 调度程序:System.Threading.Tasks.ThreadPoolTask​​Scheduler
第二次等待后:TID:37;SCTX:<null> 调度程序:System.Threading.Tasks.ThreadPoolTask​​Scheduler

***(死锁)
开始:TID:48;SCTX:System.Web.AspNetSynchronizationContext;调度程序:System.Threading.Tasks.ThreadPoolTask​​Scheduler
运行:TID:48;SCTX:System.Web.AspNetSynchronizationContext;调度程序:System.Threading.Tasks.ThreadPoolTask​​Scheduler
等待之前:TID:48;SCTX:System.Web.AspNetSynchronizationContext;调度程序:System.Threading.Tasks.ThreadPoolTask​​Scheduler

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

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

这是 WebAPI 控制器的完整示例代码:https ://pastebin.com/44RP34Ye和完整示例代码在这里

更新。这是较短的控制台应用程序代码示例,它重现了问题的根本原因——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();
}
Run Code Online (Sandbox Code Playgroud)
基地:TID:1;SCTX:<null> 调度程序:System.Threading.Tasks.ThreadPoolTask​​Scheduler
T1:TID:3;SCTX:<null> 调度程序:System.Threading.Tasks.ThreadPoolTask​​Scheduler
T2:TID:3;SCTX:<null> 调度程序:System.Threading.Tasks.ThreadPoolTask​​Scheduler

Eug*_*kov 3

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

\n\n

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

\n\n
\n

Wait 可以简单地阻塞某些同步原语,直到目标任务完成,并且在某些情况下,\xe2\x80\x99 正是它所做的事情。\n 但是阻塞线程是一项昂贵的冒险,因为线程会占用\n大量的系统资源,并且被阻塞的线程在\xe2\x80\x99s能够继续执行有用的工作之前是很沉重的\n。相反,Wait\n 更喜欢执行有用的工作而不是阻塞,并且它有触手可及的有用工作:正在等待的任务。如果正在等待\xe2\x80\x99d 的任务已经开始执行,则等待必须阻塞。但是,如果\xe2\x80\x99t 尚未开始执行,Wait 可能能够将目标任务从其排队的调度程序中拉出,并在当前线程上内联执行它。

\n
\n\n

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

\n

  • IMO,教训是技巧永远不可靠。如果您想要手动线程管理,请明确执行,而不是(尝试)依赖其他方法的副作用。 (3认同)