堆栈展开时嵌套异步方法中的堆栈溢出异常
StackOverflowExceptions in nested async methods on unwinding of the stack
我们有很多嵌套的异步方法,看到了我们并不真正理解的行为。以这个简单的 C# 控制台应用程序为例
public 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";
}
}
当我们运行这个带有以下参数的样本时:
AsyncStackSample.exe 2000 false
我们收到 WhosebugException
,这是我们在控制台中看到的最后一条消息:
e 331 of 2000 (on threadId: 4)
当我们把参数改成
AsyncStackSample.exe 2000 true
我们以这条消息结束
e 831 of 2000 (on threadId: 4)
所以 WhosebugException
发生在堆栈展开时(不确定我们是否应该这样称呼它,但是 WhosebugException
发生在我们示例中的递归调用之后,在同步代码中, WhosebugException
将始终出现在嵌套方法调用中)。在我们抛出异常的情况下,WhosebugException
发生得更早。
我们知道我们可以通过在 finally 块中调用 Task.Yield()
来解决这个问题,但是我们有几个问题:
- 为什么堆栈在展开路径上增长(与
不会导致等待线程切换的方法)?
- 为什么
WhosebugException
在 Exception 情况下比我们不抛出异常时更早发生?
Why does the Stack grow on the unwinding path (in comparison to a method that doesn't cause a thread switch on the await)?
核心原因是因为await
schedules its continuations with the TaskContinuationOptions.ExecuteSynchronously
flag.
所以,当 "innermost" Yield
被执行时,你最终得到的是 3000 个未完成的任务,每个 "inner" 任务持有一个完成回调来完成下一个最内部任务。这都在堆里。
当最里面的 Yield
恢复(在线程池线程上)时,继续(同步)执行 Test
方法的剩余部分,完成其任务,后者(同步)执行Test
方法的其余部分,它完成了 它的 任务,等等,几千次。因此,随着每个任务的完成,该线程池线程上的调用堆栈实际上 增长 。
就我个人而言,我发现这种行为令人惊讶,并将其报告为错误。但是,该错误已被 Microsoft 关闭为 "by design"。有趣的是,JavaScript 中的 Promises 规范(以及 await
的行为)always 具有异步完成 运行 和 从不同步。这让一些 JS 开发人员感到困惑,但这是我所期望的行为。
通常情况下,它运行良好,ExecuteSynchronously
是一个小的性能改进。但正如您所指出的,在 "async recursion" 这样的情况下,它可能会导致 WhosebugException
.
有一些heuristics in the BCL to run continuations asynchronously if the stack is too full,但它们只是启发式方法,并不总是有效。
Why does the WhosebugException occurs earlier in the Exception case than when we don't throw an exception?
这是一个很好的问题。我不知道。 :)
我们有很多嵌套的异步方法,看到了我们并不真正理解的行为。以这个简单的 C# 控制台应用程序为例
public 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";
}
}
当我们运行这个带有以下参数的样本时:
AsyncStackSample.exe 2000 false
我们收到 WhosebugException
,这是我们在控制台中看到的最后一条消息:
e 331 of 2000 (on threadId: 4)
当我们把参数改成
AsyncStackSample.exe 2000 true
我们以这条消息结束
e 831 of 2000 (on threadId: 4)
所以 WhosebugException
发生在堆栈展开时(不确定我们是否应该这样称呼它,但是 WhosebugException
发生在我们示例中的递归调用之后,在同步代码中, WhosebugException
将始终出现在嵌套方法调用中)。在我们抛出异常的情况下,WhosebugException
发生得更早。
我们知道我们可以通过在 finally 块中调用 Task.Yield()
来解决这个问题,但是我们有几个问题:
- 为什么堆栈在展开路径上增长(与 不会导致等待线程切换的方法)?
- 为什么
WhosebugException
在 Exception 情况下比我们不抛出异常时更早发生?
Why does the Stack grow on the unwinding path (in comparison to a method that doesn't cause a thread switch on the await)?
核心原因是因为await
schedules its continuations with the TaskContinuationOptions.ExecuteSynchronously
flag.
所以,当 "innermost" Yield
被执行时,你最终得到的是 3000 个未完成的任务,每个 "inner" 任务持有一个完成回调来完成下一个最内部任务。这都在堆里。
当最里面的 Yield
恢复(在线程池线程上)时,继续(同步)执行 Test
方法的剩余部分,完成其任务,后者(同步)执行Test
方法的其余部分,它完成了 它的 任务,等等,几千次。因此,随着每个任务的完成,该线程池线程上的调用堆栈实际上 增长 。
就我个人而言,我发现这种行为令人惊讶,并将其报告为错误。但是,该错误已被 Microsoft 关闭为 "by design"。有趣的是,JavaScript 中的 Promises 规范(以及 await
的行为)always 具有异步完成 运行 和 从不同步。这让一些 JS 开发人员感到困惑,但这是我所期望的行为。
通常情况下,它运行良好,ExecuteSynchronously
是一个小的性能改进。但正如您所指出的,在 "async recursion" 这样的情况下,它可能会导致 WhosebugException
.
有一些heuristics in the BCL to run continuations asynchronously if the stack is too full,但它们只是启发式方法,并不总是有效。
Why does the WhosebugException occurs earlier in the Exception case than when we don't throw an exception?
这是一个很好的问题。我不知道。 :)