Асинхронное программирование в C#

Вы хотите узнать, почему асинхронное программирование важно, как его использовать в C# и какие преимущества имеет асинхронный код? Давайте разберемся!

Почему мы должны использовать асинхронное программирование?

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

Настольное приложение

Предположим, что в нашем настольном приложении есть кнопка «Процесс», и когда пользователь нажимает на нее, происходят вычисления, требующие доступа к внешним ресурсам (например, API или базе данных). Если мы не используем асинхронность, то все приложение будет заморожено на время обработки — наш пользователь не сможет взаимодействовать с приложением. Иная ситуация складывается с мощью асинхронного кода. Основной поток, отвечающий за отображение представления приложения, не будет заблокирован, и заморозки не возникнет.

Давайте визуализируем эту ситуацию более детально:
1) Слева находится наше настольное приложение. Справа — детали, связанные с CPU и потоками. Когда приложение впервые рендерило представление (UI), поток использовал некоторые ресурсы для этого (черный прямоугольный элемент). В дальнейшем мы можем наблюдать, что никаких других действий не происходит, и приложение простаивает. Ничего не будет происходить до тех пор, пока пользователь не начнет взаимодействовать с пользовательским интерфейсом.

2) Представим, что пользователь нажал на кнопку. Приложение стало активным, и для обработки взаимодействия с пользователем был использован поток.

3) Если вы помните, мы предположили, что действие за этой кнопкой — это доступ к внешнему API или базе данных. Давайте посмотрим, что произойдет:

4) И здесь самый важный момент: когда мы не используем асинхронное программирование, наш поток будет активно ждать окончания вызова API. В это время приложение будет заморожено, и пользователь не сможет с ним взаимодействовать:

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

Вышеописанная ситуация была описана для настольного приложения, но давайте будем честными — этот тип не так популярен, как раньше. В настоящее время мы обычно создаем веб-приложения с бэкендом. Итак, какое влияние на него окажет асинхронное программирование?

Приложение для веб-сервера

Предположим, что у нас есть API, написанный на C#, и одна из его конечных точек обращается к базе данных для получения некоторых данных. Если мы не используем асинхронное программирование, поток будет ждать во время операции получения данных из базы данных. В результате, когда к нашему API придет новый запрос, мы не сможем эффективно его обработать. Что еще хуже, наша система может создать несколько дополнительных потоков в фоновом режиме для обработки новых запросов. Почему эта ситуация не очень хорошая? Потому что создание новых потоков требует больших затрат, а когда процессор занят первым запросом, вновь созданные потоки не будут запланированы, и, к сожалению, они будут ждать.

Давайте понаблюдаем за этой ситуацией на изображениях:
1) В самом начале мы видим, что был использован поток. Основной целью для него был запуск веб-сервера, выполнение некоторых операций запуска и т.д. После этого сервер простаивает, так как нет никаких пользовательских запросов:

2) Наш API получил один HTTP-запрос. Обработкой этого запроса займется поток:

3) Во время этого запроса мы хотим получить некоторые данные из внешнего ресурса. Когда мы дойдем до кода C#, отвечающего за получение данных из базы данных, ситуация будет следующей:

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

5) При асинхронном программировании у нас будет свободный процессор, который сможет обрабатывать новые запросы, поэтому наш API будет более эффективным — он сможет обрабатывать больше запросов, чем в предыдущей ситуации.

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

Теперь вы должны знать, в чем преимущества асинхронного программирования. Давайте посмотрим, как мы можем использовать его в C#.

Async и await

Если мы хотим использовать возможности асинхронности, мы можем легко сделать это в C# с помощью двух ключевых слов: async и await. Полезно знать, что с этими словами связаны некоторые правила — давайте разберемся в этом.

1. Метод, использующий await, должен быть async

Если предположить, что мы хотим сделать какой-то HTTP-запрос, то мы можем сделать это таким образом:

public async Task DoSth()
{
     var client = new HttpClient();
     var result = await client.GetAsync("https://www.kamilbugno.com");
     //...
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Обратите внимание, что GetAsync является асинхронным методом и поэтому должен вызываться с ключевым словом await. Каждый метод, использующий await, должен быть помечен как асинхронный. Без наличия async в объявлении метода мы получим ошибку, и код не будет компилироваться правильно.

2. Можно выполнять операции async без await.

Следующий код будет скомпилирован, даже если в нем нет ключевого слова await:

public async Task DoSth()
{
     var client = new HttpClient();
     var result = client.GetAsync("https://www.kamilbugno.com");
     //...
}
Вход в полноэкранный режим Выход из полноэкранного режима

Более того, мы также можем убрать слово async, поскольку в теле метода нет использования await. Этот процесс называется ‘Eliding async and await’. Следует знать, что он имеет серьезные последствия и рекомендуется только в особых случаях.

Последствия

Код будет просто продолжать работать, не заботясь об операции async. Это может показаться преимуществом, но есть некоторые проблемы, связанные с этим:

  • оператор using: если наш код находится в операторе using, объект может быть утилизирован до завершения операции, и, как следствие, операция не будет успешной.
  • блок try/catch: может случиться так, что выполнение достигнет конца блока try/catch до завершения операции, и в результате мы не сможем правильно обработать исключение.
  • неправильное использование ‘Eliding async and await’ может даже привести к завершению всего процесса.

Пример из практики

Предположим, что у нас есть два вложенных метода A и B (B вызывается внутри A и содержит некоторые операции async). Оба метода A и B используют слово async/await. В этом сценарии мы можем пропустить async/await во внутреннем методе (B), потому что в конечном итоге он будет ожидаться в методе A. Это единственная ситуация, когда рассмотрение ‘Eliding async and await’ может быть хорошей идеей.

Пример:

public async Task A()
{
    var result = await B();
    //...
}

public Task<HttpResponseMessage> B()
{
    var client = new HttpClient();
    return client.GetAsync("https://www.kamilbugno.com");
}
Вход в полноэкранный режим Выход из полноэкранного режима

3. Метод async может возвращать Task, Task<T>, ValueTask<T>, или быть void.

Метод async может возвращать несколько типов, и сейчас мы остановимся на наиболее распространенных:

a) Task vs void

Task обычно используется, когда мы не хотим возвращать какое-либо реальное значение. Почему не стоит использовать void? Действительно, в неасинхронном мире в этом сценарии мы будем использовать void без раздумий, но в асинхронном коде это уже не актуально. Объект Task содержит некоторую дополнительную информацию об операции и может быть ожидаемым, поэтому чаще всего вместо void встречается Task. Более того, невозможно использовать слово await для вызова метода async, который возвращает void.

b) Task<T> vs ValueTask<T>

Когда мы хотим вернуть некоторое значение, у нас есть два варианта Task<T> и ValueTask<T>. В чем разница? Первый является ссылочным типом, а второй — типом значения. ValueTask<T> в некоторых случаях лучше по производительности, чем Task<T>. Есть один особый сценарий для его использования: когда путь выполнения может получать данные как синхронно, так и асинхронно. Звучит сложно? Давайте посмотрим на примере:

public async ValueTask<int> GetPageVersion()
{
     if (_cachedValue == null)
     {
         var client = new HttpClient();
         var result = await client.GetAsync("https://www.kamilbugno.com");
         _cachedValue = result.Version.Build;
     }    
     return _cachedValue.Value;     
}
Вход в полноэкранный режим Выход из полноэкранного режима

Как видите, метод GetPageVersion ведет себя по-разному в зависимости от кэшированного значения. Скорее всего, большую часть времени асинхронный код не будет выполняться. В этом случае ValueTask<T> будет лучшим выбором. В любых других сценариях вы должны рассмотреть Task<T>.

4. Асинхронный метод может быть вызван синхронно

Очень важно уметь различать ситуацию, когда у нас есть синхронный и асинхронный код.
Рассмотрим пример:

public int GetPageVersion()
{
     var client = new HttpClient();
     var result = client.GetAsync("https://www.kamilbugno.com").GetAwaiter().GetResult();
     return result.Version.Build;
}
Вход в полноэкранный режим Выход из полноэкранного режима

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

Теперь вы должны знать основы использования async и await в вашем коде C#. Но действительно ли мы знаем, как это работает? Давайте изучим эту тему более глубоко.

Под капотом асинхронности

Чтобы заглянуть под капот, нам понадобится программа просмотра промежуточного языка (IL). Когда мы собираем проект, среда .NET генерирует IL-код из нашего исходного кода, и благодаря этому мы можем реально увидеть, что здесь происходит.

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

Он не очень читабелен, но мы видим, что в некотором смысле он похож на оригинальный метод, не так ли? Но что произойдет, если мы добавим в объявление метода слово async?

Как вы можете видеть, IL-код полностью изменится. Вместо метода DoSth у нас теперь есть класс. Почему? Потому что слова async/await используют машину состояний. Если вы внимательно посмотрите на IL-код, то увидите, что DoSth реализует интерфейс IAsyncStateMachine, содержит свойство state и имеет метод MoveNext.

Как работает машина состояний?

Важно, что машина состояний — это не сущность, которая существует только в мире C# или связана только с асинхронностью. Машина состояний — это реализация паттерна проектирования состояний, и вы можете создать свою собственную машину состояний. Она может вести себя по-разному в зависимости от своего внутреннего состояния. Звучит запутанно? Давайте представим, что у нас есть следующая машина состояний.

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

Аналогичную ситуацию мы имеем в C#, когда речь идет об асинхронном программировании. Ранее упомянутый метод MoveNext отвечает за изменение состояния машины и выполнение некоторых действий.

Предположим, что у нас есть простой асинхронный метод:

public async Task<string> DoSth()
{
     Console.WriteLine("one");
     var client = new HttpClient();
     var result = await client.GetStringAsync("https://www.kamilbugno.com");
     Console.WriteLine("two");
     return result;
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Метод MoveNext, который генерируется для этого кода, может выглядеть примерно так:

private void MoveNext()
{
    int num = this.state;
    string result;
    try
    {
        TaskAwaiter<string> awaiter;
        if (num != 0)
        {
            Console.WriteLine("one");
            client = new HttpClient();
            awaiter = client.GetStringAsync("https://www.kamilbugno.com").GetAwaiter();
            if (!awaiter.IsCompleted)
            {
                num = (state = 0);
                this.taskAwaiter = awaiter;
                this.stateMachine = this;
                this.asyncTaskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);
                return;
            }
        }
        else
        {
            awaiter = this.taskAwaiter;
            this.taskAwaiter = default(TaskAwaiter<string>);
            num = (state = -1);
        }
        this.myString = awaiter.GetResult();
        this.result = this.myString;
        this.myString = null;
        Console.WriteLine("two");
        result = this.result;
    }
    catch (Exception exception)
    {
        state = -2;
        client = null;
        this.result = null;
        this.builder.SetException(exception);
        return;
    }
    state = -2;
    this.client = null;
    this.result = null;
    this.builder.SetResult(result);
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Я изменил некоторые имена переменных, чтобы сделать их более читабельными. Поток этой машины состояний выглядит следующим образом:
1) В начале у нас this.state установлено значение -1.
2) Вызывается метод MoveNext. Из-за значения состояния оператор if является истинным, поэтому мы выполняем этот фрагмент кода:

Console.WriteLine("one");
client = new HttpClient();
awaiter = client.GetStringAsync("https://www.kamilbugno.com").GetAwaiter();
Войти в полноэкранный режим Выходим из полноэкранного режима

Предположим, что awaiter не завершен, поэтому мы установим состояние в 0 и выйдем из метода:

num = (state = 0); 
this.taskAwaiter = awaiter; 
this.stateMachine = this;
this.asyncTaskMethodBuilder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);
return;
Войти в полноэкранный режим Выйти из полноэкранного режима

3) Снова вызывается метод MoveNext. Блок else будет выполнен, так как текущее состояние машины равно 0:

awaiter = this.taskAwaiter; 
this.taskAwaiter = default(TaskAwaiter<string>); 
num = (state = -1);
Вход в полноэкранный режим Выход из полноэкранного режима

В результате состояние будет временно установлено в -1. Позже будет выполнен следующий фрагмент кода:

this.myString = awaiter.GetResult();
this.result = this.myString;
this.myString = null;
Console.WriteLine("two");
result = this.result;
Войти в полноэкранный режим Выйти из полноэкранного режима

Наконец, будет выполнен последний фрагмент кода:

state = -2;
this.client = null;
this.result = null;
this.builder.SetResult(result);
Enter fullscreen mode Выход из полноэкранного режима

4) Машина находится в состоянии -2, и мы получили результат нашей операции.

В приведенном выше примере показана машина состояний, которая была сгенерирована исходным кодом, содержащим только одно ключевое слово await. Когда мы используем await более одного раза в данном методе, машина состояний заканчивается более сложной версией MoveNext. Если вы хотите поэкспериментировать с этим, я рекомендую https://sharplab.io/ для просмотра Intermediate Language или загружаемый инструмент ILSpy, который обладает аналогичной функциональностью.

До сих пор мы узнали, что такое машина состояний и как она помогает нам с асинхронностью, но нам осталось разгадать одну загадку: кто вызывает метод MoveNext?

Пул потоков

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

Одной из составляющих пула потоков является очередь, в которой хранятся свободные потоки.

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

С точки зрения Thread Pool, как примерно будет выглядеть выполнение следующей программы?

public async Task<string> DoSth()
{
     var client = new HttpClient();
     var result = await client.GetStringAsync("https://www.kamilbugno.com");
     return result;
}
Вход в полноэкранный режим Выход из полноэкранного режима

С учетом некоторых упрощений, способ, которым пул потоков помогает нам в выполнении, не очень сложен:

  1. Будет создана государственная машина, и пул потоков запланирует обращение к Интернету, запустив метод MoveNext (для этого может быть использован новый/другой поток),
  2. Веб-запрос был отправлен, и мы ждем ответа…
  3. Пул потоков получает уведомление от внешнего объекта, например, сетевого драйвера, что веб-запрос завершен, и снова запускает метод MoveNext (для этого может быть использован новый/другой поток).
  4. Мы получаем результат нашей операции, и метод DoSth завершается.

Теперь вы должны знать все, что необходимо для осознанного использования возможностей асинхронности в C#.

Резюме

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

Вы также можете прочитать этот пост в моем официальном блоге: https://blog.kamilbugno.com/.

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *