Прочитав эту главу, вы научитесь:
• определять асинхронные методы и пользоваться ими для сокращения времени отклика приложений, работающих в интерактивном режиме и выполняющих операции, занимающие много времени или связанные с вводом-выводом данных;
• объяснять, как сократить время, затрачиваемое на выполнение сложных LINQ-запросов, используя распараллеливание;
• пользоваться параллельными классами коллекций для безопасного совместного использования данных параллельно выполняемыми задачами.
В главе 23 «Повышение производительности путем использования задач» демонстрировались способы использования класса Task для выполнения операций в параллельном режиме и повышения производительности работы приложений, связанных с интенсивными вычислениями. Но наряду с тем, что максимальное использование приложением доступной вычислительной мощности может заставить его работать намного быстрее, важным аспектом является также возможность этого приложения реагировать на действия пользователя. Следует помнить, что пользовательский интерфейс Windows работает с использованием одного потока выполнения, но пользователи ожидают от приложения возможной ответной реакции при щелчке на кнопке формы, даже если в данный момент приложение выполняет весьма объемное и сложное вычисление. Кроме того, некоторые задачи могут затрачивать много времени на выполнение, даже если они не связаны с интенсивными вычислениями (это, к примеру, задачи, связанные с вводом-выводом данных, ожидающие получения информации по сети от удаленного веб-сайта), а блокирование взаимодействия с пользователем на время ожидания события, которое может занять неопределенное количество времени до того, как это событие произойдет, несомненно, не лучший вариант практики проектирования. У обеих этих проблем одно и то же решение: задачи следует выполнять в асинхронном режиме, оставляя свободным поток пользовательского интерфейса для обработки взаимодействия с пользователем.
Вопросы, связанные со временем отклика, не ограничиваются пользовательскими интерфейсами. К примеру, в главе 21 «Запрос данных, находящихся в памяти, с помощью выражений в виде запросов» было показано, как получить доступ к данным, хранящимся в памяти, заявительным способом, используя интегрированный в C# язык запросов (Language-Integrated Query (LINQ)). Обычный LINQ-запрос создает перечисляемый набор результатов, и для извлечения данных можно организовать последовательный перебор элементов набора. Если источник данных, используемый для создания набора результатов, слишком объемный, выполнение LINQ-запроса может занять много времени. Многие системы управления базами данных сталкиваются с проблемой оптимизации запросов, решая ее путем использования алгоритмов, разбивающих процесс идентификации данных для запроса на серию задач с последующим запуском этих задач в параллельном режиме и объединением результатов, и получения полного набора результатов, когда выполнение задач завершится. Разработчики среды Microsoft .NET Framework решили снабдить расширение LINQ подобной способностью, в результате чего появилось расширение Parallel LINQ, или PLINQ. Это расширение будет рассмотрено во второй части данной главы.
Асинхронным называется метод, не блокирующий текущий поток, в котором началось его выполнение. Когда приложение вызывает асинхронный метод, по негласному соглашению ожидается, что метод возвратит управление вызывающей среде довольно быстро и станет выполнять свою работу в отдельном потоке. Определение достаточности нельзя охарактеризовать в количественных математических показателях, но ожидания заключаются в том, что если асинхронный метод выполняет операцию, которая может привести к заметной для вызывающей стороны задержке, он должен выполнять эту операцию с использованием потока, работающего в фоновом режиме, позволяя тем самым вызывающей стороне продолжить работу на текущем потоке. Судя по описанию, это довольно сложный процесс, и в самом деле в ранних версиях среды .NET Framework так оно и было. Но сейчас C# предоставляет модификатор методов под названием async и оператор await, которые возлагают основные сложности этого процесса на компилятор, а это означает (в большинстве случаев), что вам самим теперь уже не нужно связываться с тонкостями многопоточности.
Асинхронность и масштабируемость
Асинхронность является весьма эффективной концепцией, которую следует уяснить при создании крупномасштабных решений, таких как корпоративные веб-приложения и сервисы. Обычно у веб-сервера имеется ограниченный набор ресурсов для обработки запросов от потенциально широкой аудитории, каждый представитель которой ожидает, что его запрос будет обработан с высокой скоростью. Во многих случаях пользовательский запрос может вызвать серию операций, каждая из которых в отдельности может занять весьма значительное время — возможно, порядка 1–2 с. Рассмотрим, к примеру, систему электронной торговли, в которой пользователь запрашивает каталог товаров или размещает заказ. Обе эти операции обычно требуют чтения и записи данных, хранящихся в базе данных, которая может управляться сервером базы данных, удаленным от веб-сервера. Многие веб-серверы могут поддерживать только ограниченное количество одновременных подключений, и если поток, связанный с подключением, находится в режиме ожидания завершения операции ввода-вывода, это подключение фактически блокируется. Если поток создает для асинхронного ввода-вывода отдельную задачу, он может быть освобожден и подключение будет снова использовано уже другим пользователем. Этот подход предоставляет значительно более широкие возможности для масштабирования, чем тот, при котором операции проводятся в синхронном режиме.
Примеры и подробные объяснения, почему в данной ситуации выполнение синхронного ввода-вывода нельзя считать приемлемым, можно найти в материалах об антишаблоне синхронного ввода-вывода (Synchronous I/O anti-pattern) в открытом хранилище Microsoft Patterns & Practices по адресу .
Вы уже видели, как можно реализовать одновременно выполняемые операции с использованием Task-объектов. Краткое напоминание: когда задача инициируется путем использования методов Start или Run, относящихся к типу Task, среда выполнения (common language runtime (CLR)) использует для выделения задаче потока свой собственный алгоритм диспетчеризации и назначает выполнение этого потока на время, удобное для операционной системы при доступности достаточных ресурсов. Этот подход освобождает код от требований по определению рабочей нагрузки вашего компьютера и управлению ею. Если по завершении конкретной задачи нужно выполнить еще одну операцию, можно воспользоваться следующими вариантами.
• Самостоятельно задать ожидание завершения, воспользовавшись одним из Wait-методов, предоставляемых Task-типом. Затем можно инициировать новую операцию, возможно, путем определения еще одной задачи.
• Определить продолжение, которое просто указывает на операцию, выполняемую по завершении заданной операции. Среда .NET Framework автоматически выполняет операцию продолжения в качестве задачи, планируемой к выполнению после завершения исходной задачи. Продолжение использует тот же поток, что и исходная задача.
Тем не менее даже несмотря на то что Task-тип предоставляет удобное обобщение операций, вам все еще зачастую приходится создавать потенциально нескладный код для решения ряда часто встречающихся проблем, с которыми сталкиваются разработчики при использовании фоновых потоков. Предположим, к примеру, что вы определили следующий метод, выполняющий ряд длительных операций, которые требуют последовательного запуска, после чего выводит на экран сообщение в элементе управления типа TextBox:
private void slowMethod()
{
doFirstLongRunningOperation();
doSecondLongRunningOperation();
doThirdLongRunningOperation();
message.Text = "Processing Completed";
}
private void doFirstLongRunningOperation()
{
...
}
private void doSecondLongRunningOperation()
{
...
}
private void doThirdLongRunningOperation()
{
...
}
Если вызвать slowMethod из какой-то части кода пользовательского интерфейса (например, из обработчика события Click для элемента управления типа кнопки), пользовательский интерфейс перестанет реагировать на действия пользователя вплоть до завершения выполнения метода. Можно повысить его отзывчивость при выполнении метода slowMethod за счет использования Task-объекта, запускающего метод doFirstLongRunningOperation и определяющего продолжение для той же задачи, в которой поочередно запускаются методы doSecondLongRunningOperation и doThirdLongRunningOperation:
private void slowMethod()
{
Task task = new Task(doFirstLongRunningOperation);
task.ContinueWith(doSecondLongRunningOperation);
task.ContinueWith(doThirdLongRunningOperation);
task.Start();
message.Text = "Processing Completed"; // Когда появится это сообщение?
}
private void doFirstLongRunningOperation()
{
...
}
private void doSecondLongRunningOperation(Task t)
{
...
}
private void doThirdLongRunningOperation(Task t)
{
...
}
Хотя такая реорганизация кода представляется довольно простой, следует отметить ряд особенностей. А именно, чтобы приспособить методы doSecondLongRunningOperation и doThirdLongRunningOperation к требованиям продолжений, нужно изменить их сигнатуры (методам продолжений передается в качестве параметра Task-объект, инициирующий продолжение). Еще важнее задать вопрос: «Когда сообщение будет выведено в элементе управления типа TextBox?». Что касается второго пункта, то дело обстоит следующим образом: несмотря на то что метод Start инициирует запуск задачи Task, он не дожидается ее завершения, следовательно, сообщение появляется не по завершении задачи, а в ходе ее выполнения.
Особой сложностью этот пример не отличается, но важен сам принцип. Есть как минимум два решения. Первое заключается в том, чтобы перед выводом сообщения дождаться завершения задачи:
private void slowMethod()
{
Task task = new Task(doFirstLongRunningOperation);
task.ContinueWith(doSecondLongRunningOperation);
task.ContinueWith(doThirdLongRunningOperation);
task.Start();
task.Wait();
message.Text = "Processing Completed";
}
Но теперь вызов метода Wait блокирует поток, выполняющий метод slowMethod, и дискредитирует главную цель использования класса Task.
ВНИМАНИЕ Вообще-то непосредственного вызова метода Wait в потоке пользовательского интерфейса нужно избегать.
Более удачное решение предусматривает определение продолжения, которое выводит сообщение на экран и подстраивается под запуск только при завершении выполнения метода doThirdLongRunningOperation, тогда вызов метода Wait можно будет удалить. Может возникнуть соблазн реализовать это продолжение в виде делегата, показанного далее жирным шрифтом (не забудьте, что продолжение передает Task-объект в виде аргумента, именно для этого и предназначен параметр делегата t):
private void slowMethod()
{
Task task = new Task(doFirstLongRunningOperation);
task.ContinueWith(doSecondLongRunningOperation);
task.ContinueWith(doThirdLongRunningOperation);
task.ContinueWith((t) => message.Text = "Processing Complete");
task.Start();
}
К сожалению, этот подход вскрывает существование еще одной проблемы. Если попытаться запустить этот код в режиме отладки, окажется, что последнее продолжение выдаст исключение System.Exception с весьма туманным сообщением о том, что приложение вызвало интерфейс, маршализированный для другого потока: «The application called an interface that was marshaled for a different thread». Дело в том, что манипулировать элементами пользовательского интерфейса может только поток пользовательского интерфейса, а здесь вы попытались сделать запись в элемент управления типа TextBox из другого потока, того, что был задействован для запуска задачи Task-объекта. Эту проблему можно решить за счет использования Dispatcher-объекта. Этот объект является компонентом инфраструктуры пользовательского интерфейса, и ему путем вызова его же метода RunAsync можно отправлять запросы для выполнения работы в потоке пользовательского интерфейса. Этот метод получает Action-делегата, указывающего на запускаемый код. Рассмотрение подробностей, касающихся Dispatcher-объекта и метода RunAsync, не входит в круг вопросов, рассматриваемых в данной книге, но в следующем примере показано, как ими можно воспользоваться для вывода из продолжения сообщения, требующегося методу slowMethod:
private void slowMethod()
{
Task task = new Task(doFirstLongRunningOperation);
task.ContinueWith(doSecondLongRunningOperation);
task.ContinueWith(doThirdLongRunningOperation);
task.ContinueWith((t) => this.Dispatcher.RunAsync(
CoreDispatcherPriority.Normal,
() => message.Text = "Processing Complete"));
task.Start();
}
Хотя этот код работает, но в нем будет трудно разобраться и его нелегко будет сопровождать. Теперь у вас есть делегат (продолжение), указывающий на другого делегата (код, запускаемый методом RunAsync).
ПРИМЕЧАНИЕ Дополнительные сведения об объекте типа Dispatcher и о методе RunAsync можно найти на веб-сайте компании Microsoft по адресу .
Ключевые слова async и await предназначены в C# для того, чтобы позволить вам определять и вызывать методы, способные выполняться в асинхронном режиме. Это означает, что вы не обязаны заниматься указанием продолжений или диспетчеризацией кода для запуска Dispatcher-объектов с целью обеспечения манипуляции данными в надлежащих потоках. Все очень просто:
• модификатор async показывает, что метод содержит функции, которые должны выполняться в асинхронном режиме;
• оператор await указывает места, в которых функции должны выполняться в асинхронном режиме.
В следующем примере кода показан метод slowMethod, реализованный с помощью модификатора async и операторов await в виде асинхронного метода:
private async void slowMethod()
{
await doFirstLongRunningOperation();
await doSecondLongRunningOperation();
await doThirdLongRunningOperation();
message.Text = "Processing Complete";
}
Теперь этот метод выглядит в высшей степени похожим на исходную версию, чем и характеризуется эффективность async и await. Эта магия является ничем иным, как упражнением по переделке вашего кода компилятором C#. Когда в методе, объявленном с ключевым словом async, этому компилятору встречается оператор await, происходит фактическое переформатирование операнда, который следует за этим оператором, в задачу, запускаемую в том же потоке, что и async-метод. Весь остальной код превращается в продолжение, запускаемое после завершения задачи, которое снова запускается в том же потоке. Теперь, поскольку поток, в котором был запущен async-метод, был потоком, в котором запущен пользовательский интерфейс, у него есть непосредственный доступ к элементам управления окна, следовательно, он может обновлять их напрямую, не прокладывая маршрут через Dispatcher-объект.
Хотя на первый взгляд этот подход выглядит весьма простым, нужно обязательно иметь в виду следующие особенности и избегать возможного недопонимания.
• Модификатор async не является признаком того, что метод запускается в асинхронном режиме в отдельном потоке. Его роль ограничивается указанием на то, что код в методе может быть разделен для получения одного или нескольких продолжений. Когда запускаются эти продолжения, они выполняются в том же самом потоке, что и исходный метод.
• Оператор await указывает место, с которого компилятор C# может разбить код на продолжение. Сам оператор await ожидает в качестве своего операнда объект, допускающий ожидание. Этот объект относится к типу, предоставляющему метод GetAwaiter, который возвращает объект, который в свою очередь предоставляет методы для запуска кода и ожидания завершения его выполнения. Компилятор C# превращает ваш код в инструкции, использующие эти методы для создания соответствующих продолжений.
ВНИМАНИЕ Оператор await можно использовать только в методе с пометкой async. Вне async-метода ключевое слово рассматривается как обычный идентификатор (можно даже создать переменную по имени await, хотя делать это не рекомендуется).
В текущей реализации оператора await объект, допускающий ожидание, ждет, что в качестве операнда вы укажете объект типа Task. Это означает, что нужно внести ряд изменений в методы doFirstLongRunningOperation, doSecondLongRunningOperation и doThirdLongRunningOperation. Если конкретнее, то каждый метод для выполнения своей работы должен теперь создать и запустить Task-объект и вернуть ссылку на этот Task-объект. Следующий пример показывает исправленную версию метода doFirstLongRunningOperation:
private Task doFirstLongRunningOperation()
{
Task t = Task.Run(() => { /* сюда помещается код для этого метода */ });
return t;
}
Также имеет смысл разобраться с тем, есть ли возможность разбить работу, выполняемую методом doFirstLongRunningOperation, на ряд параллельных операций. Если такая возможность есть, работу можно разбить на набор задач (Task-объектов) в соответствии с описаниями, которые были даны в главе 23. Но какие из этих Task-объектов нужно вернуть в качестве результата метода?
private Task doFirstLongRunningOperation()
{
Task first = Task.Run(() => { /* код для первой операции */ });
Task second = Task.Run(() => { /* код для второй операции */ });
return ...; // Какой из объектов возвращать, first или second?
}
Если метод возвращает first, оператор await в slowMethod будет дожидаться только завершения этой задачи, но не задачи second. Похожая логика применяется, если метод возвращает second. Решение заключается в определении метода doFirstLongRunningOperation с указанием модификатора async и применением оператора await в отношении каждой из задач:
private async Task doFirstLongRunningOperation()
{
Task first = Task.Run(() => { /* код для первой операции */ });
Task second = Task.Run(() => { /* код для второй операции */ });
await first;
await second;
}
Следует напомнить, что компилятор, встретив оператор await, создает код, ожидающий завершения выполнения той задачи, которая была указана в качестве аргумента, вместе с продолжением, запускающим последующие инструкции. Значение, возвращаемое async-методом, можно рассматривать в качестве ссылки на задачу, запускающую это продолжение (это не совсем точное описание, но для целей данной главы оно является вполне подходящей моделью). Итак, метод doFirstLongRunningOperation создает и запускает в режиме параллельного выполнения задачи first и second, компилятор переформатирует операторы await в код, ожидающий завершения задачи first, за которой следует продолжение, ожидающее завершения задачи second, а модификатор async заставляет компилятор возвращать ссылку на продолжение. Учтите, что из-за того что компилятор не определяет возвращаемое значение метода, вы больше не указываете возвращаемое значение самостоятельно (фактически, если вы попытаетесь в данном случае вернуть значение, код не пройдет компиляцию).
ПРИМЕЧАНИЕ Если не включить инструкцию await в async-метод, этот метод просто станет ссылкой на Task-объект, выполняющий код в теле метода. В результате при вызове метода он не будет запущен в асинхронном режиме. В таком случае компилятор предупредит вас сообщением, что в async-методе отсутствуют операторы await и он будет выполнен в синхронном режиме: «This async method lacks await operators and will run synchronously».
СОВЕТ Модификатор async можно использовать в качестве префикса делегата, позволяя создавать делегатов, объединяющих асинхронную обработку путем использования оператора await.
В следующем упражнении вы будете работать с приложением GraphDemo из главы 23 и внесете в него изменения для создания данных, используемых при построении графического изображения путем использования асинхронного метода.
Откройте в среде Microsoft Visual Studio 2015 решение GraphDemo, которое находится в папке \Microsoft Press\VCSBS\Chapter 24\GraphDemo вашей папки документов.
Раскройте в обозревателе решений узел MainPage.xaml и откройте к окне редактора файл MainPage.xaml.cs.
Найдите в классе MainPage метод plotButton_Click. Его код должен выглядеть следующим образом:
private void plotButton_Click(object sender, RoutedEventArgs e)
{
Random rand = new Random();
redValue = (byte)rand.Next(0xFF);
greenValue = (byte)rand.Next(0xFF);
blueValue = (byte)rand.Next(0xFF);
tokenSource = new CancellationTokenSource();
CancellationToken token = tokenSource.Token;
Stopwatch watch = Stopwatch.StartNew();
try
{
generateGraphData(data, 0, pixelWidth / 2, token);
duration.Text = $"Duration (ms): {watch.ElapsedMilliseconds}";
}
catch (OperationCanceledException oce)
{
duration.Text = oce.Message;
}
Stream pixelStream = graphBitmap.PixelBuffer.AsStream();
pixelStream.Seek(0, SeekOrigin.Begin);
pixelStream.Write(data, 0, data.Length);
graphBitmap.Invalidate();
graphImage.Source = graphBitmap;
}
Это упрощенная версия приложения из предыдущей главы. Здесь метод generateGraphData вызывается непосредственно из потока пользовательского интерфейса, а объекты типа Task для создания данных, используемых при построении графического изображения, в параллельном режиме не используются.
ПРИМЕЧАНИЕ Если в упражнении в главе 23 вы в целях экономии памяти уменьшили значения полей pixelWidth и pixelHeight, то, прежде чем продолжить выполнение данного упражнения, сделайте это еще раз.
В меню Отладка щелкните на пункте Начать отладку. Щелкните в окне GraphDemo на кнопке Plot Graph. Попробуйте в ходе создания данных щелкнуть на кнопке Cancel.
Заметьте, что пользовательский интерфейс при создании и выводе графического изображения на экран ни на что не реагирует. Дело в том, что метод plotButton_Click всю свою работу, включая создание данных, используемых при построении графического изображения, выполняет в синхронном режиме.
Вернитесь в среду Visual Studio и остановите отладку.
В окне редактора, показывающем код класса MainPage, добавьте выше метода generateGraphData новый закрытый метод по имени generateGraphDataAsync.
Этот метод будет получать такой же список параметров, что и метод generateGraphData, но вместо void он должен вернуть Task-объект. Также у метода должна быть пометка async, и он должен выглядеть следующим образом:
private async Task generateGraphDataAsync(byte[] data,
int partitionStart, int partitionEnd,
CancellationToken token)
{
}
ПРИМЕЧАНИЕ Асинхронные методы принято называть с применением суффикса Async.
Добавьте к методу generateGraphDataAsync инструкции, выделенные здесь жирным шрифтом:
private async Task generateGraphDataAsync(byte[] data, int partitionStart, int
partitionEnd, CancellationToken token)
{
Task task = Task.Run(() => generateGraphData(data, partitionStart,
partitionEnd,token));
await task;
}
Этот код создает Task-объект, который запускает метод generateGraphData и использует оператор await для ожидания завершения задачи Task-объекта. Задача, создаваемая компилятором в результате использования оператора await, является значением, возвращенным методом.
Вернитесь к методу plotButton_Click и измените определение этого метода, включив в него, как показано далее жирным шрифтом, модификатор async:
private async void plotButton_Click(object sender, RoutedEventArgs e)
{
...
}
Вставьте в блок try метода plotButton_Click инструкцию, создающую данные для графического изображения и показанную далее жирным шрифтом, чтобы вызвать метод generateGraphDataAsync в асинхронном режиме:
try
{
await generateGraphDataAsync(data, 0, pixelWidth / 2, token);
duration.Text = $"Duration (ms): {watch.ElapsedMilliseconds}");
}
...
В меню Отладка укажите на пункт Окна и после этого щелкните на пункте Параметры исключений. Снимите в окне параметров исключений флажок Common Language Runtime Exceptions. Щелкните правой кнопкой мыши на пункте Common Language Runtime Exceptions и убедитесь в том, что режим Продолжить после необработанной ошибки в коде пользователя включен.
В меню Отладка щелкните на пункте Начать отладку. В окне GraphDemo щелкните на кнопке Plot Graph и убедитесь в том, что приложение успешно создает графическое изображение.
Щелкните на кнопке Plot Graph, а затем в ходе создания данных — на кнопке Cancel. На этот раз пользовательский интерфейс прореагирует. Будет создана только часть графического изображения, а в элементе управления типа TextBlock по имени duration будет выведено сообщение об отмене операции: «The operation was cancelled» (рис. 24.1).
Вернитесь в среду Visual Studio и остановите отладку.
Рис. 24.1
До сих пор во всех показанных упражнениях для выполнения части работы использовался Task-объект, не возвращающий значение. Но вы также используете задачи для запуска методов, вычисляющих результат. Для этого применяется класс-обобщение Task<TResult>, где параметр типа, TResult, указывает тип результата.
Объект типа Task<TResult> создается и запускается почти так же, как и обычный Task-объект. Основное отличие заключается в том, что выполняемый код должен вернуть значение. Например, метод по имени calculateValue, показанный в следующем примере кода, создает целочисленный результат. Для вызова этого метода с помощью задачи создается и запускается объект типа Task<int>. Значение, возвращаемое методом, вы получаете путем запроса свойства Result, принадлежащего Task<int>-объекту. Если задача не завершила выполнение метода и результат еще не доступен, свойство Result блокирует вызывающий код. Это означает, что вам не нужно самостоятельно что-либо предпринимать в плане синхронизации и что вы знаете: как только свойство Result вернет значение, задача завершит свою работу:
Task<int> calculateValueTask = Task.Run(() => calculateValue(...));
...
int calculatedData = calculateValueTask.Result; // Блокируется до завершения работы
// calculateValueTask
...
private int calculateValue(...)
{
int someValue;
// Выполнение вычисления и присваивание значения переменной someValue
...
return someValue;
}
Тип-обобщение Task<TResult> является также основой механизма определения асинхронных методов, возвращающих значения. В предыдущих примерах было показано, что асинхронные void-методы реализуются путем возвращения Task-объекта. Если асинхронный метод действительно создает результат, он, как показано в следующем примере, создающем асинхронную версию метода calculateValue, должен вернуть объект типа Task<TResult>:
private async Task<int> calculateValueAsync(...)
{
// Вызов calculateValue с использованием Task
Task<int> generateResultTask = Task.Run(() => calculateValue(...));
await generateResultTask;
return generateResultTask.Result;
}
Этот метод выглядит немного странно, поскольку возвращаемый тип указан как Task<int>, а инструкция return на самом деле возвращает значение типа int. Следует напомнить, что при определении async-метода компилятор реорганизует ваш код и на самом деле возвращает ссылку на Task-объект, который запускает продолжение для инструкции, возвращающей generateResultTask.Result;. Это продолжение возвращает выражение типа int, поэтому возвращаемым типом метода является Task<int>.
Для вызова асинхронного метода, возвращающего значение, нужно воспользоваться оператором await:
int result = await calculateValueAsync(...);
Этот оператор извлекает значение из Task-объекта путем использования метода calculateValueAsync и в данном случае присваивает его переменной result.
Программисты считают, что модификатор async и оператор await вносят в код путаницу. Поэтому важно усвоить следующее.
• Если метод объявлен с модификатором async, это еще не означает, что он выполняется в асинхронном режиме. Это означает, что метод может содержать инструкции, которые могут выполняться в асинхронном режиме.
• Оператор await показывает, что метод должен быть запущен в отдельной задаче и что вызывающий код приостанавливается, пока не будет завершен вызов метода. Поток, используемый вызывающим кодом, высвобождается и может быть использован повторно. Это важно в том случае, если это тот самый поток, который используется пользовательским интерфейсом, поскольку это позволяет сохранить его отзывчивость на действия пользователя.
• Оператор await не является функциональным аналогом принадлежащего задаче метода Wait, который всегда блокирует текущий поток и не допускает его повторного использования, пока задача не завершится.
• Изначально код, возобновляющий выполнение после оператора await, пытается получить исходный поток, который был использован для вызова асинхронного метода. Если этот поток занят, код будет блокирован. Чтобы указать, что выполнение кода может быть возобновлено в любом доступном потоке, и сократить шансы на его блокировку, можно воспользоваться методом ConfigureAwait(false). Особую пользу это принесет веб-приложениям и сервисам, которым может понадобиться обслуживать многие тысячи одновременно поступающих запросов.
• Метод ConfigureAwait(false) нельзя использовать, если код, запускаемый после оператора await, должен выполняться в исходном потоке. В ранее рассмотренном примере добавление ConfigureAwait(false) к каждой операции с await приведет к тому, что с высокой вероятностью продолжения, создаваемые компилятором, будут запускаться в отдельных потоках. Это касается и продолжений, пытающихся установить для свойства Text строковое значение с сообщением, что снова приведет к выдаче исключения, свидетельствующего о том, что приложение вызвало интерфейс, маршализированный для другого потока: «The application called an interface that was marshaled for a different thread».
private async void slowMethod()
{
await doFirstLongRunningOperation().ConfigureAwait(false);
await doSecondLongRunningOperation().ConfigureAwait(false);
await doThirdLongRunningOperation().ConfigureAwait(false);
message.Text = "Processing Complete";
}
• Неосмотрительное использование асинхронных методов, возвращающих результаты и запускаемых в потоке пользовательского интерфейса, может привести к возникновению взаимных блокировок и стать причиной зависания приложения. Рассмотрим следующий пример:
private async void myMethod()
{
var data = generateResult();
...
message.Text = $"result: {data.Result}";
}
private async Task<string> generateResult()
{
string result;
...
result = ...
return result;
}
Метод generateResult в этом примере кода возвращает строковое значение. Но метод myMethod фактически не запускает задачу, выполняющую метод generateResult, пытаясь получить доступ к свойству data.Result. Переменная data является ссылкой на задачу, и если свойство Result недоступно по причине того, что задача не была запущена, то обращение к этому свойству заблокирует текущий поток до завершения работы метода generateResult. Кроме того, когда метод завершит работу, задача, используемая для запуска метода generateResult, попытается возобновить работу потока, на котором она была вызвана (потока пользовательского интерфейса), но теперь этот поток окажется заблокированным. В результате всего этого метод myMethod не сможет завершить свою работу, пока не завершится работа метода generateResult, а метод generateResult не сможет завершиться до тех пор, пока не завершится метод myMethod.
Решением этой проблемы является использование оператора await в отношении задачи, которая выполняет метод generateResult. Сделать это можно следующим образом:
private async void myMethod()
{
var data = generateResult();
...
message.Text = $"result: {await data}";
}
Разработчики Windows 8 и последующих версий этой операционной системы хотели обеспечить максимально возможную отзывчивость приложений, поэтому при реализации WinRT ими было принято решение, что любая операция, которая может занять более 50 мс, должна быть доступна только через асинхронный API-интерфейс. В этой книге уже встречалась пара примеров такого подхода. К примеру, для вывода на экран сообщения для пользователя можно воспользоваться объектом типа MessageDialog. Но при выводе этого сообщения нужно воспользоваться методом ShowAsync:
using Windows.UI.Popups;
...
MessageDialog dlg = new MessageDialog("Message to user");
await dlg.ShowAsync();
Объект типа MessageDialog выводит сообщение на экран и ждет, пока пользователь не щелкнет на кнопке Close (Закрыть), появляющейся в качестве части этого диалогового окна. Любая форма взаимодействия с пользователем может занять неопределенное время (прежде чем щелкнуть на кнопке Close, пользователь может пойти на обед), и зачастую бывает важно не заблокировать приложение или не воспрепятствовать ему при выполнении других операций (например, при реагировании на события), пока на экране находится диалоговое окно. Класс MessageDialog не предоставляет синхронную версию метода ShowAsync, но если нужно вывести диалоговое окно в синхронном режиме, можно воспользоваться методом dlg.ShowAsync() без оператора await.
Другой часто встречающийся пример асинхронной обработки касается класса FileOpenPicker, который вам уже встречался в главе 5 «Использование инструкций составного присваивания и итераций». Класс FileOpenPicker выводит на экран список файлов, в котором пользователь может сделать свой выбор. Как и в случае использования класса MessageDialog, пользователь может потратить на просмотр и выбор файла довольно много времени, поэтому данная операция не должна блокировать работу приложения. В следующем примере показано, как использовать класс FileOpenPicker для отображения файлов, находящихся в папке документов пользователя, и ожидать выбора пользователем отдельного файла из списка:
using Windows.Storage;
using Windows.Storage.Pickers;
...
FileOpenPicker fp = new FileOpenPicker();
fp.SuggestedStartLocation = PickerLocationId.DocumentsLibrary;
fp.ViewMode = PickerViewMode.List;
fp.FileTypeFilter.Add("*");
StorageFile file = await fp.PickSingleFileAsync();
Ключевая инструкция находится в строке, где вызывается метод PickSingleFileAsync. Это метод, отображающий список файлов и позволяющий пользователю перемещаться по файловой системе и выбирать файл. (Класс FileOpenPicker также предоставляет метод PickMultipleFilesAsync, с помощью которого пользователь может выбрать сразу несколько файлов.) Значение, возвращаемое этим методом, имеет тип Task<StorageFile>, а оператор await извлекает из этого результата объект типа StorageFile. Класс StorageFile предоставляет абстрактное представление файла, хранящегося на жестком диске. Используя этот класс, можно открыть файл и производить с ним операции чтения и записи данных.
ПРИМЕЧАНИЕ Собственно говоря, метод PickSingleFileAsync возвращает объект типа IAsyncOperation<StorageFile>. WinRT использует свое собственное абстрактное представление асинхронных операций и отображает Task-объекты .NET Framework на эту абстракцию; интерфейс IAsyncOperation реализуется в классе Task. Если вы программируете на C#, ваш код не затрагивается этим преобразованием и вы можете просто использовать Task-объекты, не задумываясь о том, как они получат отображение на асинхронные операции WinRT.
Еще одним источником потенциально медленно выполняемых операций является файловый ввод-вывод, и в классе StorageFile реализуется ряд асинхронных методов, с помощью которых эти операции могут выполняться, не влияя на отзывчивость приложения. К примеру, в главе 5, после того как пользователь выбирал файл, используя FileOpenPicker-объект, код открывал этот файл для чтения в асинхронном режиме:
StorageFile file = await fp.PickSingleFileAsync();
...
var fileStream = await file.OpenAsync(FileAccessMode.Read);
И еще один, последний пример, имеющий непосредственное отношение к упражнениям, которые вы уже видели в этой и предыдущей главах, касается записи в поток данных. Вы могли заметить, что хотя время в отчете о создании данных для графического изображения составляло порядка нескольких секунд, могло пройти вдвое больше времени, прежде чем появится само изображение. Дело в способе записи данных в растровое изображение. Это изображение отображает данные, хранящиеся в буфере в качестве части объекта типа WriteableBitmap, а метод расширения AsStream предоставляет этому буферу интерфейс типа Stream (поток данных). Данные записываются в буфер через этот поток с помощью метода Write:
...
Stream pixelStream = graphBitmap.PixelBuffer.AsStream();
pixelStream.Seek(0, SeekOrigin.Begin);
pixelStream.Write(data, 0, data.Length);
...
Если вы не уменьшили значения полей pixelWidth и pixelHeight для экономии памяти, объем данных, записываемых в буфер, будет составлять чуть более 570 Мбайт (15 000 · 10 000 · 4 байта), поэтому на эту операцию Write уйдет несколько секунд. Для сокращения времени отклика эту операцию можно выполнить в асинхронном режиме, воспользовавшись методом WriteAsync:
await pixelStream.WriteAsync(data, 0, data.Length);
Таким образом, создавая приложение под операционную систему Windows, вы должны изыскивать возможность применения асинхронного режима везде, где только можно.
Шаблон проектирования IAsyncResult в ранних версиях .NET Framework
Асинхронность уже давно признана ключевым элементом в создании отзывчивых приложений с помощью .NET Framework и понятием, предшествующим введению класса Task в .NET Framework версии 4.0. Чтобы справиться с ситуациями, требующими асинхронности, компания Microsoft ввела шаблон проектирования IAsyncResult на основе делегата типа AsyncCallback. Рассмотрение подробностей работы этого шаблона не входит в круг вопросов, рассматриваемых в данной книге, но с точки зрения программистов реализация шаблона означает, что многие типы в библиотеке классов среды .NET Framework предоставляют длительные операции двумя способами: в синхронном виде, состоящем из одного метода, и в асинхронном виде, использующем пару методов с именами в формате НачалоИмяОперации (BeginOperationName) и КонецИмяОперации (EndOperationName), где ИмяОперации указывает на выполняемую операцию. Например, класс MemoryStream в пространстве имен System.IO предоставляет метод Write для записи данных в синхронном режиме в поток данных в памяти, но он также предоставляет методы BeginWrite и EndWrite для выполнения той же операции в асинхронном режиме. Метод BeginWrite инициирует операцию записи, выполняемую в новом потоке, и ждет от программиста предоставления ссылки на метод обратного вызова, который выполняется по завершении операции записи, — эта ссылка дается в виде AsyncCallback-делегата. В этом методе программист должен реализовать любое подходящее наведение порядка в использовании ресурсов и, чтобы обозначить завершение операции, вызвать метод EndWrite. Применение этого шаблона показано в следующем примере:
...
Byte[] buffer = ...; // Заполнение данными для записи в MemoryStream
MemoryStream ms = new MemoryStream();
AsyncCallback callback = new AsyncCallback(handleWriteCompleted);
ms.BeginWrite(buffer, 0, buffer.Length, callback, ms);
...
private void handleWriteCompleted(IAsyncResult ar)
{
MemoryStream ms = ar.AsyncState as MemoryStream;
... // Выполнение любых подходящих действий по наведению порядка
ms.EndWrite(ar);
}
Параметром метода обратного вызова (handleWriteCompleted) является IAsyncResult-объект, содержащий информацию о состоянии асинхронной операции и любую другую информацию о состоянии. В этом параметре вы можете передать методу обратного вызова информацию, определенную пользователем, — в этот параметр запаковывается последний аргумент, предоставляемый методу BeginOperationName. В данном примере методу обратного вызова передается ссылка на MemoryStream.
При всей работоспособности этой последовательности сам подход представляется нечетко выраженным, не обозначающим выполняемые операции достаточно ясно. Код для операции разбит на два метода, предполагаемую связь между которыми при сопровождении кода легко упустить из виду. При использовании Task-объектов эту модель можно упростить путем вызова статического метода FromAsync, принадлежащего классу TaskFactory. Этот метод получает метод BeginOperationName и метод EndOperationName и заключает их в код, выполняемый с использованием задачи, определяемой Task-объектом. При этом отпадает необходимость использования AsyncCallback-делегата, поскольку он создается за сценой с помощью метода FromAsync. Следовательно, операцию, показанную в предыдущем примере, можно выполнить следующим образом:
...
Byte[] buffer = ...;
MemoryStream s = new MemoryStream();
Task t = Task<int>.Factory.FromAsync(s.Beginwrite, s.EndWrite, buffer, 0,
buffer.Length, null);
t.Start();
await t;
...
Эта технология пригодится в том случае, если нужно получить доступ к возможностям асинхронного выполнения, предоставляемым типами, разработанными в ранних версиях среды .NET Framework.
Еще одной областью, для которой время отклика играет важную роль, является доступ к данным. Особенно это касается создания приложений, которым приходится вести поиск в протяженных структурах данных. В предыдущих главах вы смогли убедиться в эффективности применения расширения LINQ для извлечения данных из перечисляемых структур, но показанные примеры были по своей природе однопоточными. PLINQ предоставляет LINQ-набор расширений, основанных на применении задач в виде Task-объектов, помогающих существенно ускорить выполнение и распараллелить некоторые операции запросов.
PLINQ работает за счет разбиения набора данных на части и последующего применения задач для извлечения данных, соответствующих критериям, указанным в запросе параллельно для каждой части. Когда задачи завершат свое выполнение, результаты, извлеченные для каждой части, объединяются в один перечисляемый набор результатов. PLINQ идеально подходит для сценариев, где фигурируют наборы данных с большим количеством элементов, или для тех случаев, когда критерии, указанные для подходящих данных, предполагают проведение сложных операций, требующих больших вычислительных мощностей. Важной целью расширения PLINQ является его минимально возможное вторжение в код. Для преобразования LINQ-запроса в PLINQ-запрос используется метод расширения AsParallel. Этот метод возвращает ParallelQuery-объект, действующий аналогично исходному перечисляемому объекту, за исключением того, что он предоставляет возможность параллельной реализации множества LINQ-операторов, таких как join и where. Эти реализации LINQ-операторов основаны на задачах и используют различные алгоритмы в попытках запуска частей вашего LINQ-запроса в параллельном режиме везде, где только возможно. Но как и все в мире параллельных вычислений, метод AsParallel не волшебный. Вы не можете гарантировать, что выполнение кода ускорится, — все зависит от природы ваших LINQ-запросов и от того, поддаются ли распараллеливанию выполняемые ими задачи.
Чтобы разобраться в работе PLINQ и понять, в каких ситуациях это расширение принесет пользу, лучше обратиться к примерам. В упражнениях следующих разделов показаны два простых сценария.
Первый сценарий довольно прост. Давайте рассмотрим LINQ-запрос, перебирающий элементы коллекции и извлекающий их из нее на основе вычисления, интенсивно нагружающего процессор. Эта форма запроса может получить преимущество от параллельного выполнения при условии, что вычисления независимы друг от друга. Элементы в коллекции могут быть разбиты на несколько частей, точное число которых зависит от текущей нагрузки на компьютер и количества доступных центральных процессоров. Элементы в каждой части могут обрабатываться в отдельном потоке. После того как будут обработаны все части, результаты можно объединить. Таким образом можно управлять любой коллекцией, поддерживающей доступ к элементам посредством индекса, например массивом или коллекцией, реализующей интерфейс IList<T>.
Откройте в среде Visual Studio 2015 решение PLINQ, которое находится в папке \Microsoft Press\VCSBS\Chapter 24\PLINQ вашей папки документов.
Дважды щелкните в обозревателе решений на файле Program.cs проекта PLINQ, чтобы его код появился в окне редактора. Приложение, с которым ведется работа, консольное. Его основная структура уже создана. Класс Program содержит методы с именами Test1 и Test2, созданными, чтобы проиллюстрировать два наиболее распространенных сценария. Метод Main вызывает каждый из этих тестовых методов по очереди. Оба тестовых метода имеют одинаковую основную структуру: они создают LINQ-запрос (добавлять код к ним вы будете в наборе упражнений чуть позже), запускают его на выполнение и выводят на экран затраченное на это время. Код для каждого из этих методов практически полностью отделен от инструкций, создающих и запускающих запросы.
Давайте изучим метод Test1. Этот метод создает большой массив, состоящий из целых чисел, и заполняет его набором случайных чисел в диапазоне от 0 до 200. Для генератора случайных чисел применяется начальное число, поэтому при запуске приложения вы должны получать одни и те же результаты.
Добавьте к этому методу сразу же после первого комментария TO DO LINQ-запрос, показанный здесь жирным шрифтом:
// TO DO: Создать LINQ-запрос, извлекающий все числа больше 100
var over100 = from n in numbers
where TestIfTrue(n > 100)
select n;
Этот LINQ-запрос извлекает все элементы в массиве чисел со значением больше 100. Сам по себе тест n > 100 не может считаться содержащим интенсивное вычисление, достаточное для того, чтобы показать преимущества распараллеливания этого запроса, поэтому код вызывает метод по имени TestIfTrue, который создает небольшое замедление, выполняя операцию SpinWait. Метод SpinWait заставляет процессор за короткое время многократно выполнять цикл специальных инструкций «пустая команда», нагружая процессор, но не выполняя никакой работы (это действие называется пробуксовкой). Метод TestIfTrue выглядит следующим образом:
public static bool TestIfTrue(bool expr)
{
Thread.SpinWait(1000);
return expr;
}
Добавьте в метод Test1 после второго комментария TO DO код, показанный здесь жирным шрифтом:
// TO DO: Run the LINQ query, and save the results in a List<int> object
List<int> numbersOver100 = new List<int>(over100);
Следует напомнить, что LINQ-запросы используют отложенное выполнение, поэтому они не запускаются на выполнение, пока вы не станете извлекать из них результаты. Эта инструкция создает объект типа List<int> и заполняет его результатами выполнения запроса over100.
Добавьте в метод Test1 после третьего комментария TO DO инструкцию, показанную далее жирным шрифтом:
// TO DO: Display the results
Console.WriteLine($"There are {numbersOver100.Count} numbers over 100");
Щелкните в меню Отладка на пункте Запуск без отладки. Запишите время выполнения Test1 и количество элементов массива, значение которых превышает 100.
Запустите приложение несколько раз и вычислите среднее время. Убедитесь в том, что количество элементов со значением, превышающим 100, одинаково при каждом запуске (чтобы обеспечить повторяемость тестов, приложение использует при запуске одни и те же случайные числа). Когда закончите, вернитесь в среду Visual Studio.
Логика, выбирающая каждый элемент, возвращенный LINQ-запросом, независима от выборочной логики для всех остальных элементов, поэтому запрос является идеальным кандидатом на разделение. Внесите изменения, показанные здесь жирным шрифтом, в инструкцию, определяющую LINQ-запрос, и укажите метод расширения AsParallel для массива numbers:
var over100 = from n in numbers.AsParallel()
where TestIfTrue(n > 100)
select n;
ПРИМЕЧАНИЕ Если логика выбора или вычисления требует совместного использования данных, вы должны синхронизировать задачи, выполняемые в параллельном режиме, в противном случае результат может быть непредсказуем. Но синхронизация может привести к издержкам и свести на нет все преимущества от распараллеливания запроса.
В меню Отладка щелкните на пункте Запуск без отладки. Убедитесь в том, что количество элементов, попадающее в отчет Test1, такое же, как и прежде, а время, затрачиваемое на выполнение, существенно снизилось. Запустите тест несколько раз и выведите среднее значение продолжительности тестирования.
Если тест выполняется на двухъядерном процессоре (или на компьютере с двумя процессорами), вы должны заметить, что время сократилось на 40–45 %. Если у вас больше процессорных ядер, сокращение будет еще большим (на моей четырехъядерной машине время обработки уменьшилось с 10,3 до 2,8 с).
Закройте приложение и вернитесь в среду Visual Studio.
Предыдущее упражнение показало повышение производительности, которого можно достичь путем внесения небольших изменений в LINQ-запрос. Но следует иметь в виду, что подобные результаты можно увидеть только при условии, что вычисление, выполняемое запросом, занимает довольно длительное время. Я немного схитрил за счет пробуксовки процессора. Без этой издержки параллельная версия запроса будет выполняться медленнее последовательной. В следующем упражнении вы увидите LINQ-запрос, объединяющий два массива в памяти. На этот раз в упражнении используются более реалистичные объемы данных, поэтому в искусственном замедлении запроса нет необходимости.
В обозревателе решений щелкните на файле Data.cs, откройте его в окне редактора и найдите класс CustomersInMemory.
В этом классе содержится открытый строковый массив по имени Customers. Каждая строка в массиве Customers хранит данные об отдельном клиенте, а поля отделены друг от друга запятыми. Такой формат обычно применяется для данных, которые приложение может считать из текстового файла, использующего поля, разделенные запятыми. В первом поле содержится идентификатор клиента, во втором — название компании, которую он представляет, а в остальных полях — адрес, город, страна или регион и почтовый код.
Найдите класс OrdersInMemory. Этот класс похож на класс CustomersInMemory, за исключением того, что в нем содержится строковый массив Orders. В первом поле каждой строки содержится номер заказа, во втором — идентификатор клиента, в третьем — данные о размещении заказа.
Найдите класс OrderInfo. Он содержит четыре поля: идентификатор клиента, название компании, идентификатор заказа и дату заказа. Для заполнения коллекции объектов типа OrderInfo из данных, находящихся в массивах Customers и Orders, будет использоваться LINQ-запрос.
Выведите в окно редактора файл Program.cs и найдите в классе Program метод Test2. В этом методе будет создан LINQ-запрос, объединяющий массивы Customers и Orders путем использования идентификатора пользователя для возвращения списка клиентов и заказов, размещенных каждым клиентом. Запрос будет сохранять каждую строку результата в объекте типа OrderInfo.
Добавьте в try-блок этого метода после первого комментария TO DO код, показанный жирным шрифтом:
// TO DO: Create a LINQ query that retrieves customers and orders from arrays
// Store each row returned in an OrderInfo object
var orderInfoQuery = from c in CustomersInMemory.Customers
join o in OrdersInMemory.Orders
on c.Split(',')[0] equals o.Split(',')[1]
select new OrderInfo
{
CustomerID = c.Split(',')[0],
CompanyName = c.Split(',')[1],
OrderID = Convert.ToInt32(o.Split(',')[0]),
OrderDate = Convert.ToDateTime(o.Split(',')[2],
new CultureInfo("en-US"))
};
Добавленная инструкция определяет LINQ-запрос. Обратите внимание на то, что для разбиения каждой строки на массив строк в нем используется метод Split, принадлежащий классу String. Строки разбиваются по запятым, при этом запятые удаляются. Одно из осложнений заключается в том, что даты хранятся в формате United States English, поэтому код, помещая их в OrderInfo-объект, преобразует их в объекты типа DateTime, задавая формат United States English. Если вы пользуетесь указателем формата по умолчанию для вашей локализации, разбор дат может пройти неправильно. В конечном счете для создания каждой записи этот запрос выполняет существенный объем работы.
Добавьте в метод Test2 после второго комментария TO DO следующий код, выделенный жирным шрифтом:
// TO DO: Run the LINQ query, and save the results in a List<OrderInfo> object
List<OrderInfo> orderInfo = new List<OrderInfo>(orderInfoQuery);
Эти инструкции запускают запрос и заполняют коллекцию orderInfo.
Добавьте после третьего комментария TO DO инструкцию, показанную жирным шрифтом:
// TO DO: Display the results
Console.WriteLine($"There are {orderInfo.Count} orders");
Закомментируйте в методе Main инструкцию, вызывающую метод Test1, и удалите символы комментария из строки с инструкцией, вызывающей метод Test2, как показано далее жирным шрифтом:
static void Main(string[] args)
{
// Test1();
Test2();
}
В меню Отладка щелкните на пункте Запуск без отладки.
Убедитесь в том, что Test2 извлекает 830 заказов, и зафиксируйте продолжительность выполнения теста. Запустите приложение несколько раз для получения среднего значения, а затем вернитесь в среду Visual Studio.
Внесите в Test2 изменения, показанные далее жирным шрифтом, скорректировав LINQ-запрос и добавив метод расширения AsParallel к массивам Customers и Orders:
var orderInfoQuery = from c in CustomersInMemory.Customers.AsParallel()
join o in OrdersInMemory.Orders.AsParallel()
on c.Split(',')[0] equals o.Split(',')[1]
select new OrderInfo
{
CustomerID = c.Split(',')[0],
CompanyName = c.Split(',')[1],
OrderID = Convert.ToInt32(o.Split(',')[0]),
OrderDate = Convert.ToDateTime(o.Split(',')[2],
New CultureInfo("en-US"))
};
ВНИМАНИЕ При объединении двух источников данных таким способом оба они должны быть объектами типа IEnumerable или ParallelQuery. Это означает, что при указании метода AsParallel для одного источника вы также должны указать AsParallel и для другого. Если этого не сделать, ваш код не запустится — его выполнение будет прервано с выдачей ошибки.
Запустите приложение несколько раз. Обратите внимание на то, что на выполнение Test2 будет тратиться значительно меньше времени, чем раньше. Чтобы оптимизировать операции объединения путем извлечения данных для каждой объединяемой части в параллельном режиме, PLINQ может воспользоваться несколькими потоками.
Закройте приложение и вернитесь в среду Visual Studio.
Эти два простых упражнения показали вам эффективность применения метода расширения AsParallel и расширения PLINQ. Следует заметить, что PLINQ является развивающейся технологией, и весьма вероятно, что со временем ее внутренняя реализация изменится. Кроме того, объемы данных и вычислительной работы, выполняемой в запросе, также имеют отношение к эффективности использования PLINQ. Поэтому вы не должны рассматривать эти упражнения в качестве определения жестких правил, подлежащих неукоснительному соблюдению. Они просто иллюстрируют положение, согласно которому вы должны очень тщательно подходить к оценке возможного повышения производительности или получения других преимуществ использования PLINQ со своими собственными данными и в собственной вычислительной среде.
В отличие от обычных LINQ-запросов, PLINQ-запрос можно отменить. Для этого указывается CancellationToken-объект из CancellationTokenSource и используется метод расширения WithCancellation, принадлежащий классу ParallelQuery:
CancellationToken tok = ...;
...
var orderInfoQuery =
from c in CustomersInMemory.Customers.AsParallel().WithCancellation(tok)
join o in OrdersInMemory.Orders.AsParallel()
on ...
В запросе WithCancellation указывается однократно. Отмена применяется ко всем источникам запроса. Если объект типа CancellationTokenSource, используемый для создания CancellationToken, содержит отмену, запрос останавливается с выдачей исключения OperationCanceledException.
Технология PLINQ не может быть всегда наиболее подходящей технологией для вашего приложения. Если вы создаете задачи самостоятельно, то для правильной работы нужно обеспечить их скоординированность. Библиотека классов .NET Framework предоставляет методы, с помощью которых вы можете ожидать завершения задач, и эти методы можно использовать для координации выполнения задач на весьма примитивном уровне. Но представьте себе, что произойдет, если две задачи пытаются получить доступ к одним и тем же данным и внести в них изменения. Если обе задачи выполняются одновременно, их параллельно осуществляемые операции могут испортить данные. Эта ситуация может привести к возникновению ошибок, исправить которые будет очень трудно главным образом по причине их непредсказуемости.
Класс Task предоставляет эффективную структуру, с помощью которой можно проектировать и создавать приложения, пользующиеся преимуществами применения нескольких ядер центрального процессора для выполнения задач в параллельном режиме. Но при сборке решений, использующих одновременно выполняемые операции, нужно проявлять осмотрительность, особенно если эти операции реализуют совместный доступ к данным. Средств управления диспетчеризацией параллельно выполняемых операций или даже степенью распараллеливания, которую может предоставить операционная система приложению, спроектированному путем использования задач, у вас немного. Подобные решения остаются на усмотрение среды выполнения и зависят от рабочей нагрузки и возможностей оборудования компьютера, на котором выполняется приложение. Этот уровень абстракции был преднамеренным решением части команды разработчиков компании Microsoft: при создании приложений, требующих использования параллельных потоков, он избавляет вас от необходимости разбираться в низкоуровневом управлении потоками и в подробностях диспетчеризации. Но за эту абстрагированность приходится платить. Хотя все вроде бы работает волшебным образом само по себе, но вам все же нужно приложить усилия, чтобы разобраться в том, как работает код. В противном случае у вас, как показано в следующем примере кода (этот пример доступен в проекте ParallelTest в папке, содержащей код для главы 24), получатся приложения с непредсказуемым (и неправильным) поведением:
using System;
using System.Threading;
class Program
{
private const int NUMELEMENTS = 10;
static void Main(string[] args)
{
SerialTest();
}
static void SerialTest()
{
int[] data = new int[NUMELEMENTS];
int j = 0;
for (int i = 0; i < NUMELEMENTS; i++)
{
j = i;
doAdditionalProcessing();
data[i] = j;
doMoreAdditionalProcessing();
}
for (int i = 0; i < NUMELEMENTS; i++)
{
Console.WriteLine($"Element {i} has value {data[i]}");
}
}
static void doAdditionalProcessing()
{
Thread.Sleep(10);
}
static void doMoreAdditionalProcessing()
{
Thread.Sleep(10);
}
}
Метод SerialTest заполняет (довольно многословным способом) целочисленный массив набором значений, а затем выполняет последовательный перебор получившегося списка, выводя на экран индекс каждого элемента массива и значение соответствующего элемента. Методы doAdditionalProcessing и doMoreAdditionalProcessing просто имитируют выполнение довольно продолжительных операций в качестве части обработки, которая может заставить среду выполнения приступить к управлению распределением рабочей нагрузки процессора. Программа выводит следующие данные:
Element 0 has value 0
Element 1 has value 1
Element 2 has value 2
Element 3 has value 3
Element 4 has value 4
Element 5 has value 5
Element 6 has value 6
Element 7 has value 7
Element 8 has value 8
Element 9 has value 9
Теперь рассмотрим показанный далее метод ParallelTest. Этот метод аналогичен методу SerialTest, за исключением того, что для заполнения массива data с помощью запуска одновременно выполняемых задач в нем используется конструкция Parallel.For. Код в лямбда-выражении, запускаемом каждой задачей, идентичен коду в исходном цикле for в методе SerialTest:
using System.Threading.Tasks;
...
static void ParallelTest()
{
int[] data = new int[NUMELEMENTS];
int j = 0;
Parallel.For (0, NUMELEMENTS, (i) =>
{
j = i;
doAdditionalProcessing();
data[i] = j;
doMoreAdditionalProcessing();
});
for (int i = 0; i < NUMELEMENTS; i++)
{
Console.WriteLine($"Element {i} has value {data[i]}");
}
}
Предполагается, что метод ParallelTest будет выполнять ту же операцию, что и метод SerialTest, за исключением того, что за счет использования одновременно выполняемых задач и при удачном стечении обстоятельств делаться это будет немного быстрее. Проблема в том, что порой этот код может работать весьма неожиданным образом. Вот один из примеров вывода, созданного методом ParallelTest:
Element 0 has value 1
Element 1 has value 1
Element 2 has value 4
Element 3 has value 8
Element 4 has value 4
Element 5 has value 1
Element 6 has value 4
Element 7 has value 8
Element 8 has value 8
Element 9 has value 9
Значения, присвоенные каждому элементу массива data, не всегда совпадают со значениями, созданными при использовании метода SerialTest. Кроме того, при последующих выполнениях метода ParallelTest могут получаться разные наборы результатов.
Если исследовать логику конструкции Parallel.For, можно заметить то место, где кроется проблема. Лямбда-выражение содержит следующие инструкции:
j = i;
doAdditionalProcessing();
data[i] = j;
doMoreAdditionalProcessing();
Внешне вроде все нормально. Код копирует текущее значение переменной i (индексной переменной, показывающей, какая итерация цикла выполняется) в переменную j, а чуть позже он сохраняет значение переменной j в элементе массива data, индексируемом значением переменной i. Если i содержит 5, j присваивается значение 5, и чуть позже значение j сохраняется в элементе data[5]. Но между присваиванием значения переменной j и считыванием его обратно код проделывает дополнительную работу — вызывает метод doAdditionalProcessing. Если выполнение этого метода занимает много времени, среда выполнения может приостановить поток и спланировать выполнение другой задачи. При этом может запуститься параллельно выполняемая задача, осуществляющая другую итерацию конструкции Parallel.For, и присвоить переменной j новое значение. Вследствие этого, когда возобновится выполнение исходной задачи, значение j, которое она будет присваивать элементу data[5], уже не будет значением, которое было сохранено этим потоком, и в результате произойдет порча данных. Еще более тревожным моментом является то, что иногда этот код может выполняться в точном соответствии с вашими ожиданиями и давать правильные результаты, а иногда он может работать совершенно по-другому — все зависит от степени занятости компьютера и момента диспетчеризации выполнения различных задач. Поэтому ошибки такого рода могут не проявляться во время тестирования, а затем совершенно внезапно обнаруживаться в производственной среде.
Переменная j совместно используется всеми одновременно выполняемыми задачами. Если задача сохраняет значение в переменной j, а чуть позже считывает его обратно, она должна гарантировать, что никакая другая задача не изменила значение j за это время. Для этого требуется синхронизированный доступ к переменной, распространяемый на все одновременно выполняемые задачи, которые могут получить к ней доступ. Одним из способов получения синхронизированного доступа является блокировка данных.
Семантика блокировки, которой можно воспользоваться для обеспечения исключительного доступа к ресурсам, предоставляется в языке C# посредством ключевого слова lock. Ключевое слово lock можно использовать следующим образом:
object myLockObject = new object();
...
lock (myLockObject)
{
// Код, требующий исключительного доступа к общему ресурсу
...
}
Инструкция lock пытается получить взаимоисключающую блокировку указанного объекта (можно вообще-то использовать любой ссылочный тип, а не только объект), и ее выполнение блокируется, если тот же объект уже заблокирован другим потоком. Когда поток становится хозяином блокировки, выполняется код, находящийся в блоке, который следует за инструкцией lock. В конце этого блока блокировка снимается. Если другой поток заблокирован в ожидании получения блокировки, он может затем захватить блокировку и продолжить выполняться.
Применение ключевого слова lock является вполне подходящим средством для многих простых сценариев, но в некоторых ситуациях у вас могут быть более сложные требования. Пространство имен System.Threading включает несколько дополнительных примитивов синхронизации, которыми можно воспользоваться в подобных ситуациях. Эти примитивы синхронизации являются классами, разработанными для использования вместе с задачами. Они предоставляют механизмы блокировки, ограничивающие доступ к ресурсу на время удержания блокировки со стороны задачи. Они поддерживают различные технологии блокировки, которыми можно воспользоваться для реализации различных стилей одновременного доступа в диапазоне от простых исключающих блокировок, где одна задача имеет исключительный доступ к ресурсу, до семафоров, где несколько задач могут обращаться к ресурсу одновременно, но управляемым образом, и блокировок по чтению-записи, позволяющих различным задачам иметь совместный доступ по чтению к ресурсу наряду с гарантированным исключительным доступом для потока, которому необходимо внести в ресурс изменение.
Некоторые их этих примитивов приведены в следующем перечне. Дополнительные сведения и примеры можно найти в документации, предоставляемой средой Visual Studio 2015.
ПРИМЕЧАНИЕ Со времен своего первого выпуска среда .NET Framework включает в себя солидный набор примитивов синхронизации. В следующем перечне перечислены только самые поздние примитивы, включенные в пространство имен System.Threading. Между новыми и предшествующими примитивами существует некоторое функциональное перекрытие. Там, где оно имеет место, вы должны использовать более поздние альтернативные варианты, поскольку они были разработаны и оптимизированы под компьютеры с несколькими центральными процессорами.
Подробное рассмотрение теории, касающейся всевозможных механизмов синхронизации, доступных для создания многопоточных приложений, в круг вопросов, рассматриваемых в данной книге, не входит. Дополнительные сведения, касающиеся общей теории многопоточности и синхронизации, можно найти в теме «Синхронизация данных для многопоточности» в документации, предоставляемой вместе со средой Visual Studio 2015.
• ManualResetEventSlim. Класс ManualResetEventSlim предоставляет функциональные средства, с помощью которых ожидать событие может одна или несколько задач.
Объект типа ManualResetEventSlim может находиться в одном из двух состояний: сигнальном (true) и несигнальном (false). Задача создает ManualResetEventSlim-объект и указывает его исходное состояние. Другие задачи могут ожидать, пока ManualResetEventSlim-объект не станет сигнальным, вызывая для этого метод Wait. Если объект типа ManualResetEventSlim находится в несигнальном состоянии, метод Wait блокирует задачи. Другая задача может изменить состояние ManualResetEventSlim-объекта на сигнальное путем вызова метода Set. Это действие освобождает все задачи, ожидающие изменения состояния ManualResetEventSlim-объекта, позволяя им возобновить выполнение. Метод Reset изменяет состояние ManualResetEventSlim-объекта, возвращая его в несигнальное.
• SemaphoreSlim. Класс SemaphoreSlim можно использовать для управления доступом к пулу ресурсов.
Объект типа SemaphoreSlim имеет исходное значение (неотрицательное целое число) и необязательное максимальное значение. Обычно исходное значение SemaphoreSlim-объекта является количеством ресурсов в пуле. Задачи, обращающиеся к ресурсам в пуле, сначала вызывают метод Wait. Этот метод пытается уменьшить значение объекта типа SemaphoreSlim на единицу, и если результат получается ненулевым, потоку разрешается продолжить выполнение и взять ресурс из пула. Когда задача завершает работу, она должна вызвать в отношении SemaphoreSlim-объекта метод Release. Это действие повысит значение семафора на единицу.
Если задача вызывает метод Wait и в результате уменьшения значения SemaphoreSlim-объекта получается отрицательное значение, задача ждет, пока другая задача не вызовет метод Release.
Класс SemaphoreSlim также предоставляет свойство CurrentCount, которым можно воспользоваться для определения того, пройдет ли операция Wait успешно или ее выполнение выльется в блокировку.
• CountdownEvent. Класс CountdownEvent можно представлять как нечто среднее между обратным семафором и сбросом события вручную.
Когда задача создает CountdownEvent-объект, она указывает начальное значение (неотрицательное целое число). Одна или несколько задач могут вызывать метод Wait объекта типа CountdownEvent, и если его значение отличается от нуля, задачи блокируются. Метод Wait не уменьшает значение CountdownEvent-объекта на единицу, вместо этого, чтобы уменьшить значение, другие задачи могут вызвать метод Signal. Когда значение CountdownEvent-объекта достигает нуля, все заблокированные задачи получают сигнал и могут возобновить свое выполнение.
Задача может вернуть значение CountdownEvent-объекта к значению, указанному в его конструкторе, воспользовавшись для этого методом Reset, а еще задача может увеличить это значение, вызвав метод AddCount. Свойство CurrentCount позволяет определить, насколько высока вероятность быть заблокированным при вызове метода Wait.
• ReaderWriterLockSlim. Класс ReaderWriterLockSlim является самым современным примитивом синхронизации, поддерживающим одну записывающую задачу и множество читающих задач. Идея заключается в том, что изменение ресурса (запись в него) требует исключительного доступа, а чтение ресурса этого не требует — одновременный доступ к ресурсу могут получить сразу несколько читающих задач, но не в то время, когда его получила записывающая задача.
Задача, желающая прочитать ресурс, вызывает метод EnterReadLock, принадлежащий объекту типа ReaderWriterLockSlim. Это действие захватывает блокировку объекта по чтению. Когда задача завершает работу с ресурсом, она вызывает метод ExitReadLock, который снимает блокировку по чтению. Один и тот же ресурс могут читать сразу несколько задач, и каждая задача получает собственную блокировку по чтению.
Когда задача вносит в ресурс изменения, она может вызвать метод EnterWriteLock того же ReaderWriterLockSlim-объекта, чтобы получить блокировку по записи. Если у одной или нескольких задач в этот момент имеется блокировка по чтению относительно этого объекта, метод EnterWriteLock блокируется до тех пор, пока все эти блокировки не будут освобождены. После того как задача получит блокировку по записи, она сможет внести изменения в ресурс и вызвать метод ExitWriteLock, чтобы освободить блокировку.
У объекта типа ReaderWriterLockSlim имеется только одна блокировка по записи. Если другая задача попытается получить блокировку по записи, она будет заблокирована до тех пор, пока первая задача не освободит эту блокировку по записи.
Чтобы обеспечить невозможность бесконечной блокировки записывающих задач, как только задача запрашивает блокировку по записи, все последующие вызовы метода EnterReadLock, осуществляемые другими задачами, блокируются до тех пор, пока блокировка по записи не будет получена и освобождена.
• Barrier. Используя класс Barrier, можно временно остановить выполнение набора задач в конкретном месте приложения и возобновить его только тогда, когда все задачи достигнут этого места. Он пригодится для синхронизации задач, которым нужно выполнить последовательно, одна за другой, серию параллельных операций.
Когда задача создает объект типа Barrier, она указывает то количество задач в наборе, которое будет синхронизировано. Это значение можно представить себе в виде счетчика задач, обслуживаемого внутренними механизмами класса Barrier. Позже это значение может быть скорректировано путем вызова метода добавления участника — AddParticipant или метода удаления участника — RemoveParticipant. Когда задача достигает места синхронизации, она вызывает метод SignalAndWait, принадлежащий Barrier-объекту, который уменьшает значение счетчика потоков внутри Barrier-объекта. Если значение этого счетчика больше нуля, задача блокируется. Только когда значение счетчика достигнет нуля, все задачи, ожидающие продолжения выполнения из-за использования объекта типа Barrier, освобождаются и могут продолжить свое выполнение.
Класс Barrier предоставляет свойство ParticipantCount, показывающее количество синхронизируемых задач, и свойство ParticipantsRemaining, показывающее, сколько задач нуждаются в вызове метода SignalAndWait до поднятия барьера и возможности заблокированным задачам продолжить выполнение.
В конструкторе класса Barrier можно также указать делегата. Этот делегат может ссылаться на метод, запускаемый, когда барьера достигнут все задачи. В качестве параметра этому методу передается Barrier-объект. Пока этот метод не завершит работу, барьер поднят не будет и задачи не получат свободу.
Все классы, ManualResetEventSlim, SemaphoreSlim, CountdownEvent и Barrier, поддерживают отмену, следуя ее модели, рассмотренной в главе 23. Операции ожидания для каждого из этих классов могут получать необязательный параметр CancellationToken, извлекаемый из объекта, имеющего тип CancellationTokenSource. Если вызвать метод Cancel объекта типа CancellationTokenSource, каждая операция ожидания, ссылающаяся на признак отмены CancellationToken, созданный этим источником, прерывается с выдачей исключения OperationCanceledException (которое, возможно, будет заключено в исключение AggregateException в зависимости от контекста операции ожидания).
Как вызывается метод Wait объекта типа SemaphoreSlim и указывается признак отмены, показано в следующем коде. Если операция ожидания отменяется, выполняется обработчик, перехватывающий исключение OperationCanceledException.
CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();
CancellationToken cancellationToken = cancellationTokenSource.Token;
...
// Семафор, защищающий пул из трех ресурсов
SemaphoreSlim semaphoreSlim = new SemaphoreSlim(3);
...
// Ожидание на семафоре и перехват исключения OperationCanceledException,
// если другой поток вызывает Cancel в отношении cancellationTokenSource
try
{
semaphoreSlim.Wait(cancellationToken);
}
catch (OperationCanceledException e)
{
...
}
Ко многим многопоточным приложениям довольно часто предъявляется требование по сохранению данных в коллекции и их извлечению. Стандартные классы коллекций, предоставляемые средой .NET Framework, изначально не считаются безопасными, хотя вы можете воспользоваться примитивами синхронизации, рассмотренными в предыдущем разделе, в которые заключается код, добавляющий элементы в коллекцию, отправляющий в отношении нее запросы и извлекающий из нее элементы. Но этот процесс потенциально предрасположен к ошибке и не обладает широкими возможностями масштабирования, поэтому в библиотеку классов среды .NET Framework включены небольшие наборы классов коллекций и интерфейсов для безопасной работы в многопоточной вычислительной среде, которые находятся в пространстве имен System.Collections.Concurrent и разработаны специально для использования с задачами. Краткое общее описание основных типов этого пространства имен приводится в следующем перечне.
• ConcurrentBag<T>. Это класс-обобщение для хранения неупорядоченной коллекции элементов. Он включает методы для вставки (Add), удаления (TryTake) и исследования (TryPeek) элементов коллекции. Эти методы безопасны в многопоточной среде. Коллекция также является перечисляемой, допускающей последовательный перебор содержимого путем использования инструкции foreach.
• ConcurrentDictionary<TKey, TValue>. Этот класс реализует безопасную в многопоточной среде версию класса-обобщения коллекции Dictionary<TKey, TValue>, рассмотренного в главе 18 «Использование коллекций». Он предоставляет методы TryAdd, ContainsKey, TryGetValue, TryRemove и TryUpdate, которыми можно воспользоваться для добавления элементов в словарь, их запроса, удаления и изменения.
• ConcurrentQueue<T>. Этот класс предоставляет безопасную в многопоточной среде версию класса-обобщения Queue<T>, рассмотренного в главе 18. Он включает методы Enqueue, TryDequeue и TryPeek, которыми можно воспользоваться для добавления элементов в очередь, их удаления и запроса.
• ConcurrentStack<T>. Это безопасная в многопоточной среде реализация класса-обобщения Stack<T>, также рассмотренного в главе 18. Она предоставляет методы Push, TryPop и TryPeek, которыми можно воспользоваться для помещения элементов в стек, их извлечения из стека и запроса элементов, находящихся в стеке.
ПРИМЕЧАНИЕ Добавление в классы коллекций безопасности при работе в многопоточной среде требует дополнительных издержек при работе среды выполнения, поэтому эти классы работают не так быстро, как обычные классы коллекций. Вам следует помнить об этом при принятии решения о распараллеливании набора операций, требующих обращения к совместно используемой коллекции.
В следующей подборке упражнений вами будет реализовано приложение, вычисляющее число π путем использования геометрического приближения. Сначала будет выполнено вычисление в однопоточном режиме, а затем в код будут внесены изменения для вычисления с использованием одновременно выполняемых задач. В ходе выполнения упражнений будет выявлен ряд вопросов синхронизации данных, на которые следует обратить внимание и которые будут разрешены путем использования класса коллекции, безопасно работающего в многопоточной вычислительной среде, и блокировкой, обеспечивающей должную координацию действий, выполняемых в задачах.
Реализуемый вами алгоритм вычисляет число π на основе простых математических и статистических выборок. Если нарисовать окружность с радиусом r и квадрат со сторонами, соприкасающимися с окружностью, то длина сторон квадрата составит 2r (рис. 24.2).
Рис. 24.2
Площадь квадрата S можно вычислить следующим образом:
S = (2r)(2r)
или
S = 4rr.
А площадь круга C вычисляется так:
C = πrr.
После перегруппировки формул можно увидеть, что
rr = C/π
и
rr = S/4.
Объединив этих равенства, получим следующий результат:
S/4 = C/π.
Следовательно:
π = 4C/S.
Весь фокус заключается в том, чтобы определить отношение площади круга C к площади квадрата S. Именно здесь и пригодится статистическая выборка. Можно создать набор случайных точек внутри квадрата и подсчитать, сколько точек попадает и в круг. Если создать довольно большую случайную выборку, отношение точек, попадающих в круг, к точкам, попадающим в квадрат (а также в круг), даст приблизительное соотношение площадей двух фигур C/S. Останется лишь подсчитать эти точки.
Как определить, что точка находится внутри круга? Чтобы сделать решение более наглядным, нарисуйте на листке бумаги квадрат с центром в точке начала координат (0, 0). После этого можно сгенерировать пару значений, или координат, находящихся в диапазоне от (–r, —r) до (+r, +r). Затем можно определить, находится ли любой набор координат (x, y) внутри круга, применив для определения расстояния от начала координат теорему Пифагора. Расстояние d можно вычислить как квадратный корень из (xx + yy). Если d меньше радиуса окружности r или равно ему, значит, как показано на графике на рис. 24.3, координаты (x, y) указывают на точку внутри круга.
Ситуацию можно упростить еще больше, генерируя координаты, которые попадают только в верхний правый квадрант графика, тогда останется лишь генерировать пары случайных чисел между 0 и r. Именно этот подход и будет использоваться в упражнениях.
ПРИМЕЧАНИЕ Упражнения в этой главе предназначены для выполнения на компьютере с многоядерным процессором. Если у вас одноядерный центральный процессор, эффектов, аналогичных описываемым, вы не увидите. Кроме того, между выполнением упражнений не нужно запускать никаких дополнительных программ или служб, поскольку это может повлиять на наблюдаемые результаты.
Откройте в среде Visual Studio 2015 решение CalculatePI, которое находится в папке \Microsoft Press\VCSBS\Chapter 24\CalculatePI вашей папки документов.
Рис. 24.3
Дважды щелкните на файле Program.cs проекта CalculatePI, показанного в обозревателе решений, чтобы вывести его содержимое в окно редактора. Это консольное приложение. Его основная структура уже создана.
Прокрутите экран до конца файла и изучите метод Main, имеющий следующий вид:
static void Main(string[] args)
{
double pi = SerialPI();
Console.WriteLine($"Geometric approximation of PI calculated serially: {pi}");
Console.WriteLine();
// pi = ParallelPI();
// Console.WriteLine($"Geometric approximation of PI calculated in
// parallel: {pi}");
}
Этот код вызывает метод SerialPI, вычисляющий число π путем использования геометрического алгоритма, расмотренного перед упражнением. Значение возвращается в виде числа с двойной точностью и выводится на экран. Код, который в данный момент закомментирован, вызывает метод ParallelPI, осуществляющий то же самое вычисление, но с использованием одновременно выполняемых задач. Выводимый на экран результат должен быть таким же, как и результат, возвращаемый методом SerialPI.
Изучите метод SerialPI.
static double SerialPI()
{
List<double> pointsList = new List<double>();
Random random = new Random(SEED);
int numPointsInCircle = 0;
Stopwatch timer = new Stopwatch();
timer.Start();
try
{
// TO DO: Реализовать геметрическую аппроксимацию π
return 0;
}
finally
{
long milliseconds = timer.ElapsedMilliseconds;
Console.WriteLine($"SerialPI complete: Duration: {milliseconds} ms",);
Console.WriteLine(
$"Points in pointsList: {pointsList.Count}. Points within circle:
{numPointsInCircle}");
}
}
Этот метод генерирует большой общий набор координат и вычисляет расстояние каждого набора координат от начальной точки. Размер общего набора указан константой NUMPOINTS в начале класса Program. Чем больше это значение, тем объемнее получается общий набор координат и тем точнее выходит значение π, вычисленное этим методом. Если у вашего компьютера имеется довольно большой объем памяти, вы можете увеличить значение NUMPOINTS. А если приложение при запуске выдает исключение, связанное с недостатком памяти — OutOfMemoryException, значение этой константы нужно уменьшить.
Расстояния от начала координат до всех точек хранятся в коллекции pointsList, имеющей тип List<double>. Данные для координат генерируются путем использования переменной random. Она является Random-объектом, для создания которого используется постоянное начальное число, чтобы при каждом запуске программы генерировался один и тот же набор случайных чисел. (Это поможет определить, правильно ли выполняется программа.) Если нужно, чтобы генератор случайных чисел имел другое начальное значение, вам следует изменить значение константы SEED в начале класса Program.
Для подсчета количества точек в коллекции pointsList, находящихся в пределах круга, используется переменная numPointsInCircle. Радиус окружности указывается в начале класса Program с помощью константы RADIUS.
Чтобы легче было сравнивать производительность этого метода с производительностью метода ParallelPI, в коде создается Stopwatch-переменная по имени timer, и объект, ссылка на который в ней содержится, запускается на выполнение. В последнем блоке определяется время, затраченное на вычисление, и результат выводится на экран. По причинам, которые будут названы чуть позже, в последнем блоке на экран выводятся также количество элементов, хранящихся в коллекции pointsList, и количество точек, попадающих в круг.
Далее в несколько приемов в try-блок будет добавлен код, выполняющий вычисления.
Удалите из try-блока комментарий и инструкцию return, которая была предоставлена исключительно для обеспечения прохождения кодом компиляции. Добавьте в try-блок цикл for и инструкции, показанные в следующем примере кода жирным шрифтом:
try
{
for (int points = 0; points < NUMPOINTS; points++)
{
int xCoord = random.Next(RADIUS);
int yCoord = random.Next(RADIUS);
double distanceFromOrigin = Math.Sqrt(xCoord * xCoord + yCoord * yCoord);
pointsList.Add(distanceFromOrigin);
doAdditionalProcessing();
}
}
Добавленный блок кода создает пару значений координат в диапазоне от 0 до RADIUS и сохраняет ее в переменных xCoord и yCoord. Затем код использует теорему Пифагора, чтобы вычислить расстояние от начальной точки до этих координат и добавить результат к коллекции pointsList.
ПРИМЕЧАНИЕ Объем вычислительной работы в этом блоке кода невелик, а в настоящее приложение для научных расчетов будут, скорее всего, включены более сложные вычисления, загружающие процессор на более продолжительный период времени. Чтобы имитировать подобную ситуацию, в этом блоке кода вызывается еще один метод, doAdditionalProcessing. Как показано в следующем примере, он всего лишь отвлекает на себя некоторое количество циклов центрального процессора. Такой подход я выбрал с целью более яркой демонстрации требований, предъявляемых к синхронизации доступа к данным, не заставляя при этом вас для загрузки центрального процессора создавать приложение, выполняющее какое-либо весьма сложное вычисление, например быстрое преобразование Фурье:
private static void doAdditionalProcessing()
{
Thread.SpinWait(SPINWAITS);
}
SPINWAITS — это еще одна константа, определение которой находится в начале класса Program.
Добавьте к методу SerialPI в try-блок, который находится после блока for, инструкцию foreach, выделенную в следующем примере кода жирным шрифтом.
try
{
for (int points = 0; points < NUMPOINTS; points++)
{
...
}
foreach (double datum in pointsList)
{
if (datum <= RADIUS)
{
numPointsInCircle++;
}
}
}
Этот код выполняет последовательный обход элементов коллекции pointsList и по очереди исследует каждое значение. Если значение меньше или равно радиусу окружности, он увеличивает значение переменной numPointsInCircle на единицу. В конце этого цикла в переменной numPointsInCircle должно находиться общее количество координат, чье присутствие обнаружено внутри круга.
Добавьте к блоку try после инструкции foreach следующие инструкции, показанные жирным шрифтом:
try
{
for (int points = 0; points < NUMPOINTS; points++)
{
...
}
foreach (double datum in pointsList)
{
...
}
double pi = 4.0 * numPointsInCircle / NUMPOINTS;
return pi;
}
Первая инструкция вычисляет π на основании отношения числа точек, попавших в круг, к общему количеству точек с применением ранее рассмотренной формулы. Получившееся значение возвращается в качестве результата выполнения метода.
В меню Отладка щелкните на пункте Запуск без отладки.
Программа запустится и выведет приблизительное значение числа π (рис. 24.4). (На моем компьютере на это ушло около 49 с, поэтому наберитесь терпения.) Также будет выведено время, затраченное на вычисление.
Рис. 24.4
ПРИМЕЧАНИЕ Если вы не изменяли значений констант NUMPOINTS, RADIUS или SEED, то за исключением того времени, которое было затрачено на вычисления, вы должны получить точно такой же результат.
Закройте окно консоли и вернитесь в среду Visual Studio.
Несомненно, областью, подлежащей распараллеливанию в методе SerialPI, является код в цикле for, который генерирует точки и вычисляет их удаление от начала координат. Именно этим распараллеливанием вы и займетесь в следующем упражнении.
Выведите в окно редактора файл Program.cs, дважды щелкнув на его имени в обозревателе решений. Найдите метод ParallelPI. В нем содержится точно такой же код, который был в исходной версии метода SerialPI до добавления в блок try кода вычисления числа π.
Удалите из блока try комментарий и инструкцию return и добавьте к нему инструкцию Parallel.For, показанную жирным шрифтом:
try
{
Parallel.For (0, NUMPOINTS, (x) =>
{
int xCoord = random.Next(RADIUS);
int yCoord = random.Next(RADIUS);
double distanceFromOrigin = Math.Sqrt(xCoord * xCoord + yCoord * yCoord);
pointsList.Add(distanceFromOrigin);
doAdditionalProcessing();
});
}
Эта конструкция является параллельным аналогом кода, который находится в цикле for метода SerialPI. Тело исходного цикла for заключено в лямбда-выражение. Следует напомнить, что каждая итерация цикла выполняется с использованием задачи, а задачи могут запускаться в параллельном режиме. Степень распараллеливания зависит от количества ядер процессора и объема других ресурсов, доступного на вашем компьютере.
Добавьте к блоку try после инструкции Parallel.For следующий код, показанный жирным шрифтом. Этот код точно такой же, как и соответствующие ему инструкции в методе SerialPI:
try
{
Parallel.For (...
{
...
});
foreach (double datum in pointsList)
{
if (datum <= RADIUS)
{
numPointsInCircle++;
}
}
double pi = 4.0 * numPointsInCircle / NUMPOINTS;
return pi;
}
Снимите в методе Main, который находится ближе к концу файла Program.cs, комментарий с вызова метода ParallelPI и с инструкции Console.WriteLine, выводящей результаты на экран.
В меню Отладка щелкните на пункте Запуск без отладки.
Программа запустится, и результатом ее работы станет вывод на экран информации, показанной на рис. 24.5 (полученное вами время, затраченное на вычисления, может отличаться от показанного, лично я использовал для работы четырехъядерный процессор).
Рис. 24.5
Значение, вычисленное методом SerialPI, будет точно таким же, что и прежде, а вот результат работы метода ParallelPI выглядит как-то подозрительно. Генератор случайных чисел получил то же самое начальное число, которое использовалось в методе SerialPI, следовательно, он должен выдать ту же последовательность случайных чисел с тем же результатом и с тем же количеством точек, попадающих в круг. Странно также то, что коллекция pointsList в методе ParallelPI содержит меньше точек, чем та же самая коллекция в методе SerialPI.
ПРИМЕЧАНИЕ Если коллекция pointsList содержит ожидаемое количество элементов, запустите приложение еще раз. В большинстве запусков (но не обязательно во всех из них) окажется, что точек в коллекции меньше, чем ожидалось.
Закройте окно консоли и вернитесь в среду Visual Studio.
В чем же причина столь странной работы параллельного вычисления? Лучше всего приступить к ее выяснению, начав с количества элементов в коллекции pointsList. Эта коллекция генерируется объектом-обобщением List<double>, но тип этого объекта не предназначен для безопасной работы в многопоточной среде. Код в инструкции Parallel.For, предназначенный для добавления элемента в коллекцию, вызывает метод Add, но не следует забывать, что этот код выполняется задачами, запускаемыми в одновременно выполняемых потоках. Следовательно, применительно к количеству элементов, добавляемых к коллекции, высока вероятность того, что некоторые вызовы метода Add будут мешать друг другу и становиться причиной искажения данных. Решить проблему можно путем применения какой-нибудь коллекции из пространства имен System.Collections.Concurrent, поскольку эти коллекции обеспечивают безопасную работу в многопоточной среде. Наверное, наиболее подходящая для нашего примера коллекция в этом пространстве имен будет создаваться классом-обобщением ConcurrentBag<T>.
Выведите в окно редактора файл Program.cs, дважды щелкнув на его имени в обозревателе решений. Добавьте к началу файла следующую директиву using:
using System.Collections.Concurrent;
Найдите метод ParallelPI. Замените в начале этого метода инструкцию, создающую экземпляр коллекции List<double>, кодом, показанным в следующем примере жирным шрифтом, который создает коллекцию ConcurrentBag<double>:
static double ParallelPI()
{
ConcurrentBag<double> pointsList = new ConcurrentBag<double>();
Random random = ...;
...
}
Заметьте, что вы не можете указать для этого класса исходный объем коллекции, поэтому конструктор не получает никаких параметров. Весь остальной код данного метода в изменениях не нуждается, поскольку элемент к коллекции ConcurrentBag<T> добавляется с помощью метода Add, то есть используется тот же самый механизм, который использовался для добавления элемента к коллекции List<T>.
Щелкните в меню Отладка на пункте Запуск без отладки. Программа запустится и выведет приблизительное значение числа π с использованием методов SerialPI и ParallelPI. Информация, выводимая программой на экран, показана на рис. 24.6.
Рис. 24.6
На этот раз коллекция pointsList в методе ParallelPI содержит правильное количество точек, но количество точек, попадающих в круг, по-прежнему слишком велико, а оно должно быть таким же, как количество, показанное после работы метода SerialPI.
Следует также заметить, что время, затраченное методом ParallelPI, по сравнению с временем, показанным в предыдущем упражнении, увеличилось. Дело в том, что для безопасности данных при работе в многопоточной среде метод в классе ConcurrentBag<T> вынужден заниматься блокировкой и разблокированием доступа к этим данным, и к общим издержкам добавляются еще и вызовы соответствующих методов. Это обстоятельство следует учитывать, принимая решение о том, стоит ли распараллеливать операцию.
Закройте окно консоли и вернитесь в среду Visual Studio.
Теперь в коллекции pointsList хранится правильное количество точек, но под вопросом остается значение, записанное для каждой из них. Код в конструкции Parallel.For вызывает метод Next объекта типа Random, но так же, как и метод в классе-обобщении List<T>, этот метод не обеспечивает безопасную работу в многопоточной среде. К сожалению, версии класса Random для работы в параллельном режиме не существует, поэтому для обеспечения последовательного режима вызовов метода Next нужно обратиться к использованию альтернативной технологии. Поскольку каждый вызов этого метода занимает относительно немного времени, есть смысл для защиты его вызовов воспользоваться простой блокировкой.
Выведите в окно редактора файл Program.cs, дважды щелкнув в обозревателе решений на его имени. Найдите метод ParallelPI. Измените код в лямбда-выражении инструкции Parallel.For для защиты вызовов random.Next путем использования инструкции lock. Укажите в качестве предмета блокировки, как показано далее жирным шрифтом, коллекцию pointsList:
static double ParallelPI()
{
...
Parallel.For(0, NUMPOINTS, (x) =>
{
int xCoord;
int yCoord;
lock(pointsList)
{
xCoord = random.Next(RADIUS);
yCoord = random.Next(RADIUS);
}
double distanceFromOrigin = Math.Sqrt(xCoord * xCoord + yCoord * yCoord);
pointsList.Add(distanceFromOrigin);
doAdditionalProcessing();
});
...
}
Обратите внимание на то, что переменные xCoord и yCoord объявляются за пределами инструкции lock. Дело в том, что инструкция lock определяет собственное пространство имен и любая переменная, определенная внутри блока, указывающего пространство имен инструкции lock, исчезает, как только происходит выход из конструкции.
Щелкните в меню Отладка на пункте Запуск без отладки.
На этот раз значения π, вычисленные методами SerialPI и ParallelPI, совпадают. Единственная разница заключается в том, что метод ParallelPI выполняется гораздо быстрее (рис. 24.7).
Закройте окно консоли и вернитесь в среду Visual Studio.
Рис. 24.7
В этой главе было показано, как с помощью модификатора async и оператора await определяются асинхронные методы, работа которых основана на применении задач, используемых для выполнения обработки данных в асинхронном режиме, при этом оператор await указывает на те места, в которых задача может использоваться для выполнения асинхронной обработки.
Также вкратце было рассмотрено применение метода расширения AsParallel, позволяющего распараллелить некоторые LINQ-запросы. Применение технологии PLINQ является темой, заслуживающей особого внимания, поэтому в данной главе показаны только подступы к ней. Дополнительные сведения можно найти в теме «Parallel LINQ (PLINQ)» документации, предоставленой средой Visual Studio.
В этой главе было показано, как осуществляется синхронизация доступа к данным в одновременно выполняемых задачах путем использования примитивов синхронизации, предоставляемых для использования с задачами. Вы также увидели, как применяются классы коллекций, предназначенные для работы с данными в многопоточной вычислительной среде.
Если хотите продолжить работу и изучить следующую главу, оставьте открытой среду Visual Studio 2015 и переходите к главе 25 «Реализация пользовательского интерфейса для приложений универсальной платформы Windows».
Если сейчас вы хотите выйти из среды Visual Studio 2015, то в меню Файл щелкните на пункте Выход. Увидев диалоговое окно с предложением сохранить изменения, щелкните на кнопке Да и сохраните проект.
Чтобы | Сделайте следующее |
Реализовать асинхронный метод | Определите метод с помощью модификатора async и измените тип метода, чтобы он возвращал Task (или void). Используйте в теле метода оператор await, чтобы указать места, в которых может выполняться асинхронная обработка данных, например: private async Task<int> calculateValueAsync(...) { // Вызов calculateValue с помощью Task Task<int> generateResultTask = Task.Run(() => calculateValue(...)); await generateResultTask; return generateResultTask.Result; } |
Распараллелить LINQ-запрос | Укажите метод расширения AsParallel с источником данных в запросе, например: var over100 = from n in numbers.AsParallel() where ... select n; |
Позволить отмену в PLINQ-запросе | Воспользуйтесь в PLINQ-запросе методом WithCancellation класса ParallelQuery и укажите признак отмены, например: CancellationToken tok = ...; ... var orderInfoQuery = from c in CustomersInMemory.Customers.AsParallel(). WithCancellation(tok) join o in OrdersInMemory.Orders.AsParallel() on |
Синхронизировать одну или несколько задач для реализации безопасного в многопоточной среде исключительного доступа к совместно используемым данным | Для обеспечения исключительного доступа к данным воспользуйтесь инструкцией lock, например: object myLockObject = new object(); ... lock (myLockObject) { // Код, требующий исключительного доступа // к совместно используемому ресурсу ... } |
Синхронизировать потоки и заставить их ожидать наступления события | Для синхронизации неопределенного количества потоков воспользуйтесь объектом типа ManualResetEventSlim. Чтобы получить сигнал о наступлении события определенное количество раз, воспользуйтесь объектом типа CountdownEvent. Для координации конкретного количества потоков и их синхронизации в конкретном месте операции воспользуйтесь объектом типа Barrier |
Синхронизировать доступ к общему пулу ресурсов | Воспользуйтесь объектом типа SemaphoreSlim. Укажите в конструкторе количество элементов в пуле. Перед обращением к ресурсу в общем пуле вызовите метод Wait. Завершив использование ресурса, вызовите метод Release, например: SemaphoreSlim semaphore = new SemaphoreSlim(3); ... semaphore.Wait(); // Обращение к ресурсу из пула ... semaphore.Release(); |
Предоставить исключительный доступ к ресурсу по записи, но совместный доступ к нему по чтению | Воспользуйтесь объектом типа ReaderWriterLockSlim. Прежде чем выполнять чтение из совместно используемого ресурса, вызовите метод EnterReadLock. Завершив работу, вызовите метод ExitReadLock. Перед записью в общий ресурс вызовите метод EnterWriteLock. Завершив операцию записи, вызовите метод ExitWriteLock, например: ReaderWriterLockSlim readerWriterLock = new ReaderWriterLockSlim();
Task readerTask = Task.Factory.StartNew(() => { readerWriterLock.EnterReadLock(); // Чтение из совместно используемого ресурса readerWriterLock.ExitReadLock(); });
Task writerTask = Task.Factory.StartNew(() => { readerWriterLock.EnterWriteLock(); // Запись в совместно используемый ресурс readerWriterLock.ExitWriteLock(); }); |
Отменить операцию ожидания блокировки | Создайте признак отмены из объекта CancellationTokenSource и укажите этот признак в качестве параметра операции ожидания. Для отмены операции ожидания вызовите метод Cancel объекта типа CancellationTokenSource, например: CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); CancellationToken cancellationToken = cancellationTokenSource.Token; ... // Семафор, защищающий пул из трех ресурсов SemaphoreSlim semaphoreSlim = new SemaphoreSlim(3); ... // Ожидание на семафоре и выдача исключения // OperationCanceledException, если // еще один поток вызвал Cancel в отношении // cancellationTokenSource semaphore.Wait(cancellationToken); |