Файл: Асинхронноепрограммирование.pdf

ВУЗ: Не указан

Категория: Не указан

Дисциплина: Не указана

Добавлен: 10.11.2023

Просмотров: 140

Скачиваний: 3

ВНИМАНИЕ! Если данный файл нарушает Ваши авторские права, то обязательно сообщите нам.

60
Глава 7. Вспомогательные средства для...
рее для удобства, чем по необходимости, потому что доступ к исходным объектам
Task сохраняется, и ничто не мешает опросить их свойство
Result
, так как точно известно, что все задачи уже завершены.
Обратимся снова к обозревателю значков сайтов. Напомним, что у нас уже есть версия, которая вызывает метод типа async void
, начи- нающий скачивание одного значка. По завершении скачивания зна- чок добавляется в окно. Это решение очень эффективно, потому что все операции скачивания производятся параллельно. Однако есть две проблемы:
• значки появляются в порядке, определяемом временем скачи- вания;
• поскольку каждый значок скачивается в отдельном экземп- ляре метода типа async void
, неперехваченные исключения повторно возбуждаются в потоке пользовательского интер- фейса, и корректно обработать их нелегко.
Поэтому переработаем программу так, чтобы метод, в котором пе- ребираются значки, сам был помечен ключевым словом async. Тогда мы сможем управлять всеми асинхронными операциями, как одной группой. Вот как выглядит текущий код:
private async void GetButton_OnClick(object sender, RoutedEventArgs e)
{
foreach (string domain in s_Domains)
{
Image image = await GetFavicon(domain);
AddAFavicon(image);
}
}
Изменим его так, чтобы все операции скачивания по-прежнему выполнялись параллельно, но значки отображались в нужном нам порядке. Сначала запустим все операции, вызвав метод
GetFavicon и сохранив объекты
Task в списке
List
List> tasks = new List>();
foreach (string domain in s_Domains)
{
tasks.Add(GetFavicon(domain));
}
Или с помощью LINQ, что даже лучше:
IEnumerable> tasks = s_Domains.Select(GetFavicon);
// Вычисление IEnumerable, являющегося результатом Select, отложенное.

61
Ожидание завершения любой задачи из нескольких
// Инициируем его, чтобы запустить задачи.
tasks = tasks.ToList();
Имея группу задач, передадим ее методу
Task.WhenAll
, который вернет объект
Task
; этот объект станет завершенным, когда закон- чатся все операции скачивания, и в этот момент будет содержать все результаты.
Task allTask = Task.WhenAll(tasks);
Осталось только дождаться allTask и воспользоваться результа- тами:
Image[] images = await allTask;
foreach (Image eachImage in images)
{
AddAFavicon(eachImage);
}
Итак, нам потребовалось всего несколько строчек, чтобы записать довольно сложный параллельный алгоритм. Эта версия программа находится в ветви whenAll
Ожидание завершения любой
задачи из нескольких
Часто возникает также ситуация, когда требуется дождаться заверше- ния первой задачи из нескольких запущенных. Например, так бывает, когда вы запрашиваете один и тот же ресурс из нескольких источни- ков и готовы удовлетвориться первым полученным ответом.
Для этой цели предназначен метод
Task.WhenAny
. Ниже приведен универсальный вариант, он наиболее интересен, хотя есть еще ряд перегруженных.
Task> WhenAny(IEnumerable> tasks)
Сигнатура метода
WhenAny несколько сложнее, чем метода
WhenAll
, но тому есть причины. В ситуации, когда возможны исклю- чения, пользоваться методом
WhenAny следует с осторожностью. Если вы хотите знать обо всех исключениях, произошедших в программе, то необходимо ждать каждую задачу, иначе некоторые исключения могут быть потеряны. Воспользоваться методом
WhenAny и просто за- быть об остальных задачах – всё равно, что перехватить все исключе- ния и игнорировать их. Это достойное порицания решение, которое


62
Глава 7. Вспомогательные средства для...
может впоследствии привести к тонким ошибкам и недопустимым состояниям программы.
Метод
WhenAny возвращает значение типа
Task>
. Это оз- начает, что по завершении задачи вы получаете объект типа
Task
Он представляет первую из завершившихся задач и поэтому гаран- тированно находится в состоянии «завершен». Но почему нам воз- вращают объект
Task
, а не просто значение типа
T
? Чтобы мы знали, какая задача завершилась первой, и могли отменить все остальные и дождаться их завершения.
Task> anyTask = Task.WhenAny(tasks);
Task winner = await anyTask;
Image image = await winner; // Этот оператор всегда завершается синхронно
AddAFavicon(image);
foreach (Task eachTask in tasks)
{
if (eachTask != winner)
{
await eachTask;
}
}
Нет ничего предосудительного в том, чтобы обновить пользова- тельский интерфейс, как только будет получен результат первой за- вершившейся задачи (winner), но после этого необходимо дождаться остальных задач, как сделано в коде выше. При удачном стечении об- стоятельств все они завершатся успешно, и на выполнении программы это никак не отразится. Если же какая-то задача завершится с ошиб- кой, то вы сможете узнать причину и исправить ошибку.
Создание собственных
комбинаторов
Методы
WhenAll и
WhenAny называются асинхронными комбина- торами. Они возвращают объект
Task
, но сами по себе не являются асинхронными методами, а лишь комбинируют другие объекты
Task тем или иным полезным способом. При необходимости вы можете и сами написать комбинаторы и тем самым запастись набором повтор- но используемых поведений.
Покажем на примере, как пишется комбинатор. Допустим, нам нужно добавить к произвольной задаче таймаут. Было бы несложно

63
Создание собственных комбинаторов написать такой код с нуля, но мы продемонстрируем, как удачно здесь можно применить методы
Delay и
WhenAny
. Вообще говоря, комбина- торы проще всего реализовывать с помощью механизма async, как в данном случае, но так бывает не всегда.
private static async Task WithTimeout(Task task, int time)
{
Task delayTask = Task.Delay(time);
Task fi rstToFinish = await Task.WhenAny(task, delayTask);
if (fi rstToFinish == delayTask)
{
// Первой закончилась задача задержки – разобраться с исключениями task.ContinueWith(HandleException);
throw new TimeoutException();
}
// Если мы дошли до этого места, исходная задача уже завершилась return await task;
}
Мой подход состоит в том, чтобы создать методом
Delay задачу
Task
, которая завершится по истечении таймаута. Затем с помощью метода
WhenAny я жду эту и исходную задачу, так что исполнение во- зобновляется в любом из двух случаев: когда завершилась исходная задача или истек таймаут. Далее я смотрю, что именно произошло и либо возбуждаю исключение
TimeoutException
, либо возвращаю результат.
Обратите внимание, что я аккуратно обрабатываю исключения в случае таймаута. С помощью метода
ContinueWith я присоединил к исходной задаче продолжение, в котором будет обработано исключе- ние, если таковое имело место. Я точно знаю, что задача задержки не может возбудить исключение, поэтому и пытаться перехватить его не нужно. Метод
HandleException можно было бы реализовать следу- ющим образом:
private static void HandleException(Task task)
{
if (task.Exception != null)
{
logging.LogException(task.Exception);
}
}
Что именно в нем делается, конечно, зависит от вашей страте- гии обработки исключений. Присоединив этот метод с помощью


64
Глава 7. Вспомогательные средства для...
ContinueWith
, я гарантирую, что в момент завершения исходной задачи, когда бы это ни случилось, будет выполнен код проверки исключения. Важно, что это никак не тормозит исполнение основной программы, которая уже сделала всё необходимое в момент, когда истек таймаут.
Отмена асинхронных операций
Согласно TAP, отмена связывается не с типом
Task
, а с типом
CancellationToken
. По соглашению, всякий TAP-метод, поддержи- вающий отмену, должен иметь перегруженный вариант, в котором за обычными параметрами следует параметр типа
CancellationToken
В самом каркасе примером может служить тип
DbCommand и его асинхронные методы для опроса базы данных. Простейший пере- груженный вариант метода
ExecuteNonQueryAsync вообще не име- ет параметров, и ему соответствует такой вариант с параметром
CancellationToken
:
Task ExecuteNonQueryAsync(CancellationToken cancellationToken)
Посмотрим, как отменить вызванный асинхронный метод. Для этого нам понадобится класс
CancellationTokenSource
, который умеет создавать объекты
CancellationToken и управлять ими – при- мерно так же, как
TaskCompletionSource создает и управляет объек- тами
Task
. Приведенный ниже код неполон, но дает представление о применяемой технике:
CancellationTokenSource cts = new CancellationTokenSource();
cancelButton.Click += delegate { cts.Cancel(); };
int result = await dbCommand.ExecuteNonQueryAsync(cts.Token);
При вызове метода
Cancel объекта
CancellationTokenSource тот переходит в состояние «отменен». Мы можем зарегистрировать делегат, который будет вызван в этот момент, но на практике эффек- тивнее гораздо более простой подход, заключающийся в проверке того, что вызывающая программа хочет отменить начатую операцию.
Если в асинхронном методе есть цикл и объект
CancellationToken доступен, то достаточно просто вызывать на каждой итерации метод
ThrowIfCancellationRequested foreach (var x in thingsToProcess)
{
cancellationToken.ThrowIfCancellationRequested();

65
// Обработать x ...
}
При вызове метода
ThrowIfCancellationRequested отмененного объекта
CancellationToken возбуждается исключение типа
Opera- tionCanceledException
. Библиотека Task Parallel Library знает, что такое исключение представляет отмену, а не ошибку, и обрабатывает его соответственно. Например, в классе
Task имеется свойство
Is-
Canceled
, которое автоматически принимает значение true
, если при выполнении async-метода произошло исключение
Operation-
CanceledException
Удобной особенностью подхода к отмене, основанного на маркерах
CancellationToken
, является тот факт, что один и тот же маркер можно распространить на столько частей асинхронной операции, сколько необходимо, – достаточно просто передать его всем частям.
Неважно, работают они параллельно или последовательно, идет ли речь о медленном вычислении или удаленной операции, – один мар- кер отменяет всё.
Информирование о ходе
выполнения асинхронной
операции
Помимо сохранения отзывчивости интерфейса и предоставления пользователю возможности отменить операцию, часто бывает полез- но сообщать, сколько времени осталось до конца медленной опера- ции. Для этого в каркас введено еще два типа, рекомендуемых в TAP.
Мы должны передать асинхронному методу объект, реализующий интерфейс
IProgress
, который метод будет вызывать для инфор- мирования о ходе работы.
По соглашению, параметр типа
IProgress
помещается в конец списка параметров, после
CancellationToken
. Вот как можно было бы добавить средства информирования о ходе выполнения в метод
DownloadDataTaskAsync
Task DownloadDataTaskAsync(Uri address,
CancellationToken cancellationToken,
IProgress progress)
Чтобы воспользоваться таким методом, мы должны создать класс, реализующий интерфейс
IProgress
. По счастью, каркас уже пре-
Информирование о ходе выполнения...


66
Глава 7. Вспомогательные средства для...
доставляет такой класс
Progress
, который в большинстве слу- чаев делает именно то, что нужно. Вы должны либо создать объект, передав его конструктору лямбда-выражение, либо подписаться на событие для получения информации о ходе выполнения, которую можно будет отобразить в пользовательском интерфейсе.
new Progress(percentage => progressBar.Value = percentage);
Интересно, что при конструировании объекта
Progress
запо- минается контекст
SynchronizationContext
, который затем исполь- зуется для вызова кода, обновляющего информацию о ходе выполне- ния, в нужном потоке. Это по существу то же самое поведение, кото- рое демонстрирует сам объект
Task в момент возобновления после await
, так что нам не нужно думать о том, что
IProgress
может вызываться из любого потока.
Если вы хотите сообщать о ходе выполнения из самого TAP- метода, то должны всего лишь вызвать метод
Report интерфейса
IProgress
:
progress.Report(percent);
Трудная часть заключается в выборе параметра-типа
T
. Это тип объекта, который передается методу
Report
, то есть того самого объ- екта, который передается в лямбда-выражение из вызывающей про- граммы. Если требуется всего лишь показать процент, то можно взять тип int
(как в примере выше), но иногда требуется более детальная информация. Однако будьте осторожны, потому что этот объект обычно используется не в том потоке, в котором создан. Во избежа- ние проблем пользуйтесь неизменяемыми типами.

ГЛАВА 8.
В каком потоке
исполняется мой код?
Я уже говорил, что асинхронное программирование неразрывно свя- зано с потоками. В C# это означает, что необходимо понимать, в ка- ком потоке .NET исполняется наш код и что происходит с потоками во время выполнения длительной операции.
До первого await
В любом async-методе какой-то код предшествует первому вхож- дению ключевого слова await
. И, разумеется, само ожидаемое вы- ражение тоже содержит некий код. Весь этот код выполняется в вызывающем потоке. До первого await не происходит ничего ин- тересного.
Эту часть механизма async чаще всего понимают неправильно.
Async не планирует выполнение метода в фоновом потоке.
Единственный способ сделать это – воспользоваться методом
Task.Run, который специально предназначен для этой цели, или чем-то подобным.
В приложении с пользовательским интерфейсом это означает, что код до первого await работает в потоке пользовательского интерфей- са. А в веб-приложении на базе ASP.NET – в рабочем потоке ASP.
NET.
Часто бывает, что выражение в строке, содержащей первый await
, содержит еще один async-метод. Поскольку это выражение пред- шествует первому await
, оно также выполняется в вызывающем потоке. Таким образом, вызывающий поток продолжает «углублять- ся» в код приложения, пока не встретит метод, действительно воз- вращающий объект
Task
. Это может быть метод, являющийся час-


68
Глава 8. В каком потоке исполняется мой код?
тью каркаса, или метод, создающий задачу-марионетку с помощью
TaskCompletionSource
. Именно этот метод и является источником асинхронности – все прочие async-методы просто распространяют асинхронность вверх по стеку вызовов.
Путь до первой реальной точки асинхронности может оказаться довольно длинным, и весь лежащий на этом пути код исполняется в потоке пользовательского интерфейса, а, стало быть, интерфейс не реагирует на действия пользователя. К счастью, в большинстве слу- чаев он выполняется недолго, но важно помнить, что одно лишь нали- чие ключевого слова async не гарантирует отзывчивости интерфейса.
Если программа отзывается медленно, подключите профилировщик и разберитесь, на что уходит время.
Во время асинхронной
операции
Какой поток в действительности исполняет асинхронную опера- цию?
Вопрос хитрый. Это асинхронный код. Для таких типичных опера- ций, как доступ к сети, вообще не существует потоков, заблокирован- ных в ожидании завершения операции.
Разумеется, если механизм async используется для ожидания завершения вычисления, запущенного, к примеру, с помощью метода
Task.Run, то занимается поток из пула.
Существует поток, ожидающий завершения сетевых запросов, но он один для всех запросов. В Windows он называется портом завер-
шения ввода-вывода. Когда сетевой запрос завершается, обработчик прерывания в операционной системе добавляет задачу в очередь порта завершения. Если запущено 1000 сетевых запросов, то все по- лученные ответы по очереди обрабатываются единственным портом завершения ввода-вывода.
На самом деле обычно существует несколько потоков, связан- ных с портом завершения ввода-вывода, – столько, сколько имеется процессорных ядер. Но их число одинаково вне зависи- мости от того, исполняется в данный момент 10 или 1000 сете- вых запросов.

69
await и SynchronizationContext
Подробнее о классе
SynchronizationContext
Класс
SynchronizationContext предназначен для исполнения кода в потоке конкретного вида. В .NET имеются разные контексты синх- ронизации, но наиболее важны контексты потока пользовательского интерфейса, используемые в WinForms и в WPF.
Сам по себе класс
SynchronizationContext не делает ничего полезного, интерес представляют лишь его подклассы. В классе есть статические члены, позволяющие получить текущий
Synchro- nizationContext и управлять им. Текущий контекст
Synchroni- zationContext
– это свойство текущего потока. Идея в том, что всякий раз, как код исполняется в каком-то специальном потоке, мы можем получить текущий контекст синхронизации и сохранить его.
Впоследствии этот контекст можно использовать для того, чтобы продолжить исполнение кода в том потоке, в котором оно было начато. Поэтому нам не нужно точно знать, в каком потоке началось исполнение, достаточно иметь соответствующий объект
Synchroni- zationContext
В классе
SynchronizationContext есть важный метод
Post
, который гарантирует, что переданный делегат будет исполняться в правильном контексте.
Некоторые подклассы
SynchronizationContext инкапсулируют единственный поток, например поток пользовательского интерфейса.
Другие инкапсулируют потоки определенного вида, например взятые из пула потоков, но могут выбирать любой из них. Есть и такие, кото- рые вообще не изменяют поток, в котором выполняется код, а исполь- зуются только для мониторинга, например контекст синхронизации в ASP.NET.
await и SynchronizationContext
Мы знаем, что код, предшествующий первому await
, исполняется в вызывающем потоке, но что происходит, когда исполнение вашего метода возобновляется после await
?
На самом деле, в большинстве случаев он также исполняется в вы- зывающем потоке, несмотря на то, что в промежутке вызывающий поток мог делать что-то еще. Это существенно упрощает написание кода.