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 происходит раньше в случае исключения, чем когда мы не генерируем исключение?

Это отличный вопрос. Понятия не имею.:)