ВУЗ: Не указан
Категория: Не указан
Дисциплина: Не указана
Добавлен: 10.11.2023
Просмотров: 139
Скачиваний: 3
ВНИМАНИЕ! Если данный файл нарушает Ваши авторские права, то обязательно сообщите нам.
70
Глава 8. В каком потоке исполняется мой код?
Для достижения такого эффекта используется класс
Synchro- nizationContext
. Выше в разделе «Контекст» мы видели, что в момент приостановки метода при встрече оператора await текущий контекст
SynchronizationContext сохраняется. Далее, когда метод возобновляется, компилятор вставляет вызов
Post
, чтобы исполнение возобновилось в запомненном контексте.
А теперь о подводных камнях. Метод может возобновиться в по- токе, отличном от того, где был начат, при выполнении следующих условий:
• если запомненный контекст
SynchronizationContext инкап- сулирует несколько потоков, например пул потоков;
• если контекст не подразумевает переключения потоков;
• если в точке, где встретился оператор await
, вообще не было текущего контекста синхронизации, как, например, в консоль- ном приложении;
• если объект
Task сконфигурирован так, что при возобновле- нии
SynchronizationContext не используется.
По счастью, к приложениям с графическим интерфейсом, где во- зобновление в том же потоке наиболее существенно, ни одно из этих условий не применимо, поэтому после await можно без опаски мани- пулировать пользовательским интерфейсом.
Жизненный цикл асинхронной
операции
Попробуем на примере обозревателя значков сайтов понять, в каком потоке какой код исполняется. Я написал два async-метода:
async void GetButton_OnClick(...)
async Task
Обработчик события
GetButton_OnClick вызывает метод
Get-
FaviconAsync
, который в свою очередь вызывает метод
WebClient.
DownloadDataTaskAsync
. На рис. 8.1 приведена диаграмма последо- вательности событий, возникающих при исполнении этих методов.
1. Пользователь нажимает кнопку, обработчик
GetButton_On-
Click помещается в очередь.
2. Поток пользовательского интерфейса исполняет первую по- ловину метода
GetButton_OnClick
, включая вызов
GetFavi- conAsync
71
Жизненный цикл асинхронной операции
3. Поток пользовательского интерфейса входит в метод
GetFa- viconAsync и исполняет его первую половину, включая вызов
DownloadDataTaskAsync
4. Поток пользовательского интерфейса входит в метод
Down- loadDataTaskAsync
, который начинает скачивание и возвра- щает объект
Task
5. Поток пользовательского интерфейса покидает метод
Down- loadDataTaskAsync и доходит до оператора await в методе
GetFaviconAsyncAsync
6. Запоминается текущий контекст
SynchronizationContext
– поток пользовательского интерфейса.
7. Метод
GetFaviconAsync приостанавливается оператором await
, и задача
Task из
DownloadDataTaskAsync извещается о том, что она должна возобновиться по завершении скачивания
(в запомненном контексте
SynchronizationContext
).
8. Поток пользовательского интерфейса покидает метод
Get-
FaviconAsync
, который вернул объект
Task
, и доходит до оператора await в методе
GetButton_OnClick
9. Как и в предыдущем случае, оператор await приостанавливает метод
GetButton_OnClick
Рис. 8.1. Жизненный цикл асинхронной операции
Поток пользовательского интерфейса
Поток порта завершения ввода-вывода
Запрос завершен
Нажать кнопку
72
Глава 8. В каком потоке исполняется мой код?
10. Поток пользовательского интерфейса покидает метод
GetBut- ton_OnClick и освобождается для обработки других действий пользователя.
В этот момент мы ждем, пока скачается значок. На это может уйти несколько секунд. Отметим, что поток пользовательского интерфейса свободен и может обрабатывать другие действия пользователя, а порт завершения ввода-вывода пока не задейс- твован. Во время исполнения операции ни один поток не забло- кирован.
11. Скачивание завершается, и порт завершения ввода-вывода ставит в очередь метод
DownloadDataTaskAsync для обработ- ки полученного результата.
12. Поток порта завершения ввода-вывода помечает, что задача
Task
, возвращенная методом
DownloadDataTaskAsync
, завер- шена.
13. Поток порта завершения ввода-вывода исполняет код обра- ботки завершения внутри
Task
; этот код вызывает метод
Post запомненного контекста
SynchronizationContext
(поток пользовательского интерфейса) для продолжения.
14. Поток порта завершения ввода-вывода освобождается для об- работки других событий ввода-вывода.
15. Поток пользовательского интерфейса находит команду, от- правленную методом
Post
, и возобновляет исполнение второй половины метода
GetFaviconAsync
– до конца.
16. Покидая метод
GetFaviconAsync
, поток пользовательского интерфейса помечает, что задача
Task
, возвращенная методом
GetFaviconAsync
, завершена.
17. Поскольку на этот раз текущий контекст синхронизации совпадает с запомненным, вызывать метод
Post не нужно, и поток пользовательского интерфейс продолжает работать синхронно.
В WPF эта логика ненадежна, потому что WPF часто создает новые объекты
SynchronizationContext. И хотя все они эквивалентны, Task Parallel Library считает, что должна вызвать метод Post.
18. Поток пользовательского интерфейса возобновляет испол- нение второй половины метода
GetButton_OnClick
– до конца.
73
Всё это выглядит довольно сложно, но я думаю, что детально выписать все шаги полезно. Обратите внимание, что абсолютно все строки моей программы исполнялись в потоке пользовательского интерфейса. Поток порта завершения ввода-вывода только вызывал метод
Post
, чтобы отправить команду потоку пользовательского ин- терфейса, который затем исполнял вторые половины моих методов.
Когда не следует использовать
SynchronizationContext
В каждом подклассе
SynchronizationContext метод
Post реализо- ван по-своему. Как правило, вызов этого метода обходится сравни- тельно дорого. Чтобы избежать накладных расходов, .NET не вызы- вает
Post
, если запомненный контекст синхронизации совпадает с те- кущим на момент завершения задачи. Если в этот момент посмотреть на стек вызовов в отладчике, то он окажется «перевернутым» (если не обращать внимания на вызовы самого каркаса). Тот метод, кото- рый с точки зрения программиста является самым глубоко вложен- ным, то есть вызывается другими методами, на самом деле вызывает остальные методы по своем завершении.
Однако если контексты синхронизации различаются, то необхо- дим дорогостоящий вызов
Post
. Если производительность стоит на первом месте или речь идет о библиотечном коде, которому безраз- лично, в каком потоке выполняться, то, возможно, не имеет смысла нести такие расходы. В таком случае следует вызвать метод
Confi g- ureAwait объекта
Task
, перед тем как ждать его. Тогда при возобнов- лении исполнения не будет вызываться метод
Post запомненного контекста
SynchronizationContext byte[] bytes =
await client.DownloadDataTaskAsync(url).Confi gureAwait(false);
Однако метод
Confi gureAwait не всегда делает то, что вы ожи- даете. Он задуман как способ информирования .NET о том, что вам безразлично, в каком потоке будет возобновлено выполнение, а не как неукоснительный приказ. Что происходит в действительности, зависит от того, в каком потоке завершилось выполнение ожидаемой задачи. Если этот поток не очень важен, например взят из пула, то исполнение кода в нем и продолжится. Но если поток по какой-то причине важен, то .NET предпочтет освободить его для других дел,
Когда не следует использовать...
74
Глава 8. В каком потоке исполняется мой код?
а исполнение вашего метода продолжить в потоке, взятом из пула.
Решение о том, важен поток или нет, принимается на основе анализа текущего контекста синхронизации.
Взаимодействие с синхронным
кодом
Допустим, вы работаете с уже существующим приложением и, хотя написанный вами новый код является асинхронным и следует пат- терну TAP, остается необходимость взаимодействовать со старым синхронным кодом. Конечно, при этом обычно теряются преиму- щества асинхронности, но, возможно, в будущем вы планируете пе- реписать код в асинхронном стиле, а пока надо соорудить какую-то
«времянку».
Вызвать синхронный код из асинхронного просто. Если имеется блокирующий API, то достаточно выполнить его в потоке, взятом из пула, воспользовавшись методом
Task.Run
. Правда, в таком случае захватывается поток, но это неизбежно.
var result = await Task.Run(() => MyOldMethod());
Вызов асинхронного кода из синхронного или реализация синх- ронного API тоже выглядит просто, но тут есть скрытые проблемы. В классе
Task имеется свойство
Result
, обращение к которому блоки- рует вызывающий поток до завершения задачи. Его можно использо- вать в тех же местах, что await
, но при этом не требуется, чтобы метод был помечен ключевым словом async или возвращал объект
Task
. И в этом случае один поток занимается – на этот раз вызывающий (то есть тот, что блокируется).
var result = AlexsMethodAsync().Result;
Хочу предупредить: эта техника не будет работать, если исполь- зуется в контексте синхронизации с единственным потоком, напри- мер пользовательского интерфейса. Вдумайтесь – что мы хотим от потока пользователя интерфейса? Он блокируется в ожидании за- вершения задачи
Task
, возвращенной методом
AlexsMethodAsync
AlexsMethodAsync
, скорее всего, вызвал какой-то еще TAP-метод и ждет его завершения. Когда операция завершится, запомненный
SynchronizationContext
(поток пользовательского интерфейса) используется для отправки (методом
Post
) команды возобновления
AlexsMethodAsync
. Однако поток пользовательского интерфейса
75
Взаимодействие с синхронным кодом никогда не получит это сообщение, потому что он по-прежнему за- блокирован. Получилась взаимоблокировка. К счастью, эта ошибка приводит к стопроцентно воспроизводимой взаимоблокировке, так что отладить ее нетрудно.
При должной осмотрительности проблемы взаимоблокировки можно избежать, переключившись на поток из пула до запуска асин- хронного кода. Тогда будет запомнен контекст синхронизации, свя- занный с пулом потоков, а не с потоком пользовательского интерфей- са. Но это некрасивое решение, лучше потратить время на то, чтобы сделать вызывающий код асинхронным.
var result = Task.Run(() => AlexsMethodAsync()).Result;
1 2 3 4 5 6 7 8 9
ГЛАВА 9.
Исключения
в асинхронном коде
В синхронном коде исключение распространяется вверх по стеку вызовов, пока не достигнет блока try-catch
, способного его обрабо- тать, либо не выйдет за пределы вашего кода. В асинхронном коде, особенно после возобновления кода, погруженного в await
, текущий стек вызовов имеет мало общего с тем, что интересует программиста, и состоит по большей части из вызовов методов каркаса, обеспечи- вающих возобновление async-метода. В такой ситуации возникшее исключение было бы невозможно перехватить в вызывающем коде, а трассировка вызовов вообще не имела бы никакого смысла. Поэто- му компилятор C# изменяет поведение исключений, делая его более полезным.
Исходный стек вызовов тем не менее можно просмотреть в от- ладчике.
Исключения в async-методах,
возвращающих Task
Большинство написанных вами async-методов возвращают значение типа
Task или
Task
. Цепочка таких методов, в которой каждый ожидает завершения следующего, представляет собой асинхронный аналог стека вызовов в синхронном коде. Компилятор C# прилагает максимум усилий к тому, чтобы исключения, возбуждаемые в этих методах, вели себя так же, как в синхронном случае. В частности, блок try-catch
, окружающий ожидаемый async-метод, перехватывает исключения, возникшие внутри этого метода.
77
Исключения в async-методах, возвращающих Task async Task Catcher()
{
try
{
await Thrower();
}
catch (AlexsException)
{
// Исключение будет обработано здесь
}
}
async Task Thrower()
{
await Task.Delay(100);
throw new AlexsException();
}
До тех пор пока исполнение не дошло до первого await, синхронный стек вызовов и цепочка асинхронных методов ничем не отличаются. Поведение исключений в этой точке несколько изменено ради согласованности, но это изменение гораздо более скромное.
Для этого C# перехватывает все исключения, возникшие в вашем async-методе. Перехваченное исключение помещается в объект
Task
, который был возвращен вызывающей программе. Объект
Task переходит в состояние Faulted. Если задача завершилась с ошибкой, то ожидающий ее метод не возобновится как обычно, а получит исключение, возбужденное в коде внутри await
Исключение, повторно возбужденное оператором await
, – это тот же самый объект, который был порожден в предложении throw
. По мере того как он распространяется вверх по стеку вызовов, растет ас- социированная с ним трассировка стека. Это может показаться стран- ным тому, кто пробовал повторно возбуждать исключение вручную, например в написанном вручную асинхронном коде. Дело в том, что здесь используется новая возможность типа .NET
Exception
Ниже приведен пример трассировки стека для цепочки из двух async-методов. Мой код выделен полужирным шрифтом.
System.NullReferenceException: Object reference not set to an instance of an object.
at FaviconBrowser.MainWindow.
MainWindow.xaml.cs:line 74
--- End of stack trace from previous location where exception was thrown --- at System.Runtime.CompilerServices.TaskAwaiter.ThrowForNonSuccess(Task task)
at
78
Глава 9. Исключения в асинхронном коде
System.Runtime.CompilerServices.TaskAwaiter.HandleNonSuccessAndDebuggerNotifi cation(T
ask task)
at System.Runtime.CompilerServices.TaskAwaiter`1.GetResult()
at FaviconBrowser.MainWindow.
MainWindow.xaml.cs:line 41
--- End of stack trace from previous location where exception was thrown --- at System.Runtime.CompilerServices.AsyncMethodBuilderCore.
at ... Методы каркаса
Упоминание метода
MoveNext связано с преобразованием кода, осуществляемым компилятором (см. главу 14). Мои методы пере- межаются методами каркаса, но тем не менее можно получить пред- ставление о том, какая последовательность моих методов привела к исключению.
Незамеченные исключения
Одно из важных различий между синхронным и асинхронным ко- дом – место, где возбуждается исключение, возникшее в вызванном методе. В async-методе оно возбуждается там, где находится оператор await
, а не в точке фактического вызова метода. Это становится оче- видным, если разделить вызов и await
// Здесь AlexsException никогда не возбуждается
Task task = Thrower();
try
{
await task;
}
catch (AlexsException)
{
// Исключение обрабатывается здесь
}
Очень легко забыть о необходимости ожидать завершения async- метода, особенно если тот возвращает объект неуниверсального класса
Task
, потому что программе не требуется значение результа- та. Но поступить таким образом – все равно, что включить пустой блок catch
, который перехватывает и игнорирует все исключения.
Это плохое решение, потому что может привести к некорректному состоянию программы и к тонким ошибкам, проявляющимся далеко от места возникновения. Возьмите за правило всегда ожидать завер- шения любого async-метода, если не хотите потом провести долгие часы в обществе отладчика.
79
Выстрелил и забыл
Такое игнорирование исключений – новшество, появившееся в
.NET после включения механизма async. Если вы ожидаете, что исключение, возникшее в библиотечном коде Task Parallel Library, будет повторно возбуждено в потоке финализатора, то имейте в виду – в .NET 4.5 этого уже не происходит.
Исключения в методах типа
async void
Ждать с помощью await завершения async-метода, возвращающего void
, невозможно, поэтому его поведение в части исключений долж- но быть другим. Заведомо не хотелось бы, чтобы исключения, воз- буждаемые такими методами, оставались незамеченными. А хотим мы, чтобы исключение, покинувшее метод типа async void
, было повторно возбуждено в вызывающем потоке:
• если в месте, где был вызван async-метод, существовал кон- текст синхронизации
SynchronizationContext
, то исключе- ние посылается ему методом
Post
;
• в противном случае оно возбуждается в потоке, взятом из пула.
В большинстве случаев обе ситуации приводят к завершению про- цесса, если только к соответствующему событию не присоединен об- работчик необработанных исключений. Маловероятно, что вас это устроит, и именно по этой причине методы типа async void следует писать, только если они будут вызываться из внешнего кода или если можно гарантировать отсутствие исключений.
Выстрелил и забыл
Редко, но бывают ситуации, когда неважно, как завершился метод, а ждать его завершения слишком сложно. В таком случае я рекомен- дую все же возвращать объект
Task
, но передавать его методу, спо- собному обработать исключения. Лично мне нравится такой метод расширения:
public static void ForgetSafely(this Task task)
{
task.ContinueWith(HandleException);
}
80
Глава 9. Исключения в асинхронном коде
Здесь метод
HandleException передает любое исключение систе- ме протоколирования, как, например, в разделе «Создание собствен- ных комбинаторов» выше.
AggregateException и WhenAll
В асинхронных программах приходится иметь дело с ситуацией, ко- торая в синхронном коде вообще невозможна. Метод может возбуж- дать сразу несколько исключений. Так бывает, например, когда мы используем метод
Task.WhenAll для ожидания завершения группы асинхронных операций. Ошибкой могут завершиться несколько опе- раций, причем ни одну ошибку нельзя считать первой или самой важ- ной.
Метод
WhenAll
– всего лишь наиболее распространенный меха- низм, приводящий к возникновению нескольких исключений, но существует немало других способов параллельного выполнения нескольких операций. Поэтому поддержка множественных исклю- чений встроена непосредственно в класс
Task
. Исключение в нем представляется объектом класса
AggregateException
, а не просто
Exception
. Объект
AggregateException содержит коллекцию дру- гих исключений.
Поскольку эта поддержка встроена в класс
Task
, то когда исклю- чение покидает async-метод, создается объект
AggregateException и фактическое исключение добавляется в него как внутреннее пе- ред помещением в
Task
. Таким образом, в большинстве случаев
AggregateException содержит только одно внутреннее исключение, но метод
WhenAll создает
AggregateException с несколькими ис- ключениями.
Все это происходит вне зависимости от того, произошло ли ис- ключение до первого await или позже. Исключения, имевшие место до первого await, легко можно было бы возбудить синх- ронно, но тогда они возникали бы при вызове метода, а не в ре- зультате await в вызывающей программе, что было бы непосле- довательно.
С другой стороны, если исключение повторно возбуждается оператором await
, нам необходим какой-то компромисс. Оператор await должен возбуждать исключение того же типа, что было изна- чально возбуждено в async-методе, а не типа
AggregateException
Поэтому ничего не остается, кроме как возбудить первое внутреннее
81
Синхронное возбуждение исключений исключение. Однако, перехватив его, мы можем обратиться к объек- ту
Task напрямую и получить от него объект
AggregateException
, содержащий полный список исключений.
Task
try
{
await allTask;
}
catch
{
foreach (Exception ex in allTask.Exception.InnerExceptions)
{
// Обработать исключение
}
}
Синхронное возбуждение
исключений
Паттерн TAP допускает синхронное возбуждение исключений, но только если исключение служит для индикации ошибок в порядке вызова метода, а не ошибок, возникающих в процессе его выполнения.
Мы видели, что async-метод перехватывает любое исключение и по- мещает его в объект
Task
, вне зависимости от того, произошло оно до или после первого await
. Поэтому если вы хотите возбудить исклю- чение синхронно, то придется прибегнуть к трюку: воспользоваться синхронным методом, который проверяет ошибки перед вызовом асинхронного.
private Task
{
if (domain == null) throw new ArgumentNullException(“domain”);
return GetFaviconAsyncInternal(domain);
}
private async Task
{
Получающуюся при таком подходе трассировку стека интерпре- тировать немного проще. Стоит ли овчинка выделки? Лично я сомне- ваюсь. Тем не менее, этот пример полезен для лучшего понимания.
82
Глава 9. Исключения в асинхронном коде
Блок finally в async-методах
Наконец, в async-методе разрешено использовать блок try-fi nally
, и работает он в основном, как и ожидается. Гарантируется, что блок fi nally будет выполнен до того, как программа покинет содержащий его метод, неважно произошло это в ходе нормального выполнения или в результате исключения внутри блока try
Но эта гарантия таит в себе скрытую опасность. В случае async-ме- тода нет гарантии, что поток выполнения вообще когда-либо покинет метод. Легко написать метод, который доходит до await
, приостанав- ливается, а затем про него забывают, и в конечном итоге он достается сборщику мусора.
async void AlexsMethod()
{
try
{
await DelayForever();
}
fi nally
{
// Сюда мы никогда не попадем
}
}
Task DelayForever()
{
return new TaskCompletionSource
ГЛАВА 10.
Организация параллелизма
с помощью механизма
async
Механизм async открывает широкие возможности для использования параллелизма, характерного для современных машин. Это языковое средство позволяет структурировать программу гораздо проще, чем было возможно раньше.
Мы уже видели, как пишется простой код, запускающий несколь- ко длительных операций, например сетевых запросов, которые затем исполняются параллельно. Благодаря таким средствам, как
WhenAll
, асинхронный код весьма эффективен для выполнения подобных опе- раций, не связанных с локальными вычислениями. Если же речь идет о локальных вычислениях, то сам по себе механизм async не поможет.
Пока не достигнута точка асинхронности, весь код выполняется син- хронно в вызывающем потоке.
await и блокировки
Простейший способ распараллелить программу состоит в том, что за- планировать работу в разных потоках. Это легко сделать с помощью метода
Task.Run
, так как он возвращает объект
Task
, с которым мы можем обращаться, как с любой другой длительной операцией. Но наличие нескольких потоков сопряжено с риском небезопасного до- ступа к разделяемым объектам в памяти.
Традиционное решение, заключающееся в использовании ключе- вого слова lock
, в асинхронном коде более сложно, о чем мы говори- ли в разделе «Блоки lock» выше. Поскольку в блоке lock нельзя ис- пользовать оператор await
, то не существует способа предотвратить исполнение конфликтующего кода во время ожидания. На самом
84
Глава 10. Организация параллелизма с помощью...
деле, лучше вообще избегать удержания ресурсов на время исполне- ния await
. Весь смысл асинхронности как раз и состоит в том, чтобы освобождать ресурсы на время ожидания, и программист должен по- нимать, что в это время может произойти всё что угодно.
lock (sync)
{
// Подговиться к асинхронной операции
}
int myNum = await AlexsMethodAsync();
lock (sync)
{
// Использовать результат асинхронной операции
}
Рассмотрим в качестве примера поток пользовательского интер- фейса. Такой поток существует в единственном экземпляре, поэтому в некотором смысле его можно считать блокировкой. Если некий код работает в потоке пользовательского интерфейса, то в каждый момент времени заведомо выполняется только одна его команда. Но даже в этом случае во время ожидания может случиться всё что угодно.
Если в ответ на нажатие пользователем кнопки вы запускаете сетевой запрос, то ничто не помешает пользователю нажать ту же кнопку еще раз, пока ваша программа ждет результата. В этом и состоит смысл асинхронности в приложениях с пользовательским интерфейсом: ин- терфейс остается отзывчивым и делает всё, что просит пользователь, даже если это опасно.
Но мы по крайней мере можем указать в программе точки, в кото- рых могут происходить неприятности. Следует взять за правило ста- вить await только в местах, где это безопасно, и быть готовым к тому, что после возобновления состояние мира может измениться. Иногда это означает, что необходимо произвести вторую, кажущуюся бес- смысленной, проверку, прежде чем продолжать работу.
if (DataInvalid())
{
Data d = await GetNewData();
// Внутри await могло произойти всё что угодно if (DataInvalid())
{
SetNewData(d);
}
}
85
Акторы
Я сказал, что поток пользовательского интерфейса можно рассмат- ривать, как блокировку, просто потому что такой поток всего один.
На самом деле, было бы правильно назвать его актором. Актор – это поток, который отвечает за определенный набор данных, причем ни- какой другой поток не вправе обращаться к этим данным. В данном случае только поток пользовательского интерфейса имеет доступ к данным, составляющим пользовательский интерфейс. При таком подходе становится гораздо проще обеспечить безопасность кода пользовательского интерфейса, потому что единственное место, где что-то может произойти, – это await
Если говорить более общо, то программы можно строить из ком- понентов, которые работают в одном потоке и отвечают за опреде- ленные данные. Такая модель акторов легко адаптируется к парал- лельным архитектурам, так как акторы могут работать на разных ядрах. Она эффективна и в программах общего вида, когда имеются компоненты, наделенные состоянием, которым требуется безопасно манипулировать.
Существуют и другие парадигмы, например программирование потоков данных (dataflow programming), чрезвычайно эффектив- ные в естественно параллельных (embarrassingly parallel) задачах, когда имеется много независимых друг от друга вычис- лений, допускающих очевидное распараллеливание. Акторы – подходящий выбор для тех задач, где очевидного распараллели- вания не существует.
На первый взгляд, между программированием с помощью акторов и с помощью блокировок очень много общего. В частности, в обоих моделях присутствует идея о том, что в каждый момент времени до- ступ к определенным данным разрешен только одному потоку. Раз- ница же в том, что один поток не может принадлежать сразу несколь- ким акторам. Вместо того чтобы удерживать ресурсы одного актора на время выполнения кода другого актора, поток должен сделать асинхронный вызов. Во время ожидания вызывающий поток может заняться другими делами.
Модель акторов лучше масштабируется, чем модель программиро- вания с блокировками разделяемых объектов. Модель, основанная на доступе к общему адресному пространству из нескольких ядер, посте- пенно отдаляется от реальности. Если вам доводилось использовать в программе блокировки, то вы знаете, как просто «нарваться» на взаи- моблокировки и состояния гонки.
Акторы
86
Глава 10. Организация параллелизма с помощью...
Использование акторов в C#
Конечно, никто не мешает программировать в стиле модели акто- ров вручную, но есть и библиотеки, упрощающие эту задачу. Так, библиотека NAct (http://code.google.com/p/n-act/) в полной мере задействует механизм async в C# для превращения обычных объектов в акторов, в результате чего все обращения к ним производятся в отдельном потоке. Достигается это с помощью заместителя, который обертывает объект, делая его актором.
Рассмотрим пример. Пусть требуется реализовать криптографи- ческую службу, которой для шифрования потока данных необходима последовательность псевдослучайных чисел. Здесь имеются две зада- чи, требующие большого объема вычислений, и мы хотели бы решать их параллельно:
• генерация псевдослучайных чисел;
• использование их для потокового шифрования.
Мы будем рассматривать только актора, играющего роль генера- тора случайных чисел. Библиотеке NAct необходим интерфейс, имея который она сможет создать для нас заместителя. Реализуем этот интерфейс:
public interface IRndGenerator : IActor
{
Task
}
Этот интерфейс должен наследовать пустому маркерному интер- фейсу
IActor
. Все методы интерфейса должны возвращать один из совместимых с механизмом async типов:
• void
•
Task
•
Task
Теперь можно реализовать сам класс генератора.
class RndGenerator : IRndGenerator
{
public async Task
{
// Безопасная генерация случайного числа – медленная операция return num;
}
}
87
Библиотека Task Parallel Library Dataflow
Неожиданно здесь только то, что никаких неожиданностей нет.
Это самый обычный класс. Чтобы им воспользоваться, мы должны сконструировать объект и передать его NAct, чтобы та обернула его, превратив в актора.
IRndGenerator rndActor = ActorWrapper.WrapActor(new RndGenerator());
Task
foreach (var chunk in stream)
{
int rndNum = await nextTask;
// Начать генерацию следующего числа nextTask = rndActor.GetNextNumber();
// Использовать rndNum для шифрования блока – медленная операция
}
На каждой итерации цикла я жду случайного числа, а затем запус- каю процедуру генерации следующего, пока сам занимаюсь медлен- ной операцией. Поскольку rndActor
– актор, NAct возвращает объект
Task немедленно, а генерацию производит в потоке
RndGenerator
Теперь два вычисления осуществляются параллельно, что позволяет лучше использовать ресурсы процессора. Благодаря встроенному в язык механизму async трудная задача программирования выражает- ся очень естественно.
Здесь не место вдаваться в детали работы с библиотекой NAct, но надеюсь, я рассказал достаточно для понимания того, насколько прос- то использовать модель акторов. У нее есть и другие возможности, в частности генерация событий в нужном потоке и интеллектуальное разделение потоков между простаивающими акторами. В общем и целом это означает, что модель хорошо масштабируется в реальных системах.
Библиотека Task Parallel Library
Dataflow
Еще одно полезное средство параллельного программирования, ис- пользование которого в C# упрощается за счет применения механиз- ма async, – это парадигма программирования потоков данных. В этой модели определяется последовательность операций, которые необ- ходимо произвести над входными данными, а система автоматичес-
88
Глава 10. Организация параллелизма с помощью...
ки распараллеливает их. Microsoft предлагает для этой цели библи- отеку TPL Dataflow, которую можно скачать из репозитория NuGet
(https://nuget.org/packages/Microsoft.Tpl.Dataflow).
Техника программирования потоков данных особенно полезна, когда самой критичной с точки зрения производительности час- тью программы является преобразование данных. Ничто не ме- шает одновременно использовать акторов и программирование потоков данных. В этом случае один актор, на который приходит- ся большой объем вычислений, применяет для их распаралле- ливания потоки данных.
Идея библиотеки TPL Dataflow заключается в передаче сообще- ний между блоками. Чтобы создать сеть потоков данных, мы сцепля- ем блоки, реализующие два интерфейса:
ISourceBlock
Нечто такое, у чего можно запросить сообщения типа
T.
ITargetBlock
Нечто такое, чему можно передать сообщения.
В интерфейсе
ISourceBlock
LinkTo
, который принимает объект-получатель типа
ITargetBlock
и связывает его с источником, так что каждое сообщение, порожденное объектом
ISourceBlock
, передается объекту
ITargetBlock
. Большинст- во блоков реализуют оба интерфейса, быть может, с разными пара- метрами-типами, так чтобы блок мог потреблять сообщения одного типа и порождать сообщения другого типа.
Интерфейсы можно реализовывать самостоятельно, но гораздо чаще используются встроенные блоки, например:
ActionBlock
Конструктору объекта
ActionBlock
передается делегат, который вызывается для каждого сообщения. Класс
Acti- onBlock
ITargetBlock
TransformBlock
Конструктору также передается делегат, только на этот раз он является функцией, возвращающей значение. Это значение становится сообщением, которое передается следующему бло- ку. Класс
TransformBlock
реализует интерфейсы
ITargetBlock
ISourceBlock
. Это параллель- ная версия LINQ-метода
Select
89
Библиотека Task Parallel Library Dataflow
JoinBlock
Объединяет несколько входных потоков в один выходной по- ток, состоящий из кортежей.
Есть много других встроенных блоков, из которых можно конст- руировать вычисление конвейерного типа. Без дополнительного программирования блоки работают как параллельный конвейер, но каждый блок способен одновременно обрабатывать только одно сообщение. Этого достаточно, если все блоки работают примерно одинаковое время, но если какая-то стадия оказывается существен- но медленнее всех остальных, то можно сконфигурировать объекты
ActionBlock
и
TransformBlock
, так чтобы они ра- ботали параллельно в отдельном блоке, то есть по сути дела расщеп- ляли себя на много идентичных блоков, которые сообща трудятся над некоторой задачей.
Библиотека TPL Dataflow получает выигрыш от механизма async, потому что делегаты, передаваемые блокам
ActionBlock
и
TransformBlock
, могут быть асинхронными и возвра- щать значения типа
Task или
Task
SendAsync
, расши- ряющий класс
ITargetBlock
1 2 3 4 5 6 7 8 9