Несколько советов по работе с асинхронным кодом в C#

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

В рамках набора учащихся на курс "C# Developer. Professional" подготовили перевод материала.

Также приглашаем будущих студентов курса и просто всех желающих записаться на открытый урок «5 шагов к хорошему коду», на котором эксперт поделится максимально понятными и конкретными правилами написания хорошего кода.


Ниже вашему вниманию представлены несколько советов и приемов по работе с асинхронностью в C#. Изначально это был скорее небольшой справочник для меня самого, нежели пособие для всех остальных, но я подумал, что он все-таки может оказаться полезным и для вас.

Запуск синхронного метода как «асинхронного»

Task.Run(() => DoSyncStuff());  

Технически он не асинхронный. Он все еще блокирующий, но запускается в background-потоке. Это полезно по большей части только для предотвращения блокировки потока пользовательского интерфейса в десктопных/мобильных приложениях. В контексте веб-приложения это практически бессмысленно, поскольку каждый поток берется из того же пула для обслуживания запросов, из которого берется и main (запрос) поток, и ответ все равно не будет возвращен, пока все не будет сделано.

Запуск асинхронного метода как синхронного

Для этого у нас есть вспомогательный класс, позаимствованный у Microsoft. Он присутствует в различных пространствах имен, но всегда как внутренний класс, поэтому вы не можете просто взять и использовать его прямо из фреймворка.

public static class AsyncHelper  
{
    private static readonly TaskFactory _taskFactory = new
        TaskFactory(CancellationToken.None,
                    TaskCreationOptions.None,
                    TaskContinuationOptions.None,
                    TaskScheduler.Default);

    public static TResult RunSync<TResult>(Func<Task<TResult>> func)
        => _taskFactory
            .StartNew(func)
            .Unwrap()
            .GetAwaiter()
            .GetResult();

    public static void RunSync(Func<Task> func)
        => _taskFactory
            .StartNew(func)
            .Unwrap()
            .GetAwaiter()
            .GetResult();
}

А затем

AsyncHelper.RunSync(() => DoAsyncStuff());  

Сброс контекста

Когда вы ожидаете завершения (await) асинхронной (async) операции, контекст вызывающего кода передается по умолчанию. Это может оказывать существенное влияние на производительность. Если у вас нет необходимости восстанавливать этот контекст позже, значит, это просто напрасная трата ресурсов. Вы можете предотвратить это, добавив к вызову ConfigureAwait(false):

await DoSomethingAsync().ConfigureAwait(false);  

На самом деле, вам почти всегда следует так делать, если только нет особой причины для сохранения контекста. Например, когда вам нужен доступ к определенному компоненту графического интерфейса или вам нужно вернуть ответ от действия контроллера.

Однако важно отметить, что каждая операция имеет собственный контекст, поэтому вы можете безопасно использовать ConfigureAwait(false) внутри асинхронного метода, вызываемого кодом, который должен сохранять контекст; вы просто не сможете использовать ConfigureAwait(false) для самого этого метода. Например:

public async Task<IActionResult> Foo()  
{
    // No `ConfigureAwait(false)` here
    await DoSomethingAsync();
    return View();
}

...

public async Task DoSomethingAsync()  
{
    // This is fine
    await DoSomethingElseAsync().ConfigureAwait(false);
}

В результате вы можете (и должны) выделить несколько асинхронных операций, в которых необходимо сохранять контекст, в отдельный метод, чтобы вам нужно было сохранять контекст только один, а не N раз. Например:

public async Task<IActionResult> Foo()  
{
    await DoFirstThingAsync();
    await DoSecondThingAsync();
    await DoThirdThingAsync();
    return View();
}

Здесь каждая из этих операций получает копию контекста вызывающего кода, и, поскольку нам нужен этот контекст, использовать ConfigureAwait(false) мы не можем. Однако при рефакторинге следующего кода нам нужна уже только одна копия контекста вызывающего кода.

public async Task DoThingsAsync()  
{
    await DoFirstThingAsync().ConfigureAwait(false);
    await DoSecondThingAsync().ConfigureAwait(false);
    await DoThirdThingAsync().ConfigureAwait(false);
}

public async Task<IActionResult> Foo()  
{
    await DoThingsAsync();
    return View();
}

Асинхронность и сборка мусора

В синхронном коде локальные переменные помещаются в стек и сбрасываются, когда они выходят за пределы области видимости. Однако из-за переключений контекста, которые происходят, когда вы ожидаете завершения асинхронной операции, эти локальные переменные должны быть сохранены. Фреймворк реализует это посредством добавления их в структуру, которая находится в куче. Таким образом, когда выполнение возвращается к вызывающему коду, локальные объекты могут быть восстановлены. Однако чем больше всего происходит в вашем коде, тем больше нужно добавлять в кучу, что приводит к более частым циклам сборки мусора. Иногда это может быть неизбежно, но вы должны стараться избегать бесполезных назначений переменных, когда вы собираетесь ожидать завершения асинхронной операции. Например, рассмотрим такой код:

var today = DateTime.Today;
var todayString = today.ToString("MMMM d, yyyy");

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

var todayString = DateTime.Today.ToString("MMMM d, yyyy");

Это одна из тех вещей, о которых вы не задумываетесь, пока кто-то вам не расскажет.

Отмена асинхронной задачи

Одним из преимуществ асинхронной работы в C# является возможность отмены задач (Task). Это позволяет вам прервать задачу, если пользователь отменяет ее в пользовательском интерфейсе, покидает веб-страницу и т. д. Чтобы реализовать отмену, ваши асинхронные методы должны принимать параметр CancellationToken.

public async Task DoSomethingAsync(CancellationToken cancellationToken)  
{
    ...
}

Затем этот CancellationToken должен быть передан во все другие асинхронные операции, вызываемые методом. Реализовать отмену, если в этом может быть необходимость и это возможно, является обязанностью метода. Не все асинхронные задачи можно отменить. Вообще говоря, возможность отмены задачи зависит от того, имеет ли метод перегрузку, принимающую CancellationToken.

Отмена задач, которые нельзя отменить

В некоторых сценариях вы все равно можете отменить задачу, даже если метод не предоставляет перегрузку, которая принимает CancellationToken. Вы не отменяете задачу в традиционном понимании, но, в зависимости от реализации, вы, тем не менее, сможете прервать ее, получив тот же результат. Например, метод ReadAsStringAsync для HttpContent не имеет перегрузки, которая принимает CancellationToken. Однако, если вы удалите HttpResponseMessage, попытка чтения содержимого будет прервана.

try  
{
    using (var response = await httpClient.GetAsync(new Uri("https://www.google.com")))
    using (cancellationToken.Register(response.Dispose))
    {
        return await response.Content.ReadAsStringAsync();
    }
}
catch (ObjectDisposedException)  
{
    if (cancellationToken.IsCancellationRequested)
        throw new OperationCanceledException();

    throw;
}

По сути, мы используем CancellationToken для вызова Dispose в инстансе HttpResponseMessage, если он отменен. Это заставит ReadAsStringAsync пробросить ObjectDisposedException. Мы перехватываем это исключение, и если CancellationToken был отменен, вместо этого мы генерируем OperationCanceledException.

Ключ к этому подходу на самом деле заключается в способности избавиться от некоторого родительского объекта, что приведет к тому, что метод, который нельзя отменить, вызовет исключение. Это применимо не везде, но может пригодиться вам в определенных сценариях.

Когда следует избегать ключевых слов async/await 

Асинхронный метод может быть написан одним из двух следующих способов:

public async Task FooAsync()  
{
    await DoSomethingAsync();
}

public Task BarAsync()  
{
    return DoSomethingAsync();
}

В первом случае в методе ожидается завершение асинхронной операции, а результат оборачивается в другую задачу перед возвратом в вызывающий код. Во втором — задача для асинхронной операции возвращается напрямую. Если у вас есть асинхронный метод, который только лишь вызывает другой асинхронный метод (как это часто бывает с асинхронными перегрузками), вам следует избегать использования ключевых слов async/await, как во втором методе выше.

Обработка исключений в асинхронных методах

Методы с ключевым словом async могут безопасно генерировать исключения. Компилятор позаботится о том, чтобы обернуть исключения в Task.

public async Task FooAsync()  
{
    // This is fine
    throw new Exception("All your bases are belong to us.");
}

Тем не менее, возвращающие Task методы без ключевого слова async должны возвращать Task с исключением.

public Task FooAsync()  
{
    try
    {
        // Код, который генерирует исключение
    }
    catch (Exception e)
    {
        return Task.FromException(e);
    }
}

Уменьшение дублирования кода при реализации синхронных и асинхронных версий метода

Часто при разработке синхронных и асинхронных версий метода вы можете обнаружить, что единственное реальное различие между двумя реализациями заключается в том, что одна вызывает асинхронные версии различных методов, а другая вызывает синхронные. Когда реализация практически такая же, за исключением использования async/await, вы можете использовать различные хаки, чтобы уменьшить дублирование кода. Лучший и наименее хакерский метод, который я нашел, называется «Flag Argument Hack». По сути, вы вводите логическое значение, которое указывает, должен ли метод использовать синхронный или асинхронный доступ, а затем реализуете соответствующее ветвление кода:

private async Task<string> GetStringCoreAsync(bool sync, CancellationToken cancellationToken)
{
    return sync
        ? SomeLibrary.GetString()
        : await SomeLibrary.GetStringAsync(cancellationToken).ConfigureAwait(false);
}

public string GetString()
    => GetStringCoreAsync(true, CancellationToken.None)
        .ConfigureAwait(false)
        .GetAwaiter()
        .GetResult();

public Task<string> GetStringAsync()
    => GetStringAsync(CancellationToken.None);

public Task<string> GetStringAsync(CancellationToken cancellationToken)
    => GetStringCoreAsync(false, cancellationToken);

Создается впечатление, что кода здесь все-таки многовато, так что давайте его разберем. Во-первых, у нас есть приватный метод GetStringCoreAsync. В нем мы факторизуем наш общий код. Здесь мы просто вызываем какую-то другую библиотеку, в которой есть синхронные и асинхронные методы, чтобы получить какую-либо строку. По общему соглашению, для чего-то настолько простого вам действительно не следует использовать этот прием, а лучше сделать два метода, напрямую вызывающие свои соответствующие аналоги. Однако я не хотел запутать вас, используя в качестве примера слишком сложную реализацию. Как видите, главное здесь то, что мы реализуем ветвление по значению sync, чтобы использовать либо синхронный, либо асинхронный метод получения строки из библиотеки. Это будет работать как надо, до тех пор пока вы ожидаете завершения асинхронного метода, а это значит, что этот приватный метод должен иметь ключевое слово async. Мы также передаем CancellationToken на случай, если используемые внутри асинхронные методы можно отменить.

Затем, у нас есть синхронная и асинхронная реализации, которые просто вызывают приватный метод. Для синхронной версии нам нужно разворачивать Task, возвращаемый приватный методом. Для этого мы используем шаблон GetAwaiter().GetResult() для безопасной блокировки асинхронного вызова. Здесь нет опасности вызвать взаимную блокировку, потому что, хотя приватный метод и является асинхронным, когда мы передаем true в sync, фактически не используются асинхронные методы. Мы также используем ConfigureAwait(false), чтобы предотвратить присоединение контекста синхронизации, поскольку это совершенно ненужное раздувание расходов: здесь нет возможности переключения потоков.

Асинхронная реализация ничем не примечательна. Мы видим перегрузку, которая по умолчанию передает CancellationToken.None в случае, если не передается токен отмены, и фактическую реализацию, которая просто вызывает приватный метод с false в sync и токеном отмены.

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

Также можно привести аргумент, что если у вас так много логики, то ваш метод, вероятно, делает слишком много. Тут уж вам решать. Бывают случаи, когда что-то подобное на самом деле является лучшим решением, но вы должны тщательно оценить, так ли это, прежде чем использовать этот подход.

Асинхронность в консольных приложениях

class Program  
{
    static void Main(string[] args)
    {
        MainAsync(args).GetAwaiter().GetResult();
    }

    static async Task MainAsync(string[] args)
    {
        // ожидание завершения чего-то
    }
}

Как бы то ни было, C# 7.1 обещает поддержку Async Main, так что вам просто нужно будет делать:

class Program  
{
    static async Task Main(string[] args)
    {
        // ожидание завершения чего-то
    }
}

Однако на момент написания статьи это еще не работает. На самом деле это просто синтаксический сахар. Когда компилятор встречает асинхронный Main, он просто оборачивает его в обычный синхронный Main, как и в первом примере кода.

Обеспечение того, чтобы асинхронный код не блокировался

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

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

Task.Run(() => DoStuffAsync());  

Но погодите-ка. Разве это не то же самое, что мы использовали выше для запуска синхронного кода как «асинхронного»? Ага. Здесь работает тот же принцип: Task.Run запустит переданный ему делегат в новом потоке. В свою очередь, это означает, что он не будет работать в текущем потоке.

Как сделать синхронные операции Task-совместимыми

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

public Task DoSomethingAsync(CancellationToken cancellationToken)  
{
    if (cancellationToken.IsCancellationRequested)
    {
        return Task.FromCanceled(cancellationToken);
    }

    try
    {
        DoSomething();
        return Task.FromResult(0);
    }
    catch (Exception e)
    {
        return Task.FromException(e);
    }
}

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

Важно понимать, что на самом деле это не асинхронный режим. DoSomething все еще синхронный и блокирующий. Однако теперь его можно обрабатывать так, как если бы он был асинхронным, потому что он возвращает задачу. Зачем вам может понадобиться это делать? Ну, один из примеров — реализация шаблона адаптера, и один из источников, к которому вы пишете адаптер, не предлагает асинхронный API. Вы все еще должны удовлетворить интерфейсу, но вам следует прокомментировать метод, указав, что он на самом деле не асинхронный. Те, кто хочет использовать этот метод в ситуациях, когда им не нужно блокировать поток, могут вызывать его, передав его в качестве делегата в Task.Run.

Задачи возвращаются «горячими» 

Один из аспектов асинхронного программирования в C#, который не сразу очевиден, заключается в том, что задачи возвращаются «горячими» или уже запущенными. Ключевое слово await предназначено остановить выполнение кода пока задача не будет завершена, но на самом деле не запускает ее. Это становится действительно интересно, если вы посмотрите на эффект от параллельного выполнения задач.

await FooAsync();
await BarAsync();
await BazAsync();

Здесь три задачи выполняются последовательно. Только после завершения FooAsync запустится BarAsync, также как и BazAsync не запустится пока BarAsync не завершится. Это связано с тем, что задачи ожидаются инлайново. Теперь рассмотрим следующий код:

var fooTask = FooAsync();
var barTask = BarAsync();
var bazTask = BazAsync();

await fooTask;
await barTask;
await bazTask;

Здесь задачи выполняются параллельно. Это связано с тем, что все три запускаются до того, как для всех трех последовательно будет вызван await, опять же, потому что они возвращаются горячими.

Это может показаться немного нелогичным, учитывая, что существует Task.WhenAll. Зачем нужна эта функция, если все задачи уже запущены? Проще говоря, Task.WhenAll существует как способ дождаться завершения набора задач, чтобы код не продолжал выполнение, пока не будут готовы все результаты.

var factor1Task = GetFactor1Async();
var factor2Task = GetFactor2Task();

await Tasks.WhenAll(factor1Task, factor2Task);

var value = factor1Task.Result * factor2Task.Result;

Поскольку обе задачи должны быть выполнены, прежде чем мы сможем запустить строку с умножением, мы можем остановить выполнение, пока они обе не будут выполнены, с помощью await Task.WhenAll. В противном случае это не имеет смысла. Вы даже можете отказаться здесь от Task.WhenAll, если обе задачи вместо прямого вызова Result используют await:

var value = (await factor1Task) * (await factor2Task);

Короче говоря, это больше вопрос вашего предпочтения. Тем не менее, важно понимать, что задачи стартуют немедленно, а не их await заставляет их запускаться. Скорее, await просто не дает коду двигаться дальше, пока задача не завершится.


Узнать подробнее о курсе "C# Developer. Professional".

Смотреть вебинар «5 шагов к хорошему коду».

Источник: https://habr.com/ru/company/otus/blog/550812/


Интересные статьи

Интересные статьи

Благодаря успеху таких людей, как Стив Джобс, Билл Гейтс, Марк Цукерберг, профессия программиста стала популярной и востребованной у многих людей. Несмотря на то, что на ...
Зачем нефтяникам NLP? Как заставить компьютер понимать профессиональный жаргон? Можно ли объяснить машине, что такое «нагнеталка», «приемистость», «затрубное»? Как связаны вновь п...
Я тружусь в компании Mutual. Она работает в Бразилии, в сфере равноправного кредитования. Мы помогаем заёмщикам и заимодавцам наладить связь друг с другом. Первые ищут хорошие ставки,...
Моя история Так получилось, что у меня с детства уставали глаза от продожительного сидения перед экраном. Полтора года назад я сделал SMILE и стал видеть очень далеко, тут же встал вопрос, как п...
Ровно 15 лет назад, в этот самый день, своими дрожащими коленками я переступил порог офиса небольшого провинциального интернет-провайдера. Моя первая настоящая работа в качестве программиста....