StackOverflowExceptions при вложенных методах async при разматывании стека
У нас много вложенных асинхронных методов и мы видим поведение, которое мы действительно не понимаем. Возьмем, к примеру, это простое консольное приложение С#
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";
}
}
}
Когда мы запускаем этот пример со следующими аргументами:
AsyncStackSample.exe 2000 false
Мы получаем исключение StackOverflowException, и это последнее сообщение, которое мы видим на консоли:
e 331 of 2000 (on threadId: 4)
Когда мы меняем аргументы на
AsyncStackSample.exe 2000 true
Мы заканчиваем этим сообщением
e 831 of 2000 (on threadId: 4)
Таким образом, StackOverflowException возникает при разматывании стека (не совсем уверен, следует ли это ему называть, но StackOverflowException возникает после рекурсивного вызова в нашем примере, в синхронном коде, исключение StackOverflow всегда возникает при вызове вложенного метода). В случае исключения исключения исключение StackOverflowException происходит еще раньше.
Мы знаем, что мы можем решить это, вызвав Task.Yield() в блоке finally, но у нас есть несколько вопросов:
- Почему стек развивается по пути разматывания (по сравнению с
метод, который не вызывает переключатель потока в ожидании)?
- Почему исключение StackOverflowException происходит раньше в случае исключения, чем когда мы не генерируем исключение?
Ответы
Ответ 1
Почему стек развивается по пути разматывания (по сравнению с методом, который не вызывает переключатель потока в ожидании)?
Основная причина заключается в том, что await
планирует его продолжение с помощью флага TaskContinuationOptions.ExecuteSynchronously
.
Итак, когда выполняется "самая внутренняя" Yield
, у вас заканчивается 3000 неполных задач, причем каждая "внутренняя" задача содержит обратный вызов завершения, который завершает следующую самую внутреннюю задачу. Это все в куче.
Когда внутреннее Yield
возобновляется (в потоке пула потоков), продолжение (синхронно) выполняет остаток метода Test
, который завершает свою задачу, которая (синхронно) выполняет остаток Test
метод, который завершает свою задачу и т.д., несколько тысяч раз. Таким образом, стек вызовов в потоке пула потоков фактически растет по мере завершения каждой задачи.
Лично я нахожу это поведение неожиданным и сообщаю об этом как об ошибке. Тем не менее, ошибка была закрыта Microsoft как "по дизайну". Интересно отметить, что спецификация Promises в JavaScript (и, по общему мнению, поведение await
) всегда имеет обещание, завершение выполняется асинхронно и никогда не синхронно. Это смутило некоторых разработчиков JS, но это поведение, которое я ожидал бы.
Обычно он работает нормально, а ExecuteSynchronously
действует как незначительное улучшение производительности. Но, как вы отметили, есть сценарии, такие как "асинхронная рекурсия", где она может вызвать StackOverflowException
.
Есть несколько эвристик в BCL для асинхронного запуска продолжений, если стек слишком заполнен, но они просто эвристики и не всегда работают.
Почему исключение StackOverflowException происходит раньше в случае исключения, чем когда мы не генерируем исключение?
Это отличный вопрос. Понятия не имею.:)