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

Концепция асинхронного программирования

Представим гипотетический сценарий. Алиса составляет список дел, которые нужно сделать, планируя свой день. Ей нужно взять свою машину, чтобы ее обслужили; отправить посылку в почтовом отделении; и написать ее другу Бобу (как и обещала), чтобы рассказать ему все о недавнем отпуске.

Она решает съездить в гараж первым делом с утра. Когда она приезжает, механик Чарли говорит Алисе, что обслуживание займет около половины дня. Алиса может либо подождать в зоне ожидания, либо пойти в другое место, а Чарли предложит позвонить Алисе, когда работа будет сделана. С другими делами, оставшимися в ее списке дел, Алиса может выбрать, как подойти к оставшимся задачам.

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

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

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

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

Асинхронные методы были доступны уже давно: .NET Framework 1.1 включал в себя различные асинхронные операции. Однако для их эффективного использования требуются обратные вызовы.

Обратный вызов — это просто метод, запускаемый после завершения асинхронной операции — очень похоже на то, как Алиса вернется в гараж, как только Чарли позвонит Алисе, чтобы сообщить ей, что ее машина готова. Хотя их несложно написать, они усложняют код, на который не влияет синхронное программирование.

В C# 5.0 была представлена новая языковая функция, которая облегчила это: вместо того, чтобы требовать обратных вызовов, мы можем просто await асинхронные методы.

Причины быть асинхронными

Асинхронное программирование особенно полезно при написании CRUD API, т. е. с операциями по созданию, чтению, обновлению и удалению данных. Большая часть работы (с точки зрения API) связана либо с вводом-выводом, либо с сетью, а не с процессором.

Другими словами, узкими местами производительности, скорее всего, будут операции чтения/записи или задержка сети. Наличие дополнительной мощности ЦП на хосте API не поможет ускорить процесс, поскольку для завершения операции требуются внешние службы. Даже если некоторые из этих процессов заняли всего секунду или две, для современного ЦП, работающего на частоте 2,5 ГГц, это может быть вечностью: 2 500 000 000 циклов будут потрачены впустую на каждую секунду, которую он тратит на бездействие во время ожидания. Подобно тому, как Алиса могла посетить почтовое отделение, а затем написать Бобу, ожидая свою машину, API может с пользой потратить время ожидания, воздействуя, например, на другие входящие запросы.

Написание асинхронного метода

Написание асинхронного кода просто с использованием модели async/ await. В приведенном ниже примере кода мы создали службу, которая инкапсулирует экземпляр .NET HttpClient. Пока есть только один метод: он оборачивает метод для выполнения GET-запроса.

public interface IHttpService
{
    Task<T?> GetData<T>(string url);
}

public class HttpService : IHttpService
{
    private readonly HttpClient _httpClient = new();

    public async Task<T?> GetData<T>(string url)
    {
        var response = await _httpClient.GetAsync(url);
        var content = await response.Content.ReadFromJsonAsync<T>();
        return content;
    }
}

Следует отметить четыре важных момента:

  • Тип возвращаемого значения — типизированный Task. Для методов, которые были бы void в синхронном коде, возвращаемый тип будет Task (без универсального типа).
  • Мы используем ключевое слово await для ожидания асинхронных операций: _httpClient.GetAsync и ReadFromJsonAsync в данном случае.
  • Сигнатура метода в интерфейсе не включает ключевое слово async.
  • Сигнатура метода в классе включает ключевое слово async.

Но кроме этого, в этом нет ничего другого — никаких обратных вызовов не требуется.

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

Тестирование асинхронных методов

Теперь, когда у нас есть асинхронный метод, давайте напишем для него тест. Ниже показан тест NUnit/Moq для нашего файла HttpService.

[Test]
public async Task HttpServiceCanGetData()
{
    // Arrange

    var service = Mock.Of<IHttpService>(s =>
        s.GetData<string>(It.IsAny<string>()) == Task.FromResult<string>("Some data"));

    // Act

    var response = await service.GetData<string>("url");

    // Assert

    Assert.That(response, Is.EqualTo("Some data"));
}

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

  • Мы обертываем возвращаемое значение Task.FromResult в настройке LINQ to Mocks.
  • Тест помечен async и имеет тип возвращаемого значения Task.
  • Мы await тестируем асинхронный метод.

Если бы мы хотели использовать свободный синтаксис Moq, мы могли бы написать тот же тест со следующим кодом:

[Test]
public async Task HttpServiceCanGetData()
{
    // Arrange

    var serviceMock = new Mock<IHttpService>();

    serviceMock
        .Setup(s => s.GetData<string>(It.IsAny<string>()))
        .ReturnsAsync((string s) => "Some data");

    // Act

    var response = await serviceMock.Object.GetData<string>("url");

    // Assert

    Assert.That(response, Is.EqualTo("Some data"));
}

Краткое содержание

Некоторые операции программирования ожидают ответов от внешних служб/баз данных. Асинхронный код позволяет хосту максимально эффективно использовать свой ЦП. Вместо того, чтобы бездействовать, поток может быть переназначен для выполнения других задач во время этих задержек. Например, они могут реагировать на новые входящие запросы, чтобы API оставался отзывчивым для других пользователей. Когда данные доступны для дальнейшей обработки, исходная задача может быть возобновлена.

Хотя эта концепция доступна начиная с .NET Framework 1.1, необходимость использования обратных вызовов увеличивает сложность кода. В C# 5.0 появился синтаксис async/await, упрощающий работу с асинхронным кодом.

Чтобы написать асинхронный метод, вам просто нужно сделать три вещи: пометить метод ключевым словом async, изменить тип возвращаемого значения на Task и использовать ключевое слово await для префикса вызовов метода, которые необходимо ожидать.

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

Первоначально опубликовано на https://webdeveloperdiary.substack.com.