Why does the following asynchronous recursion fail with StackOverflowException
, and why is it happening exactly at the last step, when the counter becomes zero?
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();
}
Output:
... { 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.
I'm seeing this with .NET 4.6 installed. The project is a console app targeting .NET 4.5.
I understand that the continuation for Task.Yield
may get scheduled by ThreadPool.QueueUserWorkItem
on the same thread (like #5 above), in case the thread has been already released to the pool - right after await Task.Yield()
, but before the QueueUserWorkItem
callback has been actually scheduled.
I don't however understand why and where the stack is still deepening. The continuation shouldn't be happening on the same stack frame here, even if it's called on the same thread.
I took a step further and implemented a custom version of Yield
which makes sure the continuation doesn't happen on the same thread:
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();
}
}
}
Now, while using TaskExt.Yield
instead of Task.Yield
, threads are flipping each time but the stack overflow is still there:
... { 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.