Асинхронное программирование в .NET - распространенные ошибки и рекомендации
В предыдущей статье мы начали анализировать асинхронное программирование в мире .NET. Там мы выдвинули опасения о том, что эта концепция несколько неправильно понята, хотя она существует уже более шести лет, т. е. со времен NET 4.5. Используя этот стиль программирования, легче писать легко адаптируемые приложения, которые выполняют асинхронные, неблокирующие операции ввода-вывода. Это делается с помощью операторов async/wait.
Однако эта концепция часто используется неправильно. В этой статье мы рассмотрим некоторые из наиболее распространенных ошибок при использовании асинхронного программирования и дадим вам некоторые рекомендации. Мы немного погрузимся в потоки и обсудим лучшие практики.
Async Void
При чтении предыдущей статьи вы могли заметить, что методы, помеченные асинхронным способом, могут возвращать и Task, и Task<T> или любой тип, который в результате имеет доступный метод GetAwaiter. Ну, это немного вводит в заблуждение, потому что эти методы могут, по сути, также возвращать пустой тип. Однако это одна из плохих практик, которых мы хотим избежать, поэтому мы старались игнорировать ее. Почему это использование концепции неправильное? Ну, хотя в асинхронных методах можно вернуть пустой тип, цель этих методов совершенно иная. Точнее, такие методы имеют очень специфическую задачу, а именно - создание асинхронных обработчиков.
Хотя возможно иметь обработчики событий, которые возвращают некоторый фактический тип, это не очень хорошо работает с языком, и это понятие не имеет большого смысла. Кроме того, некоторые семантики асинхронных методов отличаются от async Task или async Task <T>. Например, обработка исключений не является одинаковой. Если исключение выбрано в методе async Task, оно будет зафиксировано и помещено в объект Task. Если исключение выбрано внутри метода async void, оно будет поднято непосредственно в активном SynchronizationContext.
private async void ThrowExceptionAsync()
{
throw new Exception("Async exception");
}
public void AsyncVoidExceptions_CannotBeCaughtByCatch()
{
try
{
ThrowExceptionAsync();
}
catch (Exception)
{
// The exception is never caught here!
throw;
}
}
Есть еще два недостатка в использовании async void. Во-первых, эти методы не обеспечивают простой способ уведомления вызывающего кода, который они завершили. Кроме того, из-за этого первого недостатка их очень сложно проверить. Структуры тестирования модулей, такие как xUnit или NUnit, работают только для асинхронных методов, возвращающих Task или Task <T>. Принимая во внимание все это, использование async void, как правило, не одобряется и вместо этого используется async Task. Единственное исключение может быть в случае асинхронных обработчиков событий, которые должны возвращать void.
Потока нет
Вероятно, самые большие заблуждения относительно асинхронного механизма в .NET - это то, что в фоновом режиме существует какой-то асинхронный поток. Хотя кажется вполне логичным, что, когда вы ожидаете некоторой операции, есть поток, который делает ожидание, это не так. Чтобы понять это, давайте сделаем несколько гигантских шагов назад. Когда мы используем наш компьютер, у нас одновременно работает несколько программ, что достигается за счет запуска инструкций из разных процессов по одному на ЦП.
Поскольку эти инструкции чередуются, и ЦП переключается с одного на другой быстро (контекстный переключатель), мы получаем иллюзию, что они работают одновременно. Этот процесс называется многопоточным выполнением. Теперь, когда у нас есть несколько ядер в нашем процессоре, мы можем запускать несколько потоков этих инструкций для каждого ядра. Это называется параллелизмом. Теперь важно понять, что обе эти концепции доступны на уровне ЦП. На уровне ОС у нас есть концепция потоков - последовательность инструкций, которые могут управляться независимо планировщиком.
Итак, почему мы читаем вам лекцию Computer Science 101? Ну, потому что ожидание, о котором мы говорили немного раньше, происходит на том уровне, где понятие потоков еще не существует. Давайте рассмотрим эту часть кода, общую операцию записи на устройство (сеть, файл и т. д.):
public async Task WriteMyDeviceAcync
{
byte[] data = ...
myDevice.WriteAsync(data, 0, data.Length);
}
Теперь, давай спустимся по кроличьей норе. WriteAsync начнет операцию перекрытия ввода-вывода на базовый дескриптор устройства. После этого ОС вызовет драйвер устройства и попросит его начать операцию записи. Это делается в два этапа. Во-первых, создается объект запроса на запись - пакет запроса ввода-вывода или IRP. Затем, когда драйвер устройства получает IRP, он выдает команду фактическому устройству для записи данных. Здесь есть один важный факт: не разрешается блокировать драйвер устройства при обработке IRP, даже для синхронных операций.
Это имеет смысл, так как этот драйвер может получить и другие запросы, и это не должно быть узким местом. Поскольку это не так много, как можно было бы сделать, драйвер устройства маркирует IRP как «ожидающий» и возвращает его в ОС. IRP теперь «ожидает», поэтому ОС вернется к WriteAsync. Этот метод возвращает неполную задачу WriteMyDeviceAcync, которая приостанавливает метод async, и вызывающий поток продолжает выполнение.
Через некоторое время устройство завершит запись, оно отправит уведомление ЦП и начнется магия. Это происходит через прерывание, которое находится на уровне ЦП и которое будет управлять процессором. Драйвер устройства должен ответить на это прерывание, и это делается в ISR - Interrupt Service Routine. ISR взамен представляет собой вызов, называемый «Отложенный вызов процедуры» (DCP), который обрабатывается ЦП, когда он выполняется с прерываниями.
DCP помечает IRP как «завершенную» на уровне ОС, а ОС рассылает асинхронный вызов процедур (APC) в поток, которому принадлежит дескриптор. Затем поток потока потоков ввода-вывода кратко заимствуется для выполнения APC, который уведомляет о завершении задачи. Контекст пользовательского интерфейса поймает это и знает, как возобновить работу. Затем поток пула потоков ввода-вывода кратко заимствуется для выполнения APC, который уведомляет о завершении задачи. Контекст пользовательского интерфейса поймает это и знает, как возобновить работу.
Обратите внимание, как инструкции, которые обрабатывают ожидание - ISR и DCP, выполняются непосредственно на ЦП, «ниже» ОС и «ниже» существования потоков. По сути, нет потока ни на уровне ОС, ни на уровне драйвера устройства, который обрабатывает асинхронный механизм.
Предпросмотр и свойства
Одной из распространенных ошибок является использование ожидания внутри цикла foreach. Взгляните на этот пример:
var listOfInts = new List<int>() { 1, 2, 3 };
foreach (var integer in listOfInts)
{
await WaitThreeSeconds(integer);
}
Теперь, хотя этот код написан асинхронным образом, он будет блокировать выполнение потока каждый раз, когда ожидается WaitThreeSeconds. Это реальная ситуация, например, WaitThreeSeconds вызывает какой-то веб-API, предположим, что он выполняет запрос HTTP GET, передающий данные для запроса. Иногда у нас бывают ситуации, когда мы хотим это сделать, но если мы реализуем их таким образом, мы будем ждать завершения каждого цикла “запрос-ответ” до того, как мы начнем новый. Это неэффективно.
Вот наша функция WaitThreeSeconds:
private async Task WaitThreeSeconds(int param)
{
Console.WriteLine($"{param} started ------ ({DateTime.Now:hh:mm:ss}) ---");
await Task.Delay(3000);
Console.WriteLine($"{ param} finished ------({ DateTime.Now:hh: mm: ss}) ---");
}
Если мы попытаемся запустить этот код, мы получим что-то вроде этого:
Для выполнения этого кода требуется девять секунд. Как упоминалось ранее, он очень неэффективен. Обычно мы ожидаем, что каждая из этих задач будет запущена, и все будет сделано параллельно (чуть более трех секунд).
Теперь мы можем изменить код из приведенного выше:
var listOfInts = new List<int>() { 1, 2, 3 };
var tasks = new List<Task>();
foreach (var integer in listOfInts)
{
var task = WaitThreeSeconds(integer);
tasks.Add(task);
}
await Task.WhenAll(tasks);
Когда мы запустим его, мы получим что-то вроде этого:
Это именно то, что мы хотели. Если мы хотим записать его с меньшим кодом, мы можем использовать LINQ:
var tasks = new List<int>() { 1, 2, 3 }.Select(WaitThreeSeconds);
await Task.WhenAll(tasks);
Этот код возвращает тот же результат, и он делает то, что мы хотели.
И да, я видел примеры, когда инженеры использовали async/await в свойстве косвенно, так как вы не можете использовать async/await непосредственно в свойстве. Это довольно странная вещь, и лучше держаться как можно дальше от этого противодействия.
Асинхронность от начала до конца
Асинхронный код иногда сравнивается с вирусом зомби. Он распространяется через код от самых высоких до самых низких уровней абстракции. Это связано с тем, что асинхронный код работает лучше всего, когда он вызывается из части другого асинхронного кода. В качестве общего руководства вы не должны смешивать синхронный и асинхронный код, и это то, что означает «асинхронность от начала до конца». Существуют две распространенные ошибки, совершаемые при сочетании синхронного и асинхронного кодов:
- Блокировка в асинхронном коде
- Создание асинхронных оберточных кодов для синхронных методов
Первая ошибка, безусловно, является одной из самых распространенных, приводящих к тупику. Кроме того, блокировка в асинхронном методе занимает потоки, которые лучше использовать в других местах. Например, в контексте ASP.NET это означало бы, что поток не может обслуживать другие запросы, в то время как в контексте GUI это означает, что поток не может использоваться для рендеринга. Давайте посмотрим на этот фрагмент кода:
public async Task InitiateWaitTask()
{
var delayTask = WaitAsync();
delayTask.Wait();
}
private static async Task WaitAsync()
{
await Task.Delay(1000);
}
Почему этот код может зайти в тупик? Ну, это длинное повествование о SynchronizationContext, который используется для захвата контекста работающего потока. Точнее, когда ожидается незавершенная задача, текущий контекст потока сохраняется и используется позже, уже при завершенной задаче. Этот контекст представляет собой текущий SynchronizationContext, т. е. текущую абстракцию потоковой передачи внутри приложения. Приложения GUI и ASP.NET имеют SynchronizationContext, который позволяет запускать только один кусок кода за раз. Однако приложения ASP.NET Core не имеют SynchronizationContext, поэтому они не будут блокироваться. Проще говоря, вы не должны блокировать асинхронный код.
Сегодня многие API имеют пары асинхронных и методов, например, Start () и StartAsync (), Read () и ReadAsync (). У нас может возникнуть соблазн создать их в нашей собственной синхронной библиотеке, но дело в том, что мы, вероятно, не должны этого делать. Как прекрасно описывает Стивен Туб в своем блоге, если разработчик хочет добиться отклика или параллелизма с синхронным API, он может просто завернуть вызов Task.Run (). Нам не нужно делать это в нашем API.
Вывод
Подводя итог, заметим, что когда вы используете асинхронный механизм, старайтесь избегать использования методов async void, за исключением особых случаев асинхронных обработчиков событий. Имейте в виду, что во время потока async/await нет лишних потоков, и этот механизм выполняется на более низком уровне. Кроме того, старайтесь не использовать ожидания в петлях foreach и в свойствах, так как это просто не имеет смысла. И да, не смешивайте синхронный и асинхронный код, это даст вам ужасные головные боли.