Асинхронность в C# 5. Часть 7: исключения

Давайте продолжим с того места, где мы остановились (ха-ха-ха!) после небольшого отступления: обработка исключений в таких «возобновляемых» методах, подобных нашим асинхронным методам, кажется немного странной. Чтобы понять, насколько это странно, вам может понадобиться освежить в памяти мою последнюю серию статей о проектировании блоков итераторов, и в особенности сообщение о разнице между push и pull-моделями.

В обычном блоке кода, блок try окружает нормальный «синхронный» вызов, который отслеживает возможные исключения в процессе его выполнения:

 try { Q(); }
catch { ... }
finally { ... }

Если метод Q() сгенерирует исключение, тогда будет вызван блок catch; когда управление покинет метод Q() обычным образом или вследствие исключения, будет вызван блок finally. Здесь нет ничего необычного.

Теперь давайте рассмотрим блок итератора, который переписан в виде вызова MoveNext перечислителя. При его вызове, этот метод будет вызван синхронно. Если он сгенерирует исключение, то оно будет обработано ближайшим блоком try на стеке вызовов. Но что если блок итератора сам содержит блок try, который возвращает управление обратно вызывающему коду:

 try { yield return whatever; }
catch { ... }
finally { ... }

В таком случае оператор yield возвращает управление обратно вызывающему коду, но возврат управления не приводит к вызову блока finally. Тогда, если исключение произойдет в вызывающем коде, метод MoveNext() уже не будет находиться в стеке вызовов и его обработчик исключений просто исчезнет. Модель обработки исключений в блоках итераторов достаточно запутанная. Блок finally вызывается только в том случае, когда управление находится внутри метода MoveNext и управление возвращается из него каким-то другим механизмом, но не за счет ключевых слов yield return. Или при вызове метода Dispose перечислителя, что может произойти, когда в вызывающем коде будет сгенерировано исключение, которое приведет к вызову блока finally и соответствующему вызову метода Dispose перечислителя. Короче говоря, если в коде, использующем итераторы возникает исключение, которое прерывает выполнение итерирования, тогда блок finally итератора скорее всего вызовется, а блок catch – нет! Странно. Вот почему мы сделали некорректнымоператор yield return из блока try, который содержит блок catch.

Так что же мы собираемся делать с методами, содержащими оператор “await”? Ситуация аналогична с блоками итераторов, но даже более загадочна, поскольку асинхронная задача сама по себе может генерировать исключение:

 async Task M()
{
  try { await DoSomethingAsync(); }
  catch { ... }
  finally { ... }
}

Мы хотим, чтобы можно было ожидать завершения некоторой операции внутри блока try, содержащего блок catch. Предположим, что метод DoSomethingAsync генерирует исключение до того, как он возвращает экземпляр задания. В этом случае проблем нет; метод M все еще находится в стеке вызовов, поэтому блок catch нормально вызывается. Предположим, метод DoSomethingAsync возвращает задание. M сохраняет оставшуюся часть своего тела в качестве продолжения этой задачи, и немедленно возвращает другую задачу вызывающему коду. Что произойдет, когда будет выполнен код, связанный с задачей, которую вернул метод DoSomethingAsync и когда этот код сгенерирует исключение? Логически мы хотим, чтобы метод M все еще «располагался в стеке вызовов», чтобы его блоки catch и finally выполнились аналогично тому, будто метод M вызывал синхронный метод DoSomething. (В отличие от блоков итераторов мы хотим, чтобы выполнился и блок catch, а не только блок finally!). Но вызов метода M давно завершен; он передал делегат, содержащий остаток его кода в виде продолжения задания, но сам метод M и его блок try уже исчезли. Задание может вообще выполняться в потоке, отличном от того, где вызывался метод M. Черт возьми! Да оно может выполняться даже на другом континенте, если задание вызывается в некоторой ферме «в облаках». Что мы можем сделать?

Несколько эпизодов назад я сказал, что обработка исключений в стиле передачи продолжений достаточно проста; вы просто передаете два продолжения, одно для исключительной ситуации, а другое – для нормальной. Здесь мы, на самом деле, поступаем иначе. Вот что мы делаем на самом деле: если задание генерирует необработанное другими способами исключение, тогда оно перехватывается и сохраняется в задании. Затем задание уведомляет о том, что оно завершилось неудачно. Когда выполнение продолжения будет возобновлено, управление переносится в середину блока try (куда-то) и выполнится проверка того, нормально ли завершилось задание или нет. Если задание завершилось неудачно, тогда мы пробрасываем исключение прямо оттуда, и, вуаля, на этот раз у нас есть блоки try-catch-finally, которые могут обработать это исключение.

Но предположим, мы не обработали исключение; может быть мы не нашли соответствующего блока catch. Что тогда? Кода, вызвавшего метод M уже давно нет; продолжение, скорее всего, вызвано очередью сообщений. И что нам остается? Помните, что метод M возвратил задание. Мы опять сохраняем это исключение в этом задании, и уведомляем о том, что задание завершилось неудачно. Таким образом, проблема передана вызывающему коду, а ведь именно этим и является генерация исключений: заставить вызывающий код выполнить работу по очистке в случае проблем.

Короче говоря, метод M() содержит примерно такой псевдокод на языке C#:

 Task M()
{
  var builder = AsyncMethodBuilder.Create();
  var state = State.Begin;
  Action continuation = ()=>
  {
    try
    {
      if (state == State.AfterDoSomething) goto AfterDoSomething;
      try
      {
        var awaiter = DoSomethingAsync().GetAwaiter;
        state= State.AfterDoSomething;
        if (awaiter.BeginAwait(continuation))
          return without running the finally;
      AfterDoSomething:
        awaiter.EndAwait(); // выбросить исключение, если задача завершилась неудачно
        builder.SetResult();
        return;
      }
      catch { ... }
      finally { ... }
    }
    catch (Exception exception)
    {
      builder.SetException(exception); // уведомить о том, что эта задача завершилась успешно
      return;
    }
    builder.SetResult();
  };
  continuation();
  return builder.Task;
}

(Конечно, в этом коде есть ряд проблем: вы не можете с помощью оператора goto перепрыгивать в середину блока try, поскольку метка в этом случае находится вне области видимости и т.д. Но мы можем заставить компилятор генерировать работающий IL-код; не обязательно, чтобы этот код был корректным с точки зрения языка C#. И это всего лишь набросок.)

Если метод EndAwait генерирует исключение, сохраненное асинхронной операцией, тогда блоки catch и finally будут выполнены обыкновенным образом. Если внутренний блок catch не обработает его, или сгенерирует другое исключение, тогда внешний блок catch перехватит его, сохранит в задаче и уведомит о том, что задача завершилась ненормально.

В этом наброске кода я проигнорировал несколько важных моментов. Например, что если метод M возвращает void? В этом случае не будет задачи, созданной для метода M, и, таким образом, нечего будет уведомлять о неуспешном завершении и негде будет сохранить это исключение. Что если внутри метода DoSomethingAsync вызывается метод WhenAll с десятью подзадачами, две из которых сгенерировали исключение? А как насчет такого же варианта, только с WhenAny?

В следующий раз я расскажу немного об этих вариантах, поразмышляю в общем о философии обработки исключений, и спрошу вас о том, разумна эта философия или нет. Потом мы сделаем короткий перерыв в честь Дня Благодарения, а потом приступим к другим темам, отличным от асинхронности.

Оригинал статьи