尽管屈服,意外的堆栈溢出

nos*_*tio 9 .net c# multithreading task-parallel-library async-await

为什么以下异步递归失败StackOverflowException,为什么当计数器变为零时,它恰好发生在最后一步?

static async Task<int> TestAsync(int c)
{
    if (c < 0)
        return c;

    Console.WriteLine(new { c, where = "before", Environment.CurrentManagedThreadId });

    await Task.Yield();

    Console.WriteLine(new { c, where = "after", Environment.CurrentManagedThreadId });

    return await TestAsync(c-1);
}

static void Main(string[] args)
{
    Task.Run(() => TestAsync(5000)).GetAwaiter().GetResult();
}
Run Code Online (Sandbox Code Playgroud)

输出:

...
{ c = 10, where = before, CurrentManagedThreadId = 4 }
{ c = 10, where = after, CurrentManagedThreadId = 4 }
{ c = 9, where = before, CurrentManagedThreadId = 4 }
{ c = 9, where = after, CurrentManagedThreadId = 5 }
{ c = 8, where = before, CurrentManagedThreadId = 5 }
{ c = 8, where = after, CurrentManagedThreadId = 5 }
{ c = 7, where = before, CurrentManagedThreadId = 5 }
{ c = 7, where = after, CurrentManagedThreadId = 5 }
{ c = 6, where = before, CurrentManagedThreadId = 5 }
{ c = 6, where = after, CurrentManagedThreadId = 5 }
{ c = 5, where = before, CurrentManagedThreadId = 5 }
{ c = 5, where = after, CurrentManagedThreadId = 5 }
{ c = 4, where = before, CurrentManagedThreadId = 5 }
{ c = 4, where = after, CurrentManagedThreadId = 5 }
{ c = 3, where = before, CurrentManagedThreadId = 5 }
{ c = 3, where = after, CurrentManagedThreadId = 5 }
{ c = 2, where = before, CurrentManagedThreadId = 5 }
{ c = 2, where = after, CurrentManagedThreadId = 5 }
{ c = 1, where = before, CurrentManagedThreadId = 5 }
{ c = 1, where = after, CurrentManagedThreadId = 5 }
{ c = 0, where = before, CurrentManagedThreadId = 5 }
{ c = 0, where = after, CurrentManagedThreadId = 5 }

Process is terminated due to StackOverflowException.

我在安装.NET 4.6时看到了这一点.该项目是一个面向.NET 4.5的控制台应用程序.

我理解延续Task.Yield可能会ThreadPool.QueueUserWorkItem在相同的线程(如上面的#5)上进行调度,以防线程已经释放到池中 - 紧接着await Task.Yield(),但在QueueUserWorkItem实际调度回调之前.

但是我不明白为什么以及堆栈仍然在深化的地方.这里的延续不应该发生在同一个堆栈帧上,即使它在同一个线程上被调用.

我更进了一步并实现了一个自定义版本,Yield确保在同一个线程上不会发生延续:

public static class TaskExt
{
    public static YieldAwaiter Yield() { return new YieldAwaiter(); }

    public struct YieldAwaiter : System.Runtime.CompilerServices.ICriticalNotifyCompletion
    {
        public YieldAwaiter GetAwaiter() { return this; }

        public bool IsCompleted { get { return false; } }

        public void GetResult() { }

        public void UnsafeOnCompleted(Action continuation)
        {
            using (var mre = new ManualResetEvent(initialState: false))
            {
                ThreadPool.UnsafeQueueUserWorkItem(_ => 
                {
                    mre.Set();
                    continuation();
                }, null);

                mre.WaitOne();
            }
        }

        public void OnCompleted(Action continuation)
        {
            throw new NotImplementedException();
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

现在,在使用TaskExt.Yield而不是代替时Task.Yield,线程每次都在翻转,但堆栈溢出仍然存在:

...
{ c = 10, where = before, CurrentManagedThreadId = 3 }
{ c = 10, where = after, CurrentManagedThreadId = 4 }
{ c = 9, where = before, CurrentManagedThreadId = 4 }
{ c = 9, where = after, CurrentManagedThreadId = 5 }
{ c = 8, where = before, CurrentManagedThreadId = 5 }
{ c = 8, where = after, CurrentManagedThreadId = 3 }
{ c = 7, where = before, CurrentManagedThreadId = 3 }
{ c = 7, where = after, CurrentManagedThreadId = 4 }
{ c = 6, where = before, CurrentManagedThreadId = 4 }
{ c = 6, where = after, CurrentManagedThreadId = 5 }
{ c = 5, where = before, CurrentManagedThreadId = 5 }
{ c = 5, where = after, CurrentManagedThreadId = 4 }
{ c = 4, where = before, CurrentManagedThreadId = 4 }
{ c = 4, where = after, CurrentManagedThreadId = 3 }
{ c = 3, where = before, CurrentManagedThreadId = 3 }
{ c = 3, where = after, CurrentManagedThreadId = 5 }
{ c = 2, where = before, CurrentManagedThreadId = 5 }
{ c = 2, where = after, CurrentManagedThreadId = 3 }
{ c = 1, where = before, CurrentManagedThreadId = 3 }
{ c = 1, where = after, CurrentManagedThreadId = 5 }
{ c = 0, where = before, CurrentManagedThreadId = 5 }
{ c = 0, where = after, CurrentManagedThreadId = 3 }

Process is terminated due to StackOverflowException.

usr*_*usr 8

TPL再次出现再次罢工:

注意,完成所有迭代之后,堆栈溢出发生在函数的末尾.增加迭代次数不会改变它.将其降低到少量可以消除堆栈溢出.

完成方法的异步状态机任务时发生堆栈溢出TestAsync.它不会发生在"下降".它在退出并完成所有async方法任务时发生.

让我们首先将计数减少到2000,以减少调试器的负载.然后,查看调用堆栈:

在此输入图像描述

当然非常重复而且很长.这是正确的线索.崩溃发生在:

        var t = await TestAsync(c - 1);
        return t;
Run Code Online (Sandbox Code Playgroud)

当内部任务t完成时,它会导致执行外部的其余任务TestAsync.这只是返回声明.返回完成外部TestAsync生成的任务.这再次触发了另一个的完成t等等.

TPL将一些任务延续概述为性能优化.这种行为已经引起了很多悲痛,Stack Overflow问题证明了这一点.已要求删除它.问题已经很久了,到目前为止还没有收到任何答复.这并不能激发我们最终摆脱TPL重入问题的希望.

TPL有一些堆栈深度检查,以在堆栈变得太深时关闭延续的内联.由于(我)未知的原因,这里没有这样做.请注意,堆栈中没有任何地方存在TaskCompletionSource.TaskAwaiter利用TPL中的内部功能来提高性能.也许优化的代码路径不执行堆栈深度检查.也许这就是这个意义上的错误.

我不认为调用Yield与问题有任何关系,但最好把它放在这里以确保非同步完成TestAsync.


让我们手动编写异步状态机:

static Task<int> TestAsync(int c)
{
    var tcs = new TaskCompletionSource<int>();

    if (c < 0)
        tcs.SetResult(0);
    else
    {
        Task.Run(() =>
        {
            var t = TestAsync(c - 1);
            t.ContinueWith(_ => tcs.SetResult(0), TaskContinuationOptions.ExecuteSynchronously);
        });
    }

    return tcs.Task;
}

static void Main(string[] args)
{
    Task.Run(() => TestAsync(2000).ContinueWith(_ =>
    {
          //breakpoint here - look at the stack
    }, TaskContinuationOptions.ExecuteSynchronously)).GetAwaiter().GetResult();
}
Run Code Online (Sandbox Code Playgroud)

由于TaskContinuationOptions.ExecuteSynchronously我们也期望继续内联发生.确实如此,但它不会溢出堆栈:

在此输入图像描述

那是因为TPL阻止了堆栈变得太深(如上所述).在完成async方法任务时,似乎不存在此机制.

如果ExecuteSynchronously被删除则堆栈很浅并且没有内联.await运行ExecuteSynchronously启用.