【问题标题】:Unexpected stack overflow despite yielding尽管产生了意外的堆栈溢出
【发布时间】:2015-12-01 11:03:00
【问题描述】:

为什么下面的异步递归会以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();
}

输出:

... { c = 10,其中 = 之前,CurrentManagedThreadId = 4 } { c = 10,其中 = 之后,CurrentManagedThreadId = 4 } { c = 9, where = before, CurrentManagedThreadId = 4 } { c = 9,其中 = 之后,CurrentManagedThreadId = 5 } { c = 8, where = before, CurrentManagedThreadId = 5 } { c = 8, where = after, CurrentManagedThreadId = 5 } { c = 7,其中 = 之前,CurrentManagedThreadId = 5 } { c = 7,其中 = 之后,CurrentManagedThreadId = 5 } { c = 6, where = before, CurrentManagedThreadId = 5 } { c = 6, where = after, CurrentManagedThreadId = 5 } { c = 5, where = before, CurrentManagedThreadId = 5 } { c = 5,其中 = 之后,CurrentManagedThreadId = 5 } { c = 4, where = before, CurrentManagedThreadId = 5 } { c = 4, where = after, CurrentManagedThreadId = 5 } { c = 3,其中 = 之前,CurrentManagedThreadId = 5 } { c = 3,其中 = 之后,CurrentManagedThreadId = 5 } { c = 2, where = before, CurrentManagedThreadId = 5 } { c = 2,其中 = 之后,CurrentManagedThreadId = 5 } { c = 1,其中 = 之前,CurrentManagedThreadId = 5 } { c = 1,其中 = 之后,CurrentManagedThreadId = 5 } { c = 0,其中 = 之前,CurrentManagedThreadId = 5 } { c = 0,其中 = 之后,CurrentManagedThreadId = 5 } 进程因 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();
        }
    }
}

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

... { c = 10,其中 = 之前,CurrentManagedThreadId = 3 } { c = 10,其中 = 之后,CurrentManagedThreadId = 4 } { c = 9, where = before, CurrentManagedThreadId = 4 } { c = 9,其中 = 之后,CurrentManagedThreadId = 5 } { c = 8, where = before, CurrentManagedThreadId = 5 } { c = 8,其中 = 之后,CurrentManagedThreadId = 3 } { c = 7, where = before, CurrentManagedThreadId = 3 } { c = 7, where = after, CurrentManagedThreadId = 4 } { c = 6,其中 = 之前,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,其中 = 之后,CurrentManagedThreadId = 3 } { c = 3, where = before, CurrentManagedThreadId = 3 } { c = 3,其中 = 之后,CurrentManagedThreadId = 5 } { c = 2, where = before, CurrentManagedThreadId = 5 } { c = 2,其中 = 之后,CurrentManagedThreadId = 3 } { c = 1,其中 = 之前,CurrentManagedThreadId = 3 } { c = 1,其中 = 之后,CurrentManagedThreadId = 5 } { c = 0,其中 = 之前,CurrentManagedThreadId = 5 } { c = 0,其中 = 之后,CurrentManagedThreadId = 3 } 进程因 StackOverflowException 而终止。

【问题讨论】:

  • 很高兴看到您仍在使用匿名对象 ToString 技巧 :)
  • @usr,自从我向你学习以来,它一直是我的最爱之一 :)

标签: c# .net multithreading async-await task-parallel-library


【解决方案1】:

TPL 重入再次来袭:

请注意,堆栈溢出发生在函数结束时所有迭代完成之后。增加迭代次数不会改变这一点。将其降低到少量会消除堆栈溢出。

在完成方法TestAsync的异步状态机任务时发生堆栈溢出。它不会发生在“下降”上。退出并完成所有 async 方法任务时会发生这种情况。

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

当然非常重复且冗长。这是正确的线程。崩溃发生在:

        var t = await TestAsync(c - 1);
        return t;

当内部任务t 完成时,它会执行外部TestAsync 的其余部分。这只是返回语句。返回完成外部TestAsync 产生的任务。这再次触发另一个t 的完成,依此类推。

TPL 内联了一些任务延续作为性能优化。正如 Stack Overflow 问题所证明的那样,这种行为已经引起了很多悲伤。 It has been requested to remove it.这个问题很老了,到目前为止还没有收到任何回复。这并没有激发我们最终摆脱 TPL 重入问题的希望。

TPL 有一些堆栈深度检查,以在堆栈变得太深时关闭延续的内联。由于我(还)未知的原因,这里没有这样做。请注意,堆栈上没有TaskCompletionSourceTaskAwaiter 利用 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();
}

感谢TaskContinuationOptions.ExecuteSynchronously,我们也期待继续内联发生。可以,但不会溢出堆栈:

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

如果ExecuteSynchronously 被删除,那么堆栈是浅的并且不会发生内联。 await runs with ExecuteSynchronously enabled.

【讨论】:

  • 一个很好的答案。事实上,这个问题的灵感来自一位客户等待者(我们称之为AlwaysAsync),我尝试用它来解决您在此处描述的完全相同的 TPL 问题。我在TestAsync 中使用它,但不在它的返回线上。所以我刚刚将返回行更改为return await TestAsync(c-1).AlwaysAsync(),问题就消失了:)
  • 另一种消除堆栈跳水的方法是使用Task.Run:async Task&lt;int&gt; TestAsync(int c) { if (c &lt; 0) return c; return await Task.Run(() =&gt; TestAsync(c - 1)); }
  • @Noseratio 这在实践中有效吗?我认为完成可能会一直内联。 Task.Run 内部有特殊的性能优化展开代码。也许这里堆栈溢出避免机制已经到位并且正在工作。
  • @usr, Task.Run 不管迭代次数如何都可以工作。我自己还不知道究竟是为什么。也许您对 Task.Unwrap 的看法是正确的,它可能包括避免内联的检查。
猜你喜欢
  • 1970-01-01
  • 2016-05-30
  • 2019-05-18
  • 1970-01-01
  • 2019-07-05
  • 2013-11-05
  • 2019-08-25
  • 1970-01-01
  • 2013-09-24
相关资源
最近更新 更多