StackOverflowExceptions in nested async methods on unwinding of the stack
We have a lot of nested async methods and see behavior that we do not really understand. Take for example this simple C# console application
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace AsyncStackSample
{
class Program
{
static void Main(string[] args)
{
try
{
var x = Test(index: 0, max: int.Parse(args[0]), throwException: bool.Parse(args[1])).GetAwaiter().GetResult();
Console.WriteLine(x);
}
catch(Exception ex)
{
Console.WriteLine(ex);
}
Console.ReadKey();
}
static async Task<string> Test(int index, int max, bool throwException)
{
await Task.Yield();
if(index < max)
{
var nextIndex = index + 1;
try
{
Console.WriteLine($"b {nextIndex} of {max} (on threadId: {Thread.CurrentThread.ManagedThreadId})");
return await Test(nextIndex, max, throwException).ConfigureAwait(false);
}
finally
{
Console.WriteLine($"e {nextIndex} of {max} (on threadId: {Thread.CurrentThread.ManagedThreadId})");
}
}
if(throwException)
{
throw new Exception("");
}
return "hello";
}
}
}
When we run this sample with the following arguments:
AsyncStackSample.exe 2000 false
We get a StackOverflowException and this is the last message we see in the console:
e 331 of 2000 (on threadId: 4)
When we change the arguments into
AsyncStackSample.exe 2000 true
We end with this message
e 831 of 2000 (on threadId: 4)
So the StackOverflowException occurs on the unwinding of the stack (not really sure if we should call it that, but the StackOverflowException occurs after the recursive call in our sample, in synchronous code, a StackOverflowException will always occur on the nested method call). In the case that we throw an exception, the StackOverflowException occurs even earlier.
We know we can solve this by calling Task.Yield() in the finally block, but we have a few questions:
Why does the Stack grow on the unwinding path (in comparison to a method that doesn't cause a thread switch on the await)?
The core reason is because await
schedules its continuations with the TaskContinuationOptions.ExecuteSynchronously
flag.
So, when the "innermost" Yield
is executed, what you end up with is 3000 incomplete tasks, with each "inner" task holding a completion callback that completes the nextmost-inner task. This is all in the heap.
When the innermost Yield
resumes (on a thread pool thread), the continuation (synchronously) executes the remainder of the Test
method, which completes its task, which (synchronously) executes the remainder of the Test
method, which completes its task, etc., a few thousand times. So, the call stack on that thread pool thread is actually growing as each task completes.
Personally, I find this behavior surprising and reported it as a bug. However, the bug was closed by Microsoft as "by design". It's interesting to note that the Promises specification in JavaScript (and by extension, the behavior of await
) always has promise completions run asynchronously and never synchronously. This has confused some JS devs, but it is the behavior that I would expect.
Usually, it works out OK, and ExecuteSynchronously
acts as a minor performance improvement. But as you noted, there are scenarios like "async recursion" where it can cause a StackOverflowException
.
There are some heuristics in the BCL to run continuations asynchronously if the stack is too full, but they're just heuristics and don't always work.
Why does the StackOverflowException occurs earlier in the Exception case than when we don't throw an exception?
That's a great question. I have no idea. :)
链接地址: http://www.djcxy.com/p/80768.html上一篇: Haskell单元测试