Асинхронное программирование для начинающих с использованием Async / Await в C #

Асинхронное программирование Программирование и разработка

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

Async и Await ключевые слова были введены в C # сделать асинхронное программирование на платформе.NET проще. Эти ключевые слова коренным образом изменили способ написания кода в большей части экосистемы C #. Асинхронное программирование стало распространённым явлением, и современные платформы, такие как ASP.NET Core, полностью асинхронны.

Оказывая такое влияние на экосистему C #, асинхронное программирование оказывается весьма ценным. Но что такое асинхронное программирование в первую очередь?

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

Начнём с терминологии.

Параллельный или асинхронный

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

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

Давайте в качестве примера возьмём приложение с графическим интерфейсом.

Синхронное выполнение: выполнение действий одно за другим

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

Одновременное выполнение: одновременное выполнение нескольких задач.

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

Параллельно: создание нескольких копий чего-либо одновременно

Пользователь даёт указание приложению обработать все файлы в папке. Приложение запускает несколько потоков с логикой обработки и распределяет файлы между этими потоками.

Асинхронный: не нужно ждать завершения одной задачи перед запуском другой

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

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

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

Поток выполнения не должен ждать завершения задачи, связанной с вводом-выводом или с привязкой к ЦП.

Примерами операций ввода-вывода могут быть доступ к файловой системе, доступ к БД или HTTP-запрос. Примерами операций, связанных с ЦП, могут быть изменение размера изображения, преобразование документа или шифрование / дешифрование данных.

Преимущества

Использование асинхронного программирования имеет несколько преимуществ:

избежать истощения пула потоков с помощью «паузы» ? выполнение и освобождение потока обратно в пул потоков во время асинхронных действий
поддержание отзывчивости интерфейса
возможное увеличение производительности за счёт параллелизма

Шаблоны асинхронного программирования

.NET предоставляет три шаблона для выполнения асинхронных операций.

Модель асинхронного программирования (APM): LEGACY

А также известный как IAsyncResultшаблон, он реализуется двумя способами: BeginOperationNameи EndOperationName.

public class MyClass { 
    public IAsyncResult BeginRead(byte [] buffer, int offset, int count, AsyncCallback callback, object state) {...};
    public int EndRead(IAsyncResult asyncResult);
} 

Из документации Microsoft:

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

Асинхронный шаблон на основе событий (EAP): LEGACY

Этот шаблон реализуется записью OperationNameAsyncметода и OperationNameCompletedсобытия:

Конец формы

public class MyClass { 
    public void ReadAsync(byte [] buffer, int offset, int count) {...};
    public event ReadCompletedEventHandler ReadCompleted;
} 

Асинхронная операция будет запущена с помощью метода async, который инициирует Completedсобытие, чтобы сделать результат доступным после завершения операции async. Класс, использующий EAP, также может содержать OperationNameAsyncCancelметод отмены текущей асинхронной операции.

Асинхронный шаблон на основе задач (TAP): РЕКОМЕНДУЕТСЯ

У нас есть только OperationNameAsyncметод, который возвращает Taskуниверсальный Taskобъект:

public class MyClass { 
    public Task<int> ReadAsync(byte [] buffer, int offset, int count) {...};
} 

Taskа Taskклассы моделируют асинхронные операции в TAP. Для понимания TAP важно понимать классы Taskи Task, что важно для понимания и использования ключевых слов async/ await, поэтому давайте поговорим об этих двух классах более подробно.

Задача <T>

Task И Task<T> классы являются основой асинхронного программирования в.NET. Они облегчают все виды взаимодействия с асинхронной операцией, которую они представляют, например:

  • Добавление дополнительных задач;
  • блокирование текущего потока для ожидания завершения задачи;
  • сигнализация отмены (через CancellationTokens).
Читайте также:  Руководство для начинающих по языку программирования Elixir

После запуска асинхронной операции и получения объекта Taskor Taskвы можете продолжать использовать текущий поток выполнения для асинхронного выполнения других инструкций, которым не нужен результат задачи, или для взаимодействия с задачей по мере необходимости.

Вот пример кода, который использует задачи для визуализации того, как это выглядит в действии:

using System;
using System.Threading.Tasks;

public class Example {
    public static void Main() {
       Task<DataType> getDataTask = Task.Factory.StartNew(() => { return GetData(); } );
       Task<ProcessedDataType> processDataTask = getDataTask.ContinueWith((data) => { return ProcessData(data);} );
       Task saveDataTask = processDataTask.ContinueWith((pData) => { SaveData(pData)} );
       Task<string> displayDataTask = processDataTask.ContinueWith((pData) => { return CreateDisplayString(pData); } );
       Console.WriteLine(displayDataTask.Result);
       saveDataTask.Wait();
    }
}

Пройдёмся по коду:

  • Мы хотим получить некоторые данные. Используем Task.Factory.StartNew()для создания задачи, которая сразу запускается. Эта задача запускает GetData()метод асинхронно и по завершении назначает данные своему.Resultсвойству. Мы присваиваем этот объект задачи getDataTaskпеременной.
  • Мы хотим обработать данные, GetData()которые предоставит метод. Вызывая.ContinueWith() метод, мы асинхронно создаём ещё одну задачу и устанавливаем её как продолжение getDataTask. Эта вторая задача примет значение.Resultпервой задачи в качестве входного параметра ( data) и вызовет ProcessData()метод с ним асинхронно. По завершении он назначит обработанные данные своему.Resultсвойству. Назначаем эту задачу processDataTaskпеременной. (Важно отметить, что на данный момент мы не знаем, getDataTaskзавершено ли завершение или нет, и нам всё равно. Мы просто знаем, что мы хотим, чтобы произошло, когда он будет завершён, и мы пишем для этого код.)
  • Мы хотим сохранить обработанные данные. А также мы используем тот же подход для создания третьей задачи, которая будет вызывать SaveData() асинхронно после завершения обработки данных, и устанавливаем её в качестве продолжения на processDataTask.
  • Мы также хотим отображать обработанные данные. Нам не нужно ждать сохранения данных перед их отображением, поэтому мы создаём четвёртую задачу, которая будет асинхронно создать строку отображения из обработанных данных, когда обработка данных будет завершена, и установим её также как продолжение в processDataTask. (Теперь у нас есть две задачи, которым назначены продолжения processDataTask. Эти задачи будут запускаться одновременно, как только processDataTaskбудут выполнены.)
  • Мы хотим вывести отображаемую строку на консоль. Мы звоним Console.WriteLine()с.Result собственностью displayDataTask..ResultДоступ свойство является блокирование операции; наш поток выполнения будет заблокирован, пока не displayDataTaskбудет завершён.
  • Мы хотим убедиться, что данные сохранены, прежде чем выйти из Main()метода и выйти из программы. Однако на данный момент мы не знаем состояние saveDataTask. Мы вызываем.Wait()метод, чтобы заблокировать наш поток выполнения до saveDataTaskзавершения.

Почти хорошо

Как показано выше, классы TAP и Task/ Task<T>очень эффективны для применения методов асинхронного программирования. Но есть ещё возможности для улучшения:

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

Эти недостатки могут стать серьёзной проблемой для команд при внедрении TAP.

Здесь в игру вступают ключевые слова asyncи await.

Async и Await

Эти ключевые слова были введены в ответ на эти вызовы с использованием Taskи Taskклассов. Они не представляют собой ещё один способ асинхронного программирования; они используют Taskи Taskклассы под капотом, что упрощает применение ТАП при сохранении силовых Task классов предоставляют программист при необходимости.

Давайте посмотрим на каждого.

Async

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

Тип возврата async метода всегда Taskили Task. Это проверено компилятором, так что здесь мало места для ошибок.

Await

await Используется ключевое слово асинхронно ждать для Taskили Taskдля завершения. Он приостанавливает выполнение текущего метода до асинхронной задачи, которая будучи AWAIT завершается по ред. Отличие от вызова.Resultor.Wait()состоит в том, что ключевое слово await отправляет текущий поток обратно в пул потоков, а не сохраняет его в заблокированном состоянии.

Под капотом это:

  • Создаёт новый объект Taskили Taskдля оставшейся части асинхронного метода;
  • назначает эту новую задачу как продолжение ожидаемой задачи;
  • назначает требование контекста для задачи продолжения.

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

Читайте также:  Как включить журналы отладки в Nginx?

На что это похоже

Рассмотрим следующий фрагмент из приложения ASP.NET:

public async Task<ReturnType> DoSomethingAndReturnSomeValAsync()
{
DoSomething();
SomeType someObj = await DoSomethingElseAsync();
return new ReturnType(someObj);
}

private async Task<SomeType> DoSomethingElseAsync(){

}

Пройдёмся по коду:

  • Строка 3: поток выполнения входит в DoSomethingAndReturnSomeValAsync()метод и вызывает DoSomething()метод синхронно.
  • Строка 4: поток выполнения входит в DoSomethingElseAsync()метод по- прежнему синхронно, пока не Taskбудет возвращено значение (не показано).
  • При возврате к строке 4 он встречает awaitключевое слово, поэтому он приостанавливает выполнение и возвращается в пул потоков.
  • Теперь остальная часть DoSomethingElseAsync()выполняется в ожидаемом редакторе Task.
  • По-прежнему строка 4: как только задача await ed завершена, из пула потоков назначается поток, который берёт на себя выполнение остальной части DoSomethingAndReturnSomeValAsync()метода, начиная с awaitключевого слова. Эта нить присваивает результат Taskв someObjпеременной.
  • Строка 5: новый поток создаёт ReturnTypeобъект и возвращает его из метода.

Когда поток выполнения встречает awaitключевое слово в строке 4, он делает следующее:

  • Он создаёт объект Task, содержащий оставшуюся часть DoSomethingAndReturnSomeValAsync()метода.
  • Он устанавливает эту новую задачу как продолжение (как в Task.ContinueWith ()) того, Taskчто было возвращено DoSomethingElseAsync(), вместе с требуемым контекстом.
  • Затем он завершает выполнение и возвращается в пул потоков.

Таким образом, ReturnTypeобъект фактически возвращается из этого Task, созданного awaitключевым словом. Следовательно, тип возвращаемого значения Taskв сигнатуре нашего asyncметода.

Дополнительные сведения см. В разделах Задача и Асинхронный шаблон на основе задач в документации MS.

Как обновить существующий код

В приведённом ниже примере показаны различные способы вызова асинхронных методов из синхронного метода:

public ResultType DoWork()
{
ResultType retVal;
try {
var apiResult = CallAnAPIAsync().Result;
var fileName = CreateFileName();
WriteToAFileAsync(fileName, apiResult).Wait();
retVal = StartAsyncOperation(fileName).GetAwaiter().GetResult();
} catch (AggregateException aex) {
HandleError(aex);
retVal = null
}
return retVal;
}

private async Task<APIResult> CallAnAPIAsync() {…}
private async Task WriteToAFileAsync() {…}
private Task<ResultType> StartAsyncOperation(string fileName) {…}

Поскольку DoWork()метод является синхронным, поток выполнения блокируется трижды:

  • в.Resultсобственности одна линия 5.
  • в.Wait()методе в строке 7.
  • в.GetResult()методе ожидания в строке 8.

Пройдёмся по коду

Строка 5

Основной поток выполнения входит в CallAnAPIAsync()метод синхронно, пока не Taskбудет возвращено значение a (не показано).

После возврата к строке 5 из CallAnAPIAsync()метода он обращается к.Resultсвойству возвращённого Taskобъекта, которое блокирует поток выполнения до тех пор, пока не Taskбудет завершён.

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

Итак, в настоящий момент наше программное обеспечение занимает два потока одновременно: первый — это основной поток выполнения, который заблокирован и просто ожидает, а второй — это поток, который выполняет Task.

После Taskзавершения и возврата ApiResultобъекта основной поток выполнения разблокируется. Он присваивает ApiResultобъект apiResultпеременной и переходит к строке 6.

Строка 6

Эта строка выполняется синхронно, поэтому поток выполнения завершает CreateFileName()метод и присваивает возвращаемое значение fileNameобъекту, а затем переходит к строке 7.

Строка 7

Это почти то же самое, что и в строке 5, за исключением отсутствия возвращаемого значения. Вызов.Wait()метода блокирует поток выполнения до завершения метода Taskfrom WriteToAFileAsync().

Строка 8

Это в точности то же, что и в строке 5: ResultTypeобъект, полученный из GetResult()метода блокировки, возвращается из DoWork()метода.

Теперь давайте перепишем DoWork()метод асинхронно :

public async Task<ResultType> DoWorkAsync()
{
ResultType retVal;
try {
Task<APIResult> apiResultTask = CallAnAPIAsync();
var fileName = CreateFileName();
var apiResult = await apiResultTask;
await WriteToAFileAsync(fileName, apiResult);
retVal = await StartAsyncOperation(fileName);
} catch (RealException rex) {
HandleError(rex);
retVal = null
}
return retVal;
}

Вот что мы сделали

Обновление подписи метода

Строка 1

  • Добавлено asyncключевое слово, включающее awaitключевое слово для метода
  • Тип возврата изменён на Task\. ( asyncМетод всегда должен возвращать либо a, Taskлибо a Task.)
  • Добавив имя метода с «Асинхронный» в конвенции ( за исключением методов, которые явно не называют нашим кодом, такие как обработчики событий и методов веб — контроллера).

Замена блокирующих ожиданий

Строки 5 и 7

Заменена блокировка доступа к.Resultсобственности на await.

Строка 6

Возможность одновременно заниматься независимой деятельностью становится предметом нашего внимания, когда мы занимаемся асинхронным программированием.

Я рекомендую в качестве упражнения самостоятельно разобраться в потоке выполнения между строкой 5 и строкой 7.

Выполнено? Вот как это происходит:

  • Вместо того, чтобы Await ТРАЕКТОРИЙ Taskсразу на линии 5, выполнение резьбы Назначает задачи к apiResultTaskпеременной. Затем он продолжает выполнять строку 6 одновременно, в то время как второй поток занят выполнением apiResultTaskв это же время.
  • В строке 7 поток выполнения встречает awaitключевое слово для apiResultTask, поэтому он приостанавливает выполнение и возвращается в пул потоков. Как только второй поток завершит выполнение apiResultTaskи вернёт ApiResultобъект, выполнение DoWorkAsync()продолжается со строки 7 потоком из пула потоков. Этот поток назначит ApiResultобъект apiResultпеременной и перейдёт к строке 8.

Строка 8

Заменил блокирующий.Wait()звонок на await.

Строка 9

Заменён блокирующий.GetAwaiter().GetResult()звонок на await.

Обратите внимание, что StartAsyncOperation()метод не обязательно должен быть asyncсамим собой; он возвращает Task, что является ожидаемым.

Обработка исключений

Строка 10

Заменено AggregateExceptionна RealException. Если возникает ошибка, все ожидания блокировки генерируют объект AggregateException, который обёртывает все исключения, которые были выброшены из Task. awaitКлючевое слово, с другой стороны, бросает фактическое исключение.

А также если a Taskсостоит из нескольких Tasks, то становится возможным объединение нескольких исключений в main Task.

Читайте также:  С++ функция std_max

Если вы awaitвведете такое Task, то awaitключевое слово вызовет только первое исключение ! После перехвата первого исключения вы можете использовать Task.Exceptionсвойство main Taskдля доступа к AggregateException.

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

Замечания

Эти примечания относятся только к приложениям.NET Framework и ASP.NET.Они не имеют того, SynchronizationContext что вызывает проблемы, описанные ниже. Подробности смотрите здесь.

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

Как избежать тупиков

Синхронное выполнение асинхронных операций путём блокировки потока выполнения создаёт риск возникновения тупиковой ситуации.

Можете ли вы найти тупик ниже?

открытый класс MyApiController: ApiController

public class MyApiController : ApiController
{
// Top-level method
public ActionResult HandleRESTApiCall(){
SomeType someObj = DoSomethingAsync().Result;
return OkResult(someObj);
}

private async Task<SomeType> DoSomethingAsync(){
var someData = await GetDataAsync();
return new SomeType(someData);
}
}

В строке 5, выполнение потока блокируется, ожидая.Resultиз Taskиз DoSomethingAsync()метода.

В строке 10 DoSomethingAsync()метод получает Task<>от GetDataAsync()метода и получает awaitего.

awaitКлючевое слово:

  • создаёт Task, содержащий остальную часть этого метода;
  • назначает это Taskкак продолжение долгожданногоTask<>;
  • устанавливает это Taskдля запуска в контексте потока выполнения;
  • возвращает этот Taskобъект в строку 5.

Таким образом, Task<>from GetDataAsync()завершается нормально, но доступ к.Resultсвойству Taskfrom DoSomethingAsync()в строке 5 по-прежнему блокирует поток выполнения до тех пор, пока не Taskбудет завершён. Между тем, Taskожидает доступности потока выполнения, потому что ему нужен контекст потока выполнения. В итоге мы зашли в тупик.

Рабочий процесс с тупиковой ситуацией показан на диаграмме последовательности ниже.

Есть два решения

Есть два решения:

1. Не блокируйте асинхронный код. Обновите весь стек вызовов, чтобы он был асинхронным.

Мы можем добиться этого, создав HandleRESTApiCall()метод asyncи заменив.Resultдоступ в строке 5 на await.

Удаление блокирующего.Resultвызова решает тупик.

public class MyApiController : ApiController
{
// Top-level method
public async ActionResult HandleRESTApiCall(){
SomeType someObj = await DoSomethingAsync();
return OkResult(someObj);
}

}

2. Использовать.ConfigureAwait(false)в строке 10

Подробности этого решения обсуждаются в ConfigureAwait()теме ниже.

var someData = await GetDataAsync().ConfigureAwait(false);

Лучше всего использовать оба решения, так как они обеспечат наилучшую производительность.

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

Собственно, есть и третий способ. В строке 5 мы можем заключить DoSomethingAsync()вызов в другой Task:

SomeType someObj = Task.Run(() => DoSomethingAsync()).Result;

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

ConfigureAwait (bool continueOnCapturedContext)

Каждый поток выполнения имеет контекст. В приложениях с графическим интерфейсом пользователя контекст содержит элементы пользовательского интерфейса, такие как TextBoxes или Buttons. В приложениях ASP.NET контекст содержит HttpContext.Currentи позволяет создавать ответ ASP.NET, включая операторы возврата в действиях контроллера.

Как только выполнение asyncметода приостанавливается по awaitключевому слову, продолжение по умолчанию запускается в контексте вызывающего потока. Это может вызвать взаимоблокировки (см. Раздел «Как избежать взаимоблокировок» выше), редко требуется и оказывает небольшое негативное влияние на производительность.

Вместо этого мы можем настроить продолжение для работы без контекста. Для этого мы вызываем.ConfigureAwait(false)метод. См. Пример ниже:

public class MyApiController : ApiController
{
// Top-level method
public async Task<ActionResult> HandleRESTApiCall(){
SomeType someObj = await DoSomethingAsync(); // no .ConfigureAwait(false) because…
// …we need the context here in the continuation!
return OkResult(someObj);
}

private async Task<SomeType> DoSomethingAsync(){
var someData = await GetDataAsync().ConfigureAwait(false); // <== Here it is!
// We don’t need the context here
return new SomeType(someData);
}
}

Вы можете найти более подробную информацию в документации Microsoft.

Заключение

  • Асинхронное программирование можно определить, как не заставляющее поток выполнения ждать завершения задачи, связанной с вводом-выводом или с привязкой к ЦП.
  • Асинхронное программирование необходимо для гибкого графического интерфейса. Это увеличивает пропускную способность за счёт эффективного использования пула потоков и позволяет повысить производительность за счёт параллелизма.
  • В.NET существуют различные шаблоны для асинхронного программирования. Рекомендуемый шаблон — асинхронный шаблон на основе задач (TAP).
    asyncИ awaitключевые слова делают использование TAP проще и включить неблокируемому ожидания.
  • Объединение ожиданий блокировки, таких как.Wait()или.Resultс async/ awaitв.NET Framework, может привести к взаимоблокировкам. Это очень важно при рефакторинге устаревшего кода.
  • Такие взаимоблокировки могут быть решены путём использования.ConfigureAwait(false)или удаления блокирующих ожиданий и создания всего стека вызовов async. Рекомендуется применять эти решения вместе для достижения наилучшей производительности.
Оцените статью
bestprogrammer.ru
Добавить комментарий