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.

