17.01.2017 Await, Catch и Finally в C# 6

Несколько слов перед статьей

Приведенная ниже информация также является переводом - на сей раз статьи из блога Билла Вагнера Await, Catch, and Finally in C# 6. В данном случае мой интерес к асинхронному программированию вызван тем фактом, что меня попросили написать некоторое настольное приложение, которое активно использует средства коммуникации в сети Интернет ([joke]что является большой редкостью в наши дни[/joke]). Также решил при разработке задействовать Entity Framework 6 с подходом Code First, сделав все операции по работе с базой данных асинхронными. Что это за программа, я расскажу несколько позже в одной из следующих статей, пройдусь по архитектуре и интересным местам, и обязательно опубликую исходники. А пока, поехали...

Intro

Вы считали, что await, было разрешено использовать во всем коде в ранних версиях C#? Тогда вы не одиноки. Сейчас это ключевое слово можно использовать в блоках catch и finally. Билл Вагнер, автор книги "Эффективное использование С#, 2-ое издание" показывает, как использовать преимущества await в сценариях обработки исключений. Итак, слово Биллу.

В этой статье я расскажу об одной из новых особенностей языка C# 6, которая удивила многих людей, поскольку они считали, что она была уже реализована. Эта новая особенность - использование ключевого слова await в блоке catch или finally асинхронного метода.

В версии 5.0 компилятора C# запрещалось использование await-выражений в блоках catch и finally, и это ограничение на самом деле доставляло некоторое неудобство. Большинство приложений содержит логирование или другие похожие функции в catch-блоках. В распределенных системах логирование может выполняться асинхронно. Также часто мы можем выполнять некоторую работу по очистке ресурсов (которая может быть асинхронной) в finally-блоке.

Рассмотрим пример:

public async Task DoWorkAsync()
{
    try
    {
        var items = DownloadSitesAsync(allSites);
        allResults.AddRange(await items);
    } catch(Exception e)
    {
        await LogResultAsync("Site download failed", e);
    }
}

Код, приведенный выше показывает, что мой метод LogResult является асинхронным методом, возвращающий задачу. В C# 5 вам нужно было либо синхронно ожидать окончание задачи LogResult, или же "вызвать и забыть" метод логирования.

Разработчик, который написал LogResult() информирует, что этому методу необходим доступ к асинхронному ресурсу, возвращая объект Task и следуя соглашению по именованию - заканчивая название метода на "Async". Синхронное ожидание возврата управления из этого метода заблокирует приложение и повлияет на отзывчивость, что не есть хорошо.

Способ "вызвать и забыть" также не очень хорош. Он запускает задачу, но не отслеживает успешность ее выполнения. Если в процессе выполнения LogResultAsync произойдут ошибки, и задача не выполнится, вы не сможете обнаружить проблему и предпринять какие-либо действия. (На самом деле, если ваша система логирования генерирует исключения, я не представляю, как вы их будете фиксировать. Но это уже совсем другая история).

Вы можете выполнять освобождение ресурсов в блоке finally вашего метода и эти методы также могут возвращать объекты Task. В C# 6 вы также можете использовать await для таких задач:

public async Task WorkWithAsyncResource()
{
    var resource = await AcquireResourceAsync();
    try
    {
        resource.SetState(config);
        await resource.StartWorkAsync();
    } finally
    {
        await resource.ReleaseResourceAsync();
    }
}

В предыдущих версиях C# код, показанный выше, имел те же самые проблемы, на которые я обратил внимание в своем первом примере. Нет простого способа контролировать процесс выполнения задачи, запущенной в блоке finally. Вы можете либо синхронно ожидать завершения, либо просто проигнорировать результат. Имеем те же самые проблемы, что и в первом случае. Здесь, однако, ресурсы должны быть освобождены, как в случае успеха, так и в случае возникновения исключений. Было очень сложно писать чистый код, когда невозможно было использовать await в блоках catch и finally. Мы могли бы написать некоторый "выходящий за рамки" код - сохранить Task в поле класса или другого объекта и таким образом контролировать выполнение задачи.

Добавление возможности использования await в блоках catch и finally означает тот факт, что мы можем использовать те же самые асинхронные идиомы во всем вашем коде. Нет больше костылей! Однако реализация инфраструктуры является довольно сложной. Но вся реализация осуществляется компилятором, что не влияет на читабельность и простоту поддержки вашего кода. В результате получается более чистый код и понятная логика, а компилятор обрабатывает асинхронность используемых нами библиотек.

Распространение исключений при использовании await

Когда я впервые увидел эту фичу, я немного опешил. Я был весьма обеспокоен тем, как именно и когда исключения будут распространяться, когда они будут выброшены "провалившимися" задачами, завершение которых ожидалось в блоках catch и finally. Меня интересовало, когда эти исключения "всплывут" в программе. Ответ, на самом деле, достаточно простой. Они обрабатываются способом, который является естественным дополнением к поведению синхронных методов.

В синхронном коде в блоках catch и finally можно вызывать методы, выбрасывающие исключения. Когда это происходит, исключение выбрасывается немедленно. Если новое исключение выброшено, в то время, как существующее еще действует, новое исключение фактически скрывает предыдущее исключение. Только что выброшенное исключение теперь является действующим исключением, и начинается новый процесс раскручивания стека.

Рассмотрим такой код:

var s = new Service();
try
{
    s.Work(true);
} catch (Exception e)
{
    s.Report(true);
}
finally
{
    s.Cleanup();
}

Представим, что s.Work() выбрасывает InvalidOperationException. Затем код входит в блок catch. Хорошо, делее, предположим, что s.Report() пытается получить доступ к неинициализированому члену и выбрасывает NullReferenceException. Происходит выход из блока catch и начинается новый процесс раскручивания стека. Начинает выполняться блок finally. s.Cleanup() также может выбросить исключение, поэтому представим, что этот метод выбрасывает исключение FileNotFoundException. Это исключение заменяет NullReferenceException, которое в свою очередь заменило InvalidOperationException. Единственное исключение, которое может наблюдаться на вершине стека – это FileNotFoundException.

Давайте сравним это описание с таким асинхронным кодом:

public async Task WorkWithAsyncResource()
{
    var resource = await AcquireResourceAsync();
    try
    {
        resource.SetState(config);
        await resource.StartWorkAsync();
    } catch (Exception e)
    {
        await LogResultAsync("working with resource fails", e);
    } finally
    {
        await resource.ReleaseResourceAsync();
    }
}

Если исключение выбрасывается методом SetState или StartWorkAsync, выполнение переходит в блок catch. Если LogResultAsync выбрасывает исключение, то оно заменяет исключение, которое было выброшено кодом выше. Далее предстоит выполнение блока finally, и его исполнение, собственно, начинается. Если ReleaseResourceAsync также выбрасывает исключение, то оно может быть обнаружено во время ожидания выполнения задачи, возвращаемой методом WorkWithAsyncResource.

Конечный результат таков, что любой код, ожидающий данную задачу будет в состоянии увидеть исключение, выброшенное из блока finally. Остальные исключения не могут быть обнаружены.

Некоторое начальное руководство по использованию await в блоках catch и finally

Это единственная новая фича в C# 6, которая заставила меня прочесать существующий код и добавить await в блоки catch и finally. Обычно я натыкался на синхронное выполнение при поиске и подобное изменение (внесение асинхронности с await) обеспечит лучшую отзывчивость. В случаях, когда вызывался асинхронный метод без ожидания завершения задачи, добавление await улучшает обработку. В случаях, где была собственная реализация костылей для мониторинга задач, созданных в блоках catch и finally, я с облегчением удалил их, полагаясь на сгенерированный компилятором необходимый код.

Я также поискал асинхронные методы, предназначенные для вызова из catch-блоков. Некоторые из них оказались async void методами, которые я преобразовал в методы, возвращающие Task, и добавил await к этим методам.

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