Прочитав эту главу, вы научитесь:
• объяснять преимущества реализации параллельных операций в приложении;
• использовать класс Task для создания и запуска параллельных операций в приложении;
• использовать класс Parallel для распараллеливания некоторых наиболее часто встречающихся в программировании конструкций;
• отменять долго выполняющиеся задачи и обрабатывать исключения, выдаваемые при выполнении параллельных операций.
В основной части предыдущих глав книги вы изучали использование C# для написания программ, выполняемых в однопоточном режиме. Под однопоточностью я подразумеваю то, что в любой отдельно взятый момент времени программа выполняет одну инструкцию. Но порой такой подход может быть не самым эффективным для приложения. При наличии соответствующих вычислительных ресурсов некоторые приложения могут работать значительно быстрее, если разбить их на части, которые могут выполняться параллельно в одно и то же время. Эта глава посвящена вопросам повышения производительности приложений путем интенсификации использования доступной вычислительной мощности. В частности, в этой главе вы узнаете о порядке использования Task-объектов с целью применения эффективной многозадачности для приложений, занимающихся интенсивными вычислениями.
Применение многозадачности в приложении может понадобиться по двум основным причинам.
• Для повышения оперативности реагирования. Продолжительные операции могут включать задачи, не требующие процессорного времени. К числу наиболее распространенных примеров относятся операции, связанные с вводом-выводом, например чтение с локального диска или запись на него, а также отправка и получение данных по сети. В обоих этих случаях не имеет смысла заставлять программу впустую тратить циклы работы центрального процессора, ожидая завершения операций, когда программа может делать вместо этого что-либо полезное, например реагировать на пользовательский ввод. Большинство пользователей мобильных устройств принимают эту форму реагирования как нечто само собой разумеющееся и не предполагают, что их планшетный компьютер просто замрет, отправляя или получая сообщение электронной почты. Более подробно эти особенности раскрыты в главе 24 «Сокращение времени отклика путем выполнения асинхронных операций».
• Для повышения масштабируемости. Если операция связана с использованием ресурсов центрального процессора, то масштабируемость можно повысить путем более эффективного использования доступных вычислительных ресурсов, задействуя их для сокращения времени, требующегося на выполнение операции. Разработчик может определить, какие операции включают задачи, которые могут быть выполнены в параллельном режиме, и принять меры к тому, чтобы они выполнялись одновременно. Чем больше будет добавляться вычислительных ресурсов, тем больше таких задач сможет выполняться в параллельном режиме. До относительно недавнего времени эта модель подходила только для научных и инженерных систем, у которых либо имелось несколько центральных процессоров, либо была возможность распределить обработку между различными компьютерами, связанными сетью. Но теперь большинство компьютерных устройств содержат мощные центральные процессоры, способные поддерживать реальную многозадачность, и многие операционные системы предоставляют элементарные процедуры, позволяющие относительно легко распараллеливать задачи.
На рубеже веков стоимость приличного персонального компьютера колебалась от 800 до 1500 долларов. Сегодня достойный внимания персональный компьютер, даже после 15 лет ценовой инфляции, стоит примерно столько же. Характеристика типового компьютера в наши дни включает процессор с тактовой частотой 2–3 ГГц, накопитель на жестком диске емкостью свыше 1000 Гбайт, оперативную память объемом 4–8 Гбайт, высокоскоростное графическое устройство с высоким разрешением, быстрые сетевые интерфейсы и DVD-привод с возможностью перезаписи дисков. Пятнадцать лет назад тактовая частота процессора типовой машины была между 500 МГц и 1 ГГц, емкость большого жесткого диска — 80 Гбайт, Windows вполне удовлетворялась оперативной памятью 256 Мбайт и даже меньше, а приводы перезаписываемых компакт-дисков стоили более 100 долларов. (Перезаписывающие DVD-приводы встречались крайне редко и стоили очень дорого.) Тем и вызывает восхищение технологический прогресс: все более быстрое и мощное оборудование продается по все более низким ценам.
Эта тенденция не нова. В 1965 году Гордон Мур, сооснователь компании Intel, опубликовал статью под названием «Cramming More Components onto Integrated Circuits» («Заполнение интегральных микросхем все большим количеством компонентов»), речь в которой шла о том, что рост миниатюризации компонентов, позволяющий размещать на кремниевой микросхеме больше транзисторов, и снижение стоимости производства с ростом доступности технологий приведут к тому, что к 1975 году станет вполне рентабельным уместить на одной микросхеме примерно 65 000 компонентов. Эти наблюдения позволили ему вывести часто упоминаемый закон Мура, основное утверждение которого гласит, что количество транзисторов, размещаемых на недорогой интегральной микросхеме, будет увеличиваться экспоненциально, удваиваясь примерно каждые два года. (Вообще-то Гордон Мур сначала давал более оптимистичный прогноз, утверждая, что объем транзисторов будет, скорее всего, увеличиваться вдвое каждый год, но позже скорректировал свои подсчеты.) Возможность совместного размещения транзисторов привела к возможности более быстрой передачи данных между ними. Это означает, что мы вправе ожидать, что производители микросхем станут выпускать более быстрые и мощные микропроцессоры практически неизменными темпами, позволяя разработчикам программных средств создавать все более сложные программы, способные выполняться еще быстрее.
Закон Мура, касающийся миниатюризации электронных компонентов, все еще в силе, даже полстолетия спустя. Но в дело стала вмешиваться физика. Возникли ограничения, связанные с невозможностью передавать сигналы между транзисторами на отдельно взятой микросхеме еще быстрее, независимо от того, насколько они малы или плотно упакованы. Наиболее заметным результатом этого ограничения для разработчиков программных средств стало прекращение роста скорости работы процессоров. Десять лет назад быстрый процессор работал на частоте 3 ГГц. И сегодня быстрый процессор по-прежнему работает на частоте 3 ГГц.
Ограничение по скорости передачи процессорами данных между компонентами заставили компании по производству микросхем искать альтернативные механизмы повышения объемов работ, с которыми мог бы справиться процессор. В результате большинство современных процессоров имеют два и более процессорных ядер. В действительности производители микросхем поместили несколько процессоров на один и тот же кристалл и добавили логику, необходимую для их взаимосвязи и координации. Сейчас нередко можно встретить четырех- и восьмиядерные процессоры. Доступны и кристаллы с 16, 32 и 64 ядрами, а цена двух- и четырехъядерных процессоров существенно снизилась, и их присутствие вполне ожидаемо в ноутбуках, планшетных компьютерах и смартфонах. Поэтому, несмотря на прекращение роста тактовой частоты процессоров, сегодня можно рассчитывать на получение их большего количества на одном кристалле.
А что это означает для разработчиков приложений на C#?
До появления многоядерных процессоров ускорить работу приложения, выполняемого в одном потоке, можно было просто за счет его запуска на более быстром процессоре. С появлением многоядерных процессоров ситуация изменилась. Однопоточные приложения будут работать с одинаковой скоростью на одно-, двух- или четырехъядерных процессорах с одной и той же тактовой частотой. Разница лишь в том, что при условии выполнения приложения на двухъядерном процессоре одно из ядер будет находиться в простое, а на четырехъядерном процессоре в ожидании работы будут простаивать три ядра. Для рационального использования многоядерных процессоров нужно создавать такие приложения, которые получали бы преимущества от применения многозадачности.
Многозадачность представляет собой возможность одновременного совершения сразу нескольких действий. Это одно из тех понятий, которые легко поддавались описанию, но реализация которых до последнего времени вызывала существенные затруднения.
Согласно оптимальному сценарию приложение, запущенное на многоядерном процессоре, одновременно выполняет столько задач, сколько для него имеется доступных процессорных ядер, загружая задачами каждое из них. Но для реализации одновременного выполнения нужно решить множество вопросов, включая следующие.
• Как разбить приложение на набор одновременно выполняемых операций?
• Как подстроиться под набор операций, одновременно выполняемых на нескольких процессорах?
• Как обеспечить попытку одновременного выполнения такого количества операций, которое в точности соответствует количеству доступных процессоров?
• Как обнаружить заблокированные операции (например, ожидающие завершения ввода-вывода) и настроить процессор для запуска другой операции вместо того, чтобы находиться в простое?
• Как определить, когда именно завершится одна или несколько одновременно выполняемых операций?
Для разработчика приложений первый из этих вопросов относится к проектированию приложения. Все остальные зависят от программной инфраструктуры. Чтобы помочь в решении этих вопросов, компания Microsoft предоставляет класс Task и коллекцию связанных с ним типов, находящуюся в пространстве имен System.Threading.Tasks.
ВНИМАНИЕ Основным здесь является вопрос, касающийся проектирования приложения. Если приложение реализуется без прицела на многозадачность, то неважно, сколько процессорных ядер будет на него задействовано, оно будет работать ничуть не быстрее, чем на одноядерной машине.
Класс Task является абстракцией операции, выполняемой одновременно с другими операциями. Task-объект создается для запуска блока кода. Можно создать несколько экземпляров Task-объектов и, при условии достаточного количества доступных процессоров или процессорных ядер, запустить их в параллельном режиме выполнения.
ПРИМЕЧАНИЕ Впредь я буду использовать понятие «процессор» для обозначения либо одноядерного процессора, либо отдельно взятого ядра многоядерного процессора.
Внутри среды Windows Runtime (WinRT) реализация задач и их диспетчеризация для выполнения осуществляются с помощью объектов Thread и класса ThreadPool. Многопоточность и пулы потоков были доступны в .NET Framework, начиная с версии 1.0, и при создании обычных приложений для настольного компьютера класс Thread, находящийся в пространстве имен System.Threading, можно использовать в коде напрямую. Но для приложений универсальной платформы Windows класс Thread недоступен, а вместо него используется класс Task.
Класс Task предоставляет весьма эффективную абстракцию, позволяющую довольно легко разобраться в разнице между степенью распараллеливания в приложении (задачами) и блоками для параллельного выполнения (потоками). На однопроцессорном компьютере эти понятия обычно не различаются. Но на компьютере с несколькими процессорами или с многоядерным процессором они отличаются друг от друга. При проектировании программы, основанной непосредственно на потоках, может оказаться, что приложение не слишком хорошо поддается масштабированию: программа будет использовать то количество потоков, которое было создано явным образом, а операционная система станет заниматься диспетчеризацией только этого количества потоков. Если количество потоков существенно превышает количество доступных процессоров, это может привести к превышению уровня допустимой нагрузки и к плохому показателю времени отклика, а если количество потоков меньше количества процессоров, это может обусловить неэффективность использования оборудования и низкую производительность.
WinRT оптимизирует количество потоков, требующееся для реализации конкретных задач, и осуществляет их эффективную диспетчеризацию в соответствии с количеством доступных процессоров. Для распределения рабочей нагрузки между набором потоков, реализуемых за счет использования объекта типа ThreadPool, эта среда организует механизм постановки в очередь. Когда программа создает Task-объект, задача добавляется к глобальной очереди. Когда поток становится доступен, задача удаляется из глобальной очереди и выполняется этим потоком. Класс ThreadPool выполняет ряд оптимизаций, а для того чтобы обеспечить эффективность диспетчеризации потоков, использует алгоритм на основе перехвата работы (work-stealing algorithm).
ПРИМЕЧАНИЕ Класс ThreadPool был доступен и в предыдущих редакциях .NET Framework, но в .NET Framework 4.0 он был существенно усовершенствован для поддержки экземпляров класса Task.
Следует заметить, что количество потоков, созданных для обработки ваших задач, не равно количеству процессоров. В зависимости от характеристики рабочей нагрузки один или несколько процессоров должны быть заняты выполнением высокоприоритетной работы для других приложений и служб. Следовательно, оптимальное количество потоков для вашего приложения может быть меньше, чем количество процессоров в машине. Кроме того, один или несколько потоков в приложении могут находиться в состоянии ожидания завершения продолжительных операций обращения к памяти, ввода-вывода или обмена данными по сети, освобождая тем самым соответствующие процессоры от нагрузки. В таком случае оптимальное количество потоков может быть больше количества доступных процессоров. Для динамического определения идеального количества потоков для текущей рабочей нагрузки среда WinRT следует итеративной стратегии, известной как алгоритм восхождения на гору (hill-climbing algorithm).
Важно усвоить, что единственной вашей обязанностью при работе с кодом является деление приложения, или его разбиение на задачи, которые могут выполняться в параллельном режиме. Среда WinRT берет на себя обязанности по созданию соответствующего количества потоков на основе архитектуры процессора и рабочей нагрузки вашего компьютера, связывая задачи с этими потоками и подстраивая одно под другое с целью эффективного выполнения кода. Не страшно, если вы разделите свою работу на слишком много задач — WinRT попытается запустить ровно столько параллельных потоков, столько имеет смысл выполнять. Фактически вам рекомендуется разбивать свою работу на большее количество частей, поскольку это поможет гарантировать масштабирование приложения при его перемещении на компьютер, имеющий большее количество доступных процессоров.
Создать Task-объекты можно с помощью Task-конструктора. Это перегружаемый конструктор, но все его версии ждут от вас предоставления в качестве параметра Action-делегата. В главе 20 «Отделение логики приложения и обработка событий» было показано, что Action-делегат ссылается на метод, не возвращающий значение. Task-объект задействует этого делегата при диспетчеризации выполнения его кода. В следующем примере создается Task-объект, использующий делегата для запуска метода по имени doWork:
Task task = new Task(doWork);
...
private void doWork()
{
// Этот код выполняется задачей при запуске
...
}
СОВЕТ Изначально тип Action ссылается на метод, не получающий параметры. Другие переопределения конструктора Task получают параметр Action<object>, представляющий собой делегата, который ссылается на метод, получающий один параметр типа object. Используя эти переопределения, можно передавать данные методу, запускаемому задачей. Примером может послужить следующий код:
Action<object> action;
action = doWorkWithObject;
object parameterData = ...;
Task task = new Task(action, parameterData);
...
private void doWorkWithObject(object o)
{
...
}
После создания объекта типа Task его можно запустить на выполнение, используя метод Start:
Task task = new Task(...);
task.Start();
Метод Start перегружается, позволяя дополнительно указать объект типа TaskCreationOptions для предоставления сведений о том, как планировать и запускать задачу.
ПРИМЕЧАНИЕ За дополнительными сведениями о перечислении TaskCreationOptions обращайтесь к поставляемой с Visual Studio документации с описанием библиотеки классов.NET Framework.
Создание и запуск задачи — весьма распространенный процесс, и класс Task предоставляет статический метод Run, с помощью которого можно эти операции объединить. Метод Run получает Action-делегата, указывающего на выполняемую операцию (подобно Task-конструктору), но тут же запускает выполнение задачи. Он возвращает ссылку на Task-объект. Этим можно воспользоваться следующим образом:
Task task = Task.Run(() => doWork());
Когда запущенный задачей метод завершит свою работу, задача завершится и поток, использовавшийся для запуска задачи, может быть использован повторно для выполнения другой задачи.
Когда задача завершается, можно подготовиться к другой задаче, запланированной на немедленное выполнение, создав продолжение. Для этого нужно вызвать принадлежащий Task-объекту метод ContinueWith. Когда действие, выполняемое Task-объектом, завершается, диспетчер автоматически создает новый Task-объект для запуска действия, указанного методом ContinueWith. Метод, указанный продолжением, ожидает Task-параметр, и диспетчер передает в метод ссылку на завершенную задачу. Значение, возвращенное методом ContinueWith, является ссылкой на новый Task-объект. В следующем примере кода создается Task-объект, запускающий метод doWork и указывающий продолжение, запускающее метод doMoreWork в новой задаче, когда будет завершена первая задача:
Task task = new Task(doWork);
task.Start();
Task newTask = task.ContinueWith(doMoreWork);
...
private void doWork()
{
// Этот код задача выполняет при запуске
...
}
...
private void doMoreWork(Task task)
{
// Этот код продолжение выполняет по завершении работы метода doWork
...
}
Метод ContinueWith активно перегружается, и вы, включая значение TaskContinuationOptions, можете предоставить ряд параметров, указывающих дополнительные элементы. Тип TaskContinuationOptions является перечислением, содержащим расширенный набор значений, содержащихся в перечислении типа TaskCreationOptions. К числу дополнительных доступных значений относятся:
• NotOnCanceled и OnlyOnCanceled. Необязательный параметр NotOnCanceled указывает, что продолжение должно запускаться, только если предыдущее действие завершилось и не было отменено, а необязательный параметр OnlyOnCanceled указывает, что продолжение должно запускаться, только если предыдущее действие было отменено. Отмена задачи рассматривается далее в разделе «Отмена задач и обработка исключений»;
• NotOnFaulted и OnlyOnFaulted. Необязательный параметр NotOnFaulted показывает, что продолжение должно быть запущено, только если предыдущее действие завершилось и не выдало необрабатываемое исключение. Необязательный параметр OnlyOnFaulted заставляет продолжение запускаться, только если предыдущее действие выдало необрабатываемое исключение. Дополнительные сведения по управлению исключениями в задаче даны в разделе «Отмена задач и обработка исключений»;
• NotOnRanToCompletion и OnlyOnRanToCompletion. Необязательный параметр NotOnRanToCompletion указывает, что продолжение должно запускаться, только если предыдущее действие не достигло успешного завершения (либо оно может быть отменено), или выдать исключение. Необязательный параметр OnlyOnRanToCompletion заставляет продолжение запуститься, только если предыдущее действие завершилось успешно.
В следующих примерах кода показано, как к задаче добавляется продолжение, запускаемое, только если исходное действие не выдало необрабатываемое исключение:
Task task = new Task(doWork);
task.ContinueWith(doMoreWork, TaskContinuationOptions.NotOnFaulted);
task.Start();
Общим требованием к приложениям, активизирующим операции в параллельном режиме, является синхронизация задач. Класс Task предоставляет метод Wait, реализующий простой механизм координации задач. Используя этот метод, можно приостановить выполнение текущего потока до завершения указанной задачи:
Task task2 = ...
task2.Start();
...
task2.Wait(); // Ожидание, пока не завершится task2
Можно ожидать завершения целого набора задач, воспользовавшись для этого статическими методами WaitAll и WaitAny класса Task. Оба метода получают массив params, содержащий набор Task-объектов. Метод WaitAll ждет завершения всех указанных задач, а метод WaitAny ждет завершения хотя бы одной из указанных задач. Воспользоваться ими можно следующим образом:
Task.WaitAll(task, task2); // Ожидание завершения обеих задач, task и task2
Task.WaitAny(task, task2); // Ожидание завершения любой из задач, либо task,
// либо task2
В следующем упражнении класс Task будет использоваться для распараллеливания кода приложения, интенсивно загружающего процессор, и для показа того, как распараллеливание сокращает время, затрачиваемое приложением на выполнение вычислений, путем их распределения среди нескольких ядер процессора.
Приложение с названием GraphDemo состоит из страницы, использующей элемент управления Image для вывода графики. Приложение выстраивает точки для графики, выполняя сложное вычисление.
ПРИМЕЧАНИЕ Упражнения в этой главе предназначены для запуска на компьютере c многоядерным процессором. Если у вас одноядерный процессор, вы не сможете наблюдать те же эффекты. Кроме того, не сможете запускать в ходе выполнения упражнений дополнительные программы или службы, потому что это может отразиться на наблюдаемых результатах.
Откройте в среде Microsoft Visual Studio 2015 решение GraphDemo, которое находится в папке \Microsoft Press\VCSBS\Chapter 23\GraphDemo вашей папки документов. Это приложение предназначено для работы на платформе UWP.
В обозревателе решений дважды щелкните на файле MainPage.xaml проекта GraphDemo для отображения формы в окне конструктора. Кроме элемента управления Grid, определяющего разметку, в форме имеются следующие важные элементы управления:
• элемент управления изображением Image с названием graphImage. Он выводит графику, воспроизводимую приложением;
• элемент управления Button (кнопка) с названием plotButton. Пользователи щелкают на этой кнопке для генерации данных для графики и ее вывода в элемент управления graphImage;
ПРИМЕЧАНИЕ Чтобы не усложнять операции этого приложения, оно выводит кнопку на страницу. В коммерческих UWP-приложениях кнопки такого рода должны располагаться на панели управления.
• элемент управления TextBlock с названием duration. В этой надписи приложение показывает время, затраченное на генерацию данных и вывод на экран графики, построенной по этим данным.
В обозревателе решений раскройте файл MainPage.xaml, а затем дважды щелкните на файле MainPage.xaml.cs, чтобы код формы был выведен в окно редактора.
Для вывода графики в форме используется объект типа WriteableBitmap (он определен в пространстве имен Windows.UI.Xaml.Media.Imaging) по имени graphBitmap. Горизонтальное и вертикальное разрешение для WriteableBitmap-объекта указывается в переменных pixelWidth и pixelHeight соответственно:
public partial class MainPage : Window
{
// Если места недостаточно, уменьшите значения pixelWidth и pixelHeight
private int pixelWidth = 15000;
private int pixelHeight = 10000;
private WriteableBitmap graphBitmap = null;
...
}
ПРИМЕЧАНИЕ Это приложение было разработано и протестировано на настольном компьютере с 8 Гбайт памяти. Если на вашем компьютере доступен меньший объем памяти, может понадобиться уменьшить значение переменных pixelWidth и pixelHeight, в противном случае приложение может выдать исключение OutOfMemoryException. Если же у вас имеется больший объем доступной памяти, то для полноценного наблюдения за эффектом, получаемым в этом упражнении, может потребоваться увеличить значения переменных.
Изучите последние три строки кода конструктора MainPage:
public MainPage()
{
...
int dataSize = bytesPerPixel * pixelWidth * pixelHeight;
data = new byte[dataSize];
graphBitmap = new WriteableBitmap(pixelWidth, pixelHeight);
}
В первых двух строках создается экземпляр байтового массива, в котором будут содержаться данные для графики. Размер этого массива зависит от разрешения WriteableBitmap-объекта, определяемого полями pixelWidth и pixelHeight. Кроме того, этот размер должен увеличиваться на тот объем памяти, который потребуется для вывода на экран каждого пиксела: класс WriteableBitmap использует на каждый пиксел 4 байта, которые определяют красную, зеленую и синюю насыщенность в каждом пикселе и значение в нем альфа-смешения. (Альфа-смешение определяет степень прозрачности и яркости пиксела.)
Последняя инструкция создает WriteableBitmap-объект с указанным разрешением.
Изучите код метода 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);
Stopwatch watch = Stopwatch.StartNew();
generateGraphData(data);
duration.Text = $"Duration (ms): {watch.ElapsedMilliseconds}";
Stream pixelStream = graphBitmap.PixelBuffer.AsStream();
pixelStream.Seek(0, SeekOrigin.Begin);
pixelStream.Write(data, 0, data.Length);
graphBitmap.Invalidate();
graphImage.Source = graphBitmap;
}
Этот метод запускается, когда пользователь щелкает на кнопке plotButton.
Далее вы будете щелкать на этой кнопке несколько раз, что позволит увидеть, как при каждом создании приложением произвольного набора значений для насыщенности красного, зеленого и синего цвета выводимой на экран точки будет рисоваться новая версия графического изображения. (После каждого щелчка на этой кнопке графика будет другого цвета.)
Переменная watch является System.Diagnostics.Stopwatch-объектом. Тип StopWatch применяется для операций отсчета времени. Статический метод StartNew, относящийся к типу StopWatch, создает новый экземпляр объекта StopWatch и запускает его в работу. Время работы StopWatch-объекта можно получить, изучив свойство ElapsedMilliseconds.
Метод generateGraphData заполняет массив данными для графики, выводимой на экран с помощью объекта типа WriteableBitmap. Это метод будет изучен на следующем этапе выполнения упражнения.
По завершении работы метода generateGraphData затраченное на нее время (в миллисекундах) появится в элементе управления TextBox по имени duration.
Завершающий блок кода берет информацию, хранящуюся в массиве данных, и копирует ее в WriteableBitmap-объект для вывода графики на экран. Самый простой технологический прием предполагает создание потока данных в памяти, который может использоваться для наполнения свойства PixelBuffer объекта типа WriteableBitmap. Затем метод Write этого потока можно использовать для копирования содержимого массива данных в этот буфер. Метод Invalidate класса WriteableBitmap требует, чтобы операционная система перерисовала побитовое изображение с использованием информации, хранящейся в буфере. Свойство Source элемента управления Image указывает на данные, которые должны быть отображены этим элементом. Последняя инструкция устанавливает в качестве значения свойства Source объект типа WriteableBitmap.
Изучите код метода generateGraphData:
private void generateGraphData(byte[] data)
{
int a = pixelWidth / 2;
int b = a * a;
int c = pixelHeight / 2;
for (int x = 0; x < a; x ++)
{
int s = x * x;
double p = Math.Sqrt(b - s);
for (double i = -p; i < p; i += 3)
{
double r = Math.Sqrt(s + i * i) / a;
double q = (r - 1) * Math.Sin(24 * r);
double y = i / 3 + (q * c);
plotXY(data, (int)(-x + (pixelWidth / 2)), (int)(y +
(pixelHeight / 2)));
plotXY(data, (int)(x + (pixelWidth / 2)), (int)(y +
(pixelHeight / 2)));
}
}
}
Этот метод выполняет ряд вычислений для построения точек весьма сложного графического изображения. (Само по себе вычисление роли не играет, оно просто генерирует привлекательную графику.) По мере вычисления каждой точки он вызывает метод plotXY для установки в массиве данных байтов, соответствующих этой точке. Точки для графического изображения отражаются относительно оси x, следовательно, метод plotXY для каждого вычисления вызывается два раза: первый раз для положительного значения x-координаты, второй раз — для отрицательного.
Изучите метод plotXY:
private void plotXY(byte[] data, int x, int y)
{
int pixelIndex = (x + y * pixelWidth) * bytesPerPixel;
data[pixelIndex] = blueValue;
data[pixelIndex + 1] = greenValue;
data[pixelIndex + 2] = redValue;
data[pixelIndex + 3] = 0xBF;
}
Этот метод устанавливает значения байтов в массиве данных, соответствующих x- и y-координатам, передаваемых ему в качестве параметров. Каждая выводимая на экран точка соответствует пикселу, а каждый пиксел, как уже упоминалось, состоит из 4 байтов. Пикселы, оставшиеся неустановленными, отображаются в виде черных точек. Значение 0xBF для байта альфа-смешения показывает, что соответствующий пиксел должен быть выведен на экран с умеренной интенсивностью. Если это значение уменьшить, пиксел станет тусклее, а если значение будет установлено в 0xFF (максимальное значение для байта), пиксел отобразится с максимально большой интенсивностью.
Щелкните в меню Отладка на пункте Начать отладку, чтобы инициировать сборку и запуск приложения.
Когда появится окно Graph Demo, щелкните на кнопке Plot Graph (Построить изображение) и дождитесь результата. Здесь вам придется проявить терпение. Для создания и вывода на экран графического изображения приложению понадобится несколько секунд, и приложение не будет отвечать на запросы, пока это не произойдет. (Почему так происходит и как избежать подобного поведения, объясняется в главе 24.) Графическое изображение показано на рис. 23.1. Обратите внимание на значение под надписью Duration (ms). В данном случае для построения изображения понадобилось 4907 мс. Учтите, что сюда не было включено время, затраченное на сам вывод изображения на экран, что могло занять еще несколько секунд.
ПРИМЕЧАНИЕ Приложение было запущено на компьютере, имеющем 8 Гбайт памяти и четырехъядерный процессор, работающий с тактовой частотой 2,4 ГГц. Если вы пользуетесь более медленным или более быстрым процессором с другим количеством ядер или компьютером с более или менее емкой памятью, то ваши показатели времени могут быть другими.
Щелкните на кнопке Plot Graph еще раз и заметьте время, понадобившееся для перерисовки изображения. Повторите это действие несколько раз, чтобы получить среднее значение этого времени.
ПРИМЕЧАНИЕ Может оказаться, что от случая к случаю изображение появляется значительно позже (на его построение тратится более 30 с). Чаще всего такое случается, если вы приближаетесь к границе емкости памяти своего компьютера и Windows вынуждена организовывать постраничный обмен данными между памятью и диском. Если столкнетесь с подобным феноменом, не берите показанное время в расчет при вычислении среднего времени выполнения.
Рис. 23.1
Пускай приложение работает, а вы щелкните правой кнопкой мыши на пустом месте панели задач. Щелкните в появившемся контекстном меню на пункте Диспетчер задач. Щелкните в окне диспетчера задач на вкладке Производительность (Performance) и выведите на экран график использования центрального процессора. Если вкладка Производительность не видна, щелкните на пункте Подробнее (More Details). Щелкните правой кнопкой мыши на графике использования центрального процессора (CPU), укажите на пункт Изменить график (Change Graph To), а затем щелкните на пункте Общая загрузка (Overall Utilization). Тем самым вы заставите диспетчер задач показать на одном графике использование всех ядер процессора, запущенных на вашем компьютере. Настроенная таким образом на моем компьютере вкладка Производительность диспетчера задач показана на рис. 23.2.
Вернитесь в приложение Graph Demo и измените размер и местоположение окна приложения, а также окна диспетчера задач, чтобы они оба находились в поле зрения. Дождитесь выравнивания графика использования центрального процессора, а затем щелкните в окне Graph Demo на кнопке Plot Graph.
Рис. 23.2
Опять дождитесь выравнивания графика использования центрального процессора, а затем еще раз щелкните на кнопке Plot Graph.
Повторите последнее действие 16 раз, между щелчками дожидаясь выравнивания графика использования центрального процессора.
Понаблюдайте в окне диспетчера задач за использованием центрального процессора. Результаты, получаемые разными читателями, будут варьироваться, но на двухъядерном процессоре при создании изображения использование центрального процессора будет, скорее всего, на уровне 50–55 %. Как показано на рис. 23.3, на четырехъядерной машине использование процессора, вероятнее всего, окажется на уровне 25–30 %. Учтите, что на производительность могут влиять и другие факторы, например тип графической карты вашего компьютера:
Вернитесь в среду Visual Studio 2015 и остановите отладку.
Теперь у вас есть от чего отталкиваться при анализе времени, затрачиваемого приложением на выполнение вычислений. Но при рассмотрении использования центрального процессора, показанного диспетчером задач, становится ясно, что приложение задействует не все его доступные ресурсы. На двухъядерной машине будет задействовано чуть больше половины мощности центрального процессора, а на четырехъядерной машине — чуть больше четверти. Причина
Рис. 23.3
этого феномена в однопоточности приложения, а в приложении Windows один поток может предоставить работу только одному ядру многоядерного процессора. Чтобы распределить нагрузку на все доступные ядра, нужно разбить приложение на задачи и организовать выполнение каждой задачи в отдельном потоке, где каждый поток будет запускаться на другом ядре. Именно этим вы дальше и займетесь.
Приложение GraphDemo было специально разработано для создания в заранее известном месте (в методе generateGraphData) затора в работе центрального процессора. В реальном мире вы можете быть предупреждены, что в вашем приложении имеется нечто заставляющее его работать медленнее и не реагировать на действия пользователей, но вы можете не знать, где находится вредоносный код. В таком случае можно воспользоваться бесценной услугой обозревателя производительности и профилировщика среды Visual Studio.
Профилировщик может периодически замерять состояние выполнения приложения и собирать информацию о том, какая инструкция выполняется в данный момент. Чем чаще выполняется конкретная строка кода и чем больше времени занимает ее выполнение, тем чаще эта инструкция будет наблюдаться. Профилировщик использует эти данные для генерации профиля выполнения и выдачи отчета, содержащего сведения о проблемных местах вашего кода. Эти сведения могут пригодиться при определении областей, на которых требуется сфокусировать свои усилия по оптимизации. Вы пройдете через этот процесс, выполнив следующие дополнительные действия.
ПРИМЕЧАНИЕ Обозреватель производительности и профилировщик в среде Visual Studio 2015 Community Edition недоступны.
В меню Отладка среды Visual Studio укажите на пункт Профилировщик, затем на пункт Обозреватель производительности, после чего щелкните на пункте Новый сеанс производительности. В среде Visual Studio должно появиться окно обозревателя производительности (рис. 23.4).
Рис. 23.4
В обозревателе производительности щелкните правой кнопкой мыши на пункте Целевые объекты, а затем на пункте Добавить целевой проект. В качестве целевого проекта будет добавлено приложение GraphDemo.
В панели меню обозревателя производительности щелкните на пункте Действия, а затем на пункте Запустить профилирование. Начнется выполнение приложения GraphDemo.
Щелкните на кнопке Plot Graph и дождитесь создания графического изображения. Повторите этот процесс несколько раз, после чего закройте приложение GraphDemo.
Вернитесь в среду Visual Studio и подождите, пока профилировщик проанализирует собранные данные замеров и сгенерирует отчет, который должен иметь примерно следующий вид (рис. 23.5).
Рис. 23.5
В этом отчете показан график использования центрального процессора (он должен быть похож на тот, который вы видели ранее в диспетчере задач, с пиками, возникающими при каждом щелчке на кнопке Plot Graph) и Горячий путь для приложения. Этот путь идентифицирует ту последовательность в приложении, которая потребляет основные вычислительные мощности. В данном случае приложение тратит 98 % времени на метод plotButton_Click, 91,06 % — на выполнение метода generateGraphData и 35,93 % — на выполнение метода plotXY. Существенное количество времени (23,54 %) было потрачено также средой выполнения (coreclr.dll).
Обратите внимание на то, что вы можете увеличить конкретную область графика использования центрального процессора (щелчком кнопки мыши и перетаскиванием) и отфильтровать отчет, чтобы охватить только увеличенную часть выборочных данных.
В области Горячий путь щелкните на методе GraphDemo.MainPage.generateGraphData. Подробности, касающиеся метода, а также доля времени центрального процессора, потраченная на выполнение наиболее затратных инструкций, показываются в окне отчета (рис. 23.6).
В данном случае можно увидеть, что код в цикле for должен стать первоочередной целью для осуществления любых действий по оптимизации.
Рис. 23.6
Вернитесь в среду Visual Studio 2015 и выведите в окно редактора файл MainPage.xaml.cs. Изучите метод generateGraphData.
Этот метод предназначен для заполнения массива данных элементами. С помощью внешнего for-цикла, основанного на переменной управления циклом x и выделенного в следующем примере кода жирным шрифтом, он совершает сквозной обход элементов массива:
private void generateGraphData(byte[] data)
{
int a = pixelWidth / 2;
int b = a * a;
int c = pixelHeight / 2;
for (int x = 0; x < a; x ++)
{
int s = x * x;
double p = Math.Sqrt(b - s);
for (double i = -p; i < p; i += 3)
{
double r = Math.Sqrt(s + i * i) / a;
double q = (r - 1) * Math.Sin(24 * r);
double y = i / 3 + (q * c);
plotXY(data, (int)(-x + (pixelWidth / 2)), (int)(y +
(pixelHeight / 2)));
plotXY(data, (int)(x + (pixelWidth / 2)), (int)(y +
(pixelHeight / 2)));
}
}
}
Вычисление, производимое одной итерацией этого цикла, не зависит от вычислений, производимых другими итерациями. Поэтому есть смысл разбить работу, выполняемую этим циклом, и запустить разные итерации на отдельных процессорах.
Измените определение метода generateGraphData, чтобы обеспечить прием двух дополнительных int-параметров с именами partitionStart и partitionEnd, дополнив его кодом, показанным жирным шрифтом:
private void generateGraphData(byte[] data, int partitionStart,
int partitionEnd)
{
...
}
Измените в методе generateGraphData внешний цикл for для проведения итераций между значениями partitionStart и partitionEnd, применив код, выделенный здесь жирным шрифтом:
private void generateGraphData(byte[] data, int partitionStart,
int partitionEnd)
{
...
for (int x = partitionStart; x < partitionEnd; x++)
{
...
}
}
Добавьте к началу файла MainPage.xaml.cs в окне редактора следующую директиву using:
using System.Threading.Tasks;
Закомментируйте в методе plotButton_Click инструкцию, вызывающую метод generateGraphData, и добавьте к нему показанную жирным шрифтом инструкцию, создающую объект задачи типа Task и запускающую его на выполнение:
...
Stopwatch watch = Stopwatch.StartNew();
// generateGraphData(data);
Task first = Task.Run(() => generateGraphData(data, 0, pixelWidth / 4));
...
Задача запускает код, указанный лямбда-выражением. Значения параметров partitionStart и partitionEnd показывают, что Task-объект вычисляет данные для первой половины графического изображения. (Данные для всего графического изображения содержат точки, выстраиваемые для значений между 0 и pixelWidth / 2.)
Добавьте еще одну инструкцию, показанную жирным шрифтом, которая создает и запускает в другом потоке второй объект задачи типа Task:
...
Task first = Task.Run(() => generateGraphData(data, 0, pixelWidth / 4));
Task second = Task.Run(() => generateGraphData(data, pixelWidth / 4,
pixelWidth / 2));
...
Этот Task-объект вызывает метод generateGraphData и вычисляет данные для значений между pixelWidth / 4 и pixelWidth / 2.
Добавьте следующую показанную жирным шрифтом инструкцию, которая, прежде чем продолжить выполнение, будет ожидать завершения выполнения работы обоих Task-объектов:
Task second = Task.Run(() => generateGraphData(data, pixelWidth / 4,
pixelWidth / 2));
Task.WaitAll(first, second);
...
Щелкните в меню Отладка на пункте Начать отладку, инициируя сборку и запуск приложения. Настройте изображение на экране так, чтобы можно было увидеть окно диспетчера задач, показывающее данные об использовании центрального процессора.
Щелкните в окне Graph Demo на кнопке Plot Graph. Дождитесь выравнивания графика использования центрального процессора в окне диспетчера задач.
Повторите предыдущее действие еще несколько раз, дожидаясь выравнивания графика использования центрального процессора между щелчками. Заметьте продолжительность, записываемую при каждом щелчке на кнопке, а затем вычислите ее среднее значение.
Вы должны увидеть, что приложение выполняется намного быстрее, чем раньше. На моем компьютере время сократилось на 2858 мс, то есть примерно на 40 %.
В большинстве случаев время, необходимое для выполнения вычислений, будет делиться примерно пополам, но в приложении все еще имеются однопоточные элементы, такие как логика, выводящая на экран графическое изображение после того, как будут сгенерированы данные. Именно поэтому общее время все же больше половины времени, затрачиваемого предыдущей версией приложения.
Перейдите в окно диспетчера задач. Вы должны заметить, что приложение использует больше ядер центрального процессора. На моей четырехъядерной машине использование центрального процессора после каждого щелчка на кнопке Plot Graph взлетает примерно на 50 %. Дело в том, что каждое из двух заданий запускается на отдельном ядре, но оставшиеся два ядра остаются незанятыми. Если у вас двухъядерная машина, то, скорее всего, вы увидите, что при каждом построении графического изображения использование процессора будет ненадолго достигать 100 % (рис. 23.7).
Если у вас четырехъядерный компьютер, степень использования центрального процессора можно повысить, еще больше сократив время путем добавления еще двух заданий, то есть двух Task-объектов, и разбиения работы метода plotButton_Click на четыре части, для чего нужно воспользоваться кодом, выделенным здесь жирным шрифтом:
...
Task first = Task.Run(() => generateGraphData(data, 0, pixelWidth / 8));
Task second = Task.Run(() => generateGraphData(data, pixelWidth / 8,
pixelWidth / 4));
Task third = Task.Run(() => generateGraphData(data, pixelWidth / 4,
pixelWidth * 3 / 8));
Task fourth = Task.Run(() => generateGraphData(data, pixelWidth * 3 / 8,
pixelWidth / 2));
Task.WaitAll(first, second, third, fourth);
...
Рис. 23.7
Если у вашего процессора только два ядра, вы все равно можете попробовать пойти на эти изменения и должны заметить небольшой выигрыш по времени. Причина заключается в первую очередь в том, что алгоритм, используемый средой CLR, оптимизирует способ диспетчеризации потоков для каждой задачи.
Использование класса Task позволяет получить полный контроль над количеством задач, создаваемых вашим приложением. Но чтобы приспособиться к использованию Task-объектов, приходится вносить изменения в конструкцию приложения. Кроме того, нужно добавить код для синхронизации операций: приложение может выводить на экран графическое изображение только по завершении всех задач. В сложных приложениях синхронизация задач может стать весьма непростым процессом, в ходе которого весьма легко допустить ошибки.
Используя класс Parallel, вы можете распараллелить некоторые наиболее часто встречающиеся конструкции программирования, не меняя общую конструкцию самого приложения. Выполняя внутреннюю работу, класс Parallel создает собственный набор задач в виде Task-объектов и автоматически синхронизирует эти задачи по мере их завершения. Класс Parallel находится в пространстве имен System.Threading.Tasks и предоставляет небольшой набор статических методов, который может использоваться для обозначения того, что код по возможности следует выполнять в параллельном режиме. К этим методам относятся:
• Parallel.For. Этот метод можно использовать вместо имеющейся в C# инструкции for. Он определяет цикл, в котором итерации с помощью определения задач могут выполняться параллельно. Этот метод имеет множество перегружаемых вариантов, но главный принцип у всех них один и тот же: вы указываете начальное значение, конечное значение и ссылку на метод, получающий целочисленный параметр. Метод выполняется для каждого значения между стартовым значением и значением, предшествующим указанному конечному значению, а параметр загружается целым числом, указывающим текущее значение. Рассмотрим, к примеру, следующий простой цикл for, который последовательно выполняет каждую итерацию:
for (int x = 0; x < 100; x++)
{
// Выполнение циклической обработки
}
В зависимости от обработки, выполняемой в теле цикла, может появиться возможность заменить этот цикл конструкцией Parallel.For, способной выполнять итерации в параллельном режиме:
Parallel.For(0, 100, performLoopProcessing);
...
private void performLoopProcessing(int x)
{
// Выполнение циклической обработки
}
Используя перегрузки метода Parallel.For, можно предоставить локальные данные, закрытые для каждого потока, указав различные необязательные параметры для создания задач, запускаемых методом For, и создать ParallelLoopState-объект, которым можно воспользоваться для передачи информации о состоянии другим одновременно выполняемым итерациям цикла. (Использование объекта типа ParallelLoopState будет рассмотрено чуть позже.)
• Parallel.ForEach<T>. Этим методом можно воспользоваться вместо имеющейся в C# инструкции foreach. Как и метод For, метод ForEach определяет цикл, итерации в котором могут выполняться в параллельном режиме. Вы указываете коллекцию, реализующую интерфейс-обобщение IEnumerable<T>, и ссылку на метод, получающий один параметр типа T. Этот метод выполняется в отношении каждого элемента коллекции, и этот элемент передается методу в виде параметра. Доступны варианты перегрузки метода, с помощью которых можно предоставить закрытые локальные потоковые данные и указать необязательные параметры для создания задач, запускаемых методом ForEach.
• Parallel.Invoke. Этим методом можно воспользоваться для реализации в качестве параллельно выполняемых задач вызовов набора методов без параметров. Для этого с помощью делегата указывается список вызовов методов (или лямбда-выражений), не получающих параметры и не возвращающих значения. Каждый вызов метода может запускаться в отдельном потоке в любом порядке. Например, следующий код выполняет серию вызовов методов:
doWork();
doMoreWork();
doYetMoreWork();
Эти инструкции можно заменить следующим кодом, вызывающим эти методы с использованием серий задач:
Parallel.Invoke(
doWork,
doMoreWork,
doYetMoreWork
);
Следует иметь в виду, что класс Parallel определяет фактическую степень распараллеливания, подходящую для среды и рабочей нагрузки компьютера. Например, если метод Parallel.For используется для реализации цикла, выполняющего 1000 итераций, классу Parallel совершенно необязательно создавать 1000 одновременно выполняемых задач (если только у вас не исключительно мощный компьютер с тысячью ядер). Вместо этого класс Parallel создаст оптимальное количество задач, поддерживая разумный баланс между доступными ресурсами и требованиями загруженности процессоров. В одной задаче могут выполняться несколько итераций, и задачи координируются друг с другом для определения того, какие именно итерации будет выполнять каждая из них. Важным последствием этого является невозможность гарантировать соблюдение порядка выполнения итераций, поэтому вы должны быть уверены в отсутствии зависимостей между итерациями, в противном случае можно, как будет показано далее, получить весьма неожиданные результаты.
В следующем упражнении вы вернетесь к исходной версии приложения GraphDemo и воспользуетесь для одновременного выполнения операций классом Parallel.
Откройте в среде Visual Studio 2015 решение GraphDemo, которое находится в папке \Microsoft Press\VCSBS\Chapter 23\Parallel GraphDemo вашей папки документов. Это копия исходной версии приложения GraphDemo без использования задач.
Раскройте в проекте GraphDemo, показанном в обозревателе решений, узел MainPage.xaml и дважды щелкните на файле MainPage.xaml.cs, чтобы его код был выведен в окно редактора.
Добавьте к списку в начале файла следующую директиву using:
using System.Threading.Tasks;
Найдите метод generateGraphData. Он должен выглядеть следующим образом:
private void generateGraphData(byte[] data)
{
int a = pixelWidth / 2;
int b = a * a;
int c = pixelHeight / 2;
for (int x = 0; x < a; x++)
{
int s = x * x;
double p = Math.Sqrt(b - s);
for (double i = -p; i < p; i += 3)
{
double r = Math.Sqrt(s + i * i) / a;
double q = (r - 1) * Math.Sin(24 * r);
double y = i / 3 + (q * c);
plotXY(data, (int)(-x + (pixelWidth / 2)), (int)(y +
(pixelHeight / 2)));
plotXY(data, (int)(x + (pixelWidth / 2)), (int)(y +
(pixelHeight / 2)));
}
}
}
Первым кандидатом на распараллеливание является внешний цикл for, совершающий итерации под управлением целочисленной переменной x. Можно также присмотреться к внутреннему циклу, управляемому переменной i, но для распараллеливания этого цикла из-за типа, к которому принадлежит i, потребуется больше усилий. (Методы в классе Parallel предполагают, что управляющая переменная будет целочисленной.) Кроме того, при наличии вложенных циклов, таких как те, которые встречаются в этом коде, лучше сначала распараллеливать внешние циклы, а затем выполнять тестирование, чтобы определить, является ли производительность приложения достаточной. Если нет, следует пройти через вложенные циклы и распараллелить их в направлении от внешних к внутренним, тестируя производительность после изменения каждого из них. Обнаружится, что во многих случаях распараллеливание внешних циклов оказывает на производительность наиболее существенное влияние, в то время как эффект от изменения внутренних циклов становится все более скромными.
Вырежьте код из тела цикла for и создайте на основе этого кода новый закрытый void-метод по имени calculateData. Этот метод должен получать int-параметр по имени x и байтовый массив по имени data. Кроме этого, переместите инструкции, объявляющие локальные переменные a, b и c, из метода generateGraphData в начало метода calculateData. Метод generateGraphData после удаления кода и метод calculateData показаны в следующем примере (этот код пока что не нужно компилировать):
private void generateGraphData(byte[] data)
{
for (int x = 0; x < a; x++)
{
}
}
private void calculateData(int x, byte[] data)
{
int a = pixelWidth / 2;
int b = a * a;
int c = pixelHeight / 2;
int s = x * x;
double p = Math.Sqrt(b - s);
for (double i = -p; i < p; i += 3)
{
double r = Math.Sqrt(s + i * i) / a;
double q = (r - 1) * Math.Sin(24 * r);
double y = i / 3 + (q * c);
plotXY(data, (int)(-x + (pixelWidth / 2)), (int)(y + (pixelHeight / 2)));
plotXY(data, (int)(x + (pixelWidth / 2)), (int)(y + (pixelHeight / 2)));
}
}
Замените в методе generateGraphData цикл for следующей инструкцией, вызывающей статический метод Parallel.For:
private void generateGraphData(byte[] data)
{
Parallel.For(0, pixelWidth / 2, x => calculateData(x, data));
}
Этот код является вычисляемым параллельно эквивалентом исходного цикла for. Он перебирает значения от 0 до pixelWidth / 2 — 1 включительно. Каждый вызов выполняется с использованием задачи, а каждая задача может иметь более одной итерации. Метод Parallel.For завершает свою работу только тогда, когда работу завершат все созданные им задачи. Вспомним, что в качестве последнего параметра метод Parallel.For ожидает указания метода, получающего один целочисленный параметр. Он вызывает этот метод, передавая в качестве параметра текущий индекс цикла. В данном примере метод calculateData не соответствует требуемой сигнатуре, поскольку он получает два параметра: целочисленное значение и байтовый массив. Поэтому код использует лямбда-выражение, которое действует в качестве адаптера, вызывающего метод calculateData с подходящими аргументами.
В меню Отладка щелкните на пункте Начать отладку, чтобы инициировать сборку и запуск приложения.
В окне Graph Demo щелкните на кнопке Plot Graph. Когда в окне Graph Demo появится графическое изображение, запишите время, затраченное на его создание. Чтобы получить среднее значение, повторите это действие несколько раз.
Нетрудно будет заметить, что приложение выполняется практически так же быстро, как и его предыдущая версия, в которой использовались Task-объекты (и даже, может быть, быстрее, в зависимости от количества доступных центральных процессоров). Если изучить данные диспетчера задач, можно будет заметить, что пик использования центрального процессора приближается к 100 % независимо от того, какой компьютер используется, двух- или четырехъядерный (рис. 23.8).
Вернитесь в среду Visual Studio и остановите отладку.
Рис. 23.8
Следует знать, что несмотря на широкую представленность команды разработчиков .NET Framework в компании Microsoft и приложенные ею немалые усилия, класс Parallel не волшебный, его нельзя использовать без должной осмотрительности, просто ожидая, что ваше приложение вдруг заработает намного быстрее и выдаст те же результаты. Класс Parallel предназначен для распараллеливания требовательных к вычислительной мощности центрального процессора независимых областей кода.
Если выполняемый код не требует от центрального процессора больших вычислительных мощностей, распараллеливание может не привести к повышению производительности. В этом случае издержки на создание задачи, ее запуск в отдельном потоке и ожидание ее завершения будут, скорее всего, выше затрат на непосредственный запуск того или иного метода. Дополнительные издержки при каждом вызове метода могут исчисляться всего несколькими миллисекундами, но вы должны учитывать количество запусков метода. Если вызов метода находится во вложенном цикле и выполняется тысячи раз, то все эти небольшие издержки будут складываться. Главное правило для Parallel.Invoke заключается в том, что его нужно использовать, лишь когда это целесообразно. Метод Parallel.Invoke нужно оставить для операций, производящих интенсивные вычисления, в противном случае издержки на создание задач и управление ими могут фактически замедлить выполнение приложения.
Другое основное соображение для использования класса Parallel заключается в том, что параллельно выполняемые операции должны быть независимыми. Например, если попытаться воспользоваться методом Parallel.For для распараллеливания цикла, итерации в котором зависят друг от друга, результаты будут непредсказуемыми.
Чтобы понять, что я имею в виду, посмотрите на следующий код (этот пример можно найти в решении ParallelLoop, которое находится в папке \Microsoft Press\VCSBS\Chapter 23\ParallelLoop вашей папки документов):
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ParallelLoop
{
class Program
{
private static int accumulator = 0;
static void Main(string[] args)
{
for (int i = 0; i < 100; i++)
{
AddToAccumulator(i);
}
Console.WriteLine($"Accumulator is {accumulator}");
}
private static void AddToAccumulator(int data)
{
if ((accumulator % 2) == 0)
{
accumulator += data;
}
else
{
accumulator -= data;
}
}
}
}
Эта программа последовательно перебирает значения от 0 до 99 и поочередно для каждого значения вызывает метод AddToAccumulator. Этот метод исследует текущее значение переменной accumulator и, если оно является четным числом, складывает значение параметра со значением переменной accumulator, в противном случае вычитает значение параметра. Результат выводится на экран в конце программы. По окончании работы этой программы должно получиться 100.
Чтобы повысить степень распараллеливания в этом простом приложении, у вас может возникнуть соблазн заменить цикл for в методе Main вызовом метода Parallel.For:
static void Main(string[] args)
{
Parallel.For (0, 100, AddToAccumulator);
Console.WriteLine($"Accumulator is {accumulator}");
}
Но это не дает гарантий того, что задачи, созданные для выполнения различных вызовов метода AddToAccumulator, будут выполняться в какой-то определенной последовательности. (Код также не защищен от одновременной работы нескольких потоков, поскольку потоки, выполняющие задачи, могут пытаться обновлять значение переменной accumulator одновременно.) Значение, вычисляемое методом AddToAccumulator, зависит от обслуживаемой последовательности, поэтому результат внесенных изменений выразится в том, что теперь при каждом запуске приложение может выдавать разные значения. В данном наиболее простом случае вы можете фактически не увидеть никаких отличий вычисленного значения от предыдущих значений, поскольку метод AddToAccumulator выполняется очень быстро и среда .NET Framework может выбрать последовательный запуск каждого вызова путем использования одного и того же потока. Но если внести в метод AddToAccumulator следующее изменение (выделенное жирным шрифтом), вы станете получать разные результаты:
private static void AddToAccumulator(int data)
{
if ((accumulator % 2) == 0)
{
accumulator += data;
Thread.Sleep(10); // Ожидание в течение 10 мс
}
else
{
accumulator -= data;
}
}
Метод Thread.Sleep просто заставляет текущий поток находиться в состоянии ожидания в течение указанного времени. Это изменение имитирует поток, выполняющий дополнительную обработку, и влияет на способ диспетчеризации задач классом Parallel, который теперь запускает задачи в различных потоках, что выражается в разной последовательности их выполнения.
Основное правило использования методов Parallel.For и Parallel.ForEach заключается в том, что ими следует пользоваться только при условии независимости каждой итерации цикла и тщательного тестирования кода. Подобные соображения применяются и к методу Parallel.Invoke: использование этой конструкции для вызовов методов допустимо при условии их независимости друг от друга и при том, что приложение не зависит от их запуска в определенной последовательности.
Основным требованием к приложениям, выполняющим долговременные операции, является наличие возможности в случае необходимости остановить эти операции. Но вы не можете просто прервать задачу, поскольку данные вашего приложения могут остаться в неопределенном состоянии. Вместо этого класс Task реализует стратегию согласованной отмены, позволяющей задаче выбрать удобную точку остановки обработки, а также позволяет классу при необходимости произвести откат любой работы, выполненной до отмены.
Согласованная отмена основана на понятии признака отмены. Признак отмены является структурой, представляющей запрос на отмену выполнения одной или нескольких задач. Метод, выполняемый задачей, должен включать параметр System.Threading.CancellationToken. Приложение, которому требуется отменить задачу, устанавливает булево свойство IsCancellationRequested этого параметра в true. Метод, выполняемый в задаче, может запросить это свойство на различных этапах своего выполнения. Если на любом этапе это свойство установлено в true, он знает, что приложение запросило отмену задачи. Также метод знает, какую работу он проделал до сих пор, следовательно, он, если нужно, может выполнить откат любых изменений и только потом завершить работу. Как вариант, метод может просто проигнорировать запрос и продолжить выполнение.
СОВЕТ Признак отмены в задаче можно исследовать довольно часто, но частота проверок не должна негативно сказываться на производительности выполнения задачи. По возможности нужно нацелиться на проверку признака отмены не реже чем каждые 10 мс, но не чаще чем 1 мс.
Приложение получает CancellationToken путем создания объекта типа System.Threading.CancellationTokenSource и запроса свойства Token этого объекта. Затем приложение может передать этот CancellationToken-объект в качестве параметра любому методу, запускаемому задачами, создаваемыми и выполняемыми приложением. Если приложение нуждается в отмене задач, оно вызывает метод Cancel объекта типа CancellationTokenSource. Этот метод устанавливает значение свойства IsCancellationRequested объекта CancellationToken, передаваемое всем задачам.
В следующем примере кода показано, как создается признак отмены и как он используется для отмены задачи. Метод initiateTasks создает экземпляр переменной cancellationTokenSource и получает ссылку на CancellationToken-объект, доступный через эту переменную. Затем код создает и запускает задачу, выполняющую метод doWork. Чуть позже код вызывает метод Cancel, принадлежащий источнику признака отмены, который устанавливает признак отмены. Метод doWork запрашивает свойство IsCancellationRequested, принадлежащее признаку отмены. Если свойство установлено в true, метод завершает работу, в противном случае он продолжает выполнение.
public class MyApplication
{
...
// Метод, создающий задачу и управляющий ею
private void initiateTasks()
{
// Создание признака отмены и получение этого признака
CancellationTokenSource cancellationTokenSource = new
CancellationTokenSource();
CancellationToken cancellationToken = cancellationTokenSource.Token;
// Создание задачи и запуск ее на выполнение метода doWork
Task myTask = Task.Run(() => doWork(cancellationToken));
...
if (...)
{
// Отмена задачи
cancellationTokenSource.Cancel();
}
...
}
// Метод, запускаемый задачей
private void doWork(CancellationToken token)
{
...
// Если признак отмены установлен в true, завершение обработки
if (token.IsCancellationRequested)
{
// Уборка и завершение
...
return;
}
// Если задача не отменена, продолжение обычного выполнения
...
}
}
Вдобавок к предоставлению высокой степени контроля над отменой обработки этот подход масштабируется на любое количество задач: можно запустить несколько задач и передать каждой из них один и тот же CancellationToken-объект. Если вызвать в объекте типа CancellationTokenSource метод, каждая задача проверит установку для свойства IsCancellationRequested значения true и будет действовать соответствующим образом.
Воспользовавшись методом Register, можно также зарегистрировать метод обратного вызова (в форме Action-делегата) с признаком отмены. Когда приложение вызывает метод Cancel соответствующего объекта типа CancellationTokenSource, запускается этот метод обратного вызова. Но вы не можете указать конкретный момент выполнения этого метода — это может случиться до или после выполнения задачами их собственной обработки отмены или даже в ходе этого процесса:
...
cancellationToken,Register(doAdditionalWork);
...
private void doAdditionalWork()
{
// Выполнение дополнительной обработки отмены
}
В следующем упражнении вы добавите возможность отмены к приложению GraphDemo.
Откройте в среде Visual Studio 2015 решение GraphDemo, которое находится в папке \Microsoft Press\VCSBS\Chapter 23\GraphDemo With Cancellation вашей папки документов.
Это полная копия приложения GraphDemo из ранее показанного упражнения, в которой для повышения производительности вычислений используются задачи. Также пользовательский интерфейс включает кнопку cancelButton, которой можно воспользоваться для остановки задач, вычисляющих данные для графического изображения.
В проекте GraphDemo в обозревателе решений сделайте двойной щелчок на файле MainPage.xaml, чтобы вывести форму в окно конструктора. Обратите внимание на появление в левой панели формы кнопки Cancel (Отмена).
Откройте в окне редактора файл MainPage.xaml.cs. Найдите метод Button_Click. Этот метод запускается, когда пользователь щелкает на кнопке Cancel (Отмена). Код в нем пока отсутствует.
Добавьте к списку в начале файла следующую директиву using:
using System.Threading;
В этом пространстве имен размещаются типы, используемые для согласованной отмены.
Добавьте к классу MainPage поле типа CancellationTokenSource по имени tokenSource и, как показано далее жирным шрифтом, инициализируйте его значением null:
public sealed partial class MainPage : Page
{
...
private byte redValue, greenValue, blueValue;
private CancellationTokenSource tokenSource = null;
...
}
Найдите метод generateGraphData и добавьте к определению метода параметр типа CancellationToken под названием token, код которого выделен здесь жирным шрифтом:
private void generateGraphData(byte[] data, int partitionStart,
int partitionEnd, CancellationToken token)
{
...
}
Добавьте в начало внутреннего цикла for в методе generateGraphData следующий код, показанный жирным шрифтом, для проверки запроса отказа. Если отказ запрошен, выполняется возврат из метода, в противном случае продолжается вычисление значений и построение графического изображения:
private void generateGraphData(byte[] data, int partitionStart,
int partitionEnd, CancellationToken token)
{
int a = pixelWidth / 2;
int b = a * a;
int c = pixelHeight / 2;
for (int x = partitionStart; x < partitionEnd; x ++)
{
int s = x * x;
double p = Math.Sqrt(b - s);
for (double i = -p; i < p; i += 3)
{
if (token.IsCancellationRequested)
{
return;
}
double r = Math.Sqrt(s + i * i) / a;
double q = (r - 1) * Math.Sin(24 * r);
double y = i / 3 + (q * c);
plotXY(data, (int)(-x + (pixelWidth / 2)), (int)(y +
(pixelHeight / 2)));
plotXY(data, (int)(x + (pixelWidth / 2)), (int)(y +
(pixelHeight / 2)));
}
}
}
Добавьте в метод plotButton_Click следующие инструкции, выделенные жирным шрифтом, которые создают экземпляр переменной типа tokenSource и извлекают CancellationToken-объект в переменную по имени token:
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;
...
}
Измените инструкции, создающие и запускающие две задачи, и передайте методу generateGraphData в качестве последнего параметра переменную token:
...
Task first = Task.Run(() => generateGraphData(data, 0, pixelWidth / 4,
token));
Task second = Task.Run(() => generateGraphData(data, pixelWidth / 4,
pixelWidth / 2, token));
...
Отредактируйте определение метода plotButton_Click и добавьте модификатор async, показанный здесь жирным шрифтом:
private async void plotButton_Click(object sender, RoutedEventArgs e)
{
...
}
Закомментируйте в теле метода plotButton_Click инструкцию Task.WaitAll, которая ожидает завершения выполнения задач, и замените ее следующими инструкциями, выделенными жирным шрифтом, которые используют оператор await:
...
// Task.WaitAll(first, second);
await first;
await second;
duration.Text = string.Format(...);
...
Потребности в изменениях, произведенных на двух последних этапах, связаны с однопоточной природой пользовательского интерфейса Windows. В нормальных условиях, когда приступает к работе обработчик события для такого элемента пользовательского интерфейса, как кнопка, обработчики событий для других компонентов пользовательского интерфейса блокируются, пока не завершит свою работу первый обработчик (даже если в обработчике события используются задачи). В данном примере использование метода Task.WaitAll для ожидания завершения выполнения задач превратит Cancel в бесполезную кнопку, поскольку обработчик события для кнопки Cancel не будет запущен, пока не завершит свою работу обработчик для кнопки Plot Graph, и в этом случае в попытках отмены операции не будет никакого смысла. Фактически, как уже было упомянуто, когда происходит щелчок на кнопке Plot Graph, пользовательский интерфейс становится совершенно невосприимчивым до тех пор, пока графическое изображение не появится на экране и метод plotButton_Click не завершит свою работу.
Для того чтобы справиться с подобными ситуациями, был придуман оператор await. Этим оператором можно воспользоваться только внутри метода, помеченного ключевым словом async. Его целями являются освобождение текущего потока и ожидание завершения задачи в фоновом режиме. Как только задача завершится, управление вернется методу, который продолжит свое выполнение со следующей инструкции. В данном примере две инструкции await просто позволяют каждой из задач завершиться в фоновом режиме. После того как вторая задача завершится, метод продолжит свое выполнение, показывая время, затраченное на выполнение этих задач, в элементе управления типа TextBlock по имени duration. Применение await в отношении задачи, которая уже завершилась, ошибкой не считается, просто оператор await тут же передаст управление следующей инструкции.
ПРИМЕЧАНИЕ Более подробно модификатор async и оператор await рассматриваются в главе 24.
Найдите метод cancelButton_Click. Добавьте к этому методу код, показанный жирным шрифтом:
private void cancelButton_Click(object sender, RoutedEventArgs e)
{
if (tokenSource != null)
{
tokenSource.Cancel();
}
}
Этот код проверяет наличие экземпляра переменной tokenSource. Если он имеется, код вызывает в отношении этой переменной метод Cancel.
В меню Отладка щелкните на пункте Начать отладку, инициируя сборку и запуск приложения.
В окне GraphDemo щелкните на кнопке Plot Graph и убедитесь в появлении, как и прежде, графического изображения. Но теперь вы должны заметить, что на построение изображения затрачено немного больше времени, чем раньше. Причина кроется в дополнительной проверке, выполняемой методом generateGraphData.
Щелкните на кнопке Plot Graph еще раз, а затем сразу же щелкните на кнопке Cancel.
Если вы не промедлили и щелкнули на кнопке Cancel до того, как были сгенерированы данные для графического изображения, это действие повлечет за собой возвращение из методов, запущенных задачами. Данные до конца не сформировались, поэтому графическое изображение появится с пустотами (рис. 23.9). (Несмотря на пустоты, предыдущее графическое изображение будет по-прежнему отображаться на экране, а размеры этих пустот зависят от того, насколько быстро вы щелкнули на кнопке Cancel.)
Вернитесь в среду Visual Studio и остановите отладку.
Рис. 23.9
Изучив свойство Status объекта типа Task, можно определить, была задача завершена или же прервана. Свойство Status содержит значение из перечисления типа System.Threading.Tasks.TaskStatus. Некоторые значения состояния, с которыми вы можете столкнуться, рассмотрены в следующем списке (есть и другие значения).
• Created. Это начальное состояние задачи. Она была создана, но еще не спланирована на выполнение.
• WaitingToRun. Задача была спланирована на выполнение, но оно еще не началось.
• Running. Задача выполняется потоком.
• RanToCompletion. Задача успешно завершилась без каких-либо необработанных исключений.
• Canceled. Задача была прервана до того, как началось ее выполнение, или же она распознала отмену и завершилась без выдачи исключения.
• Faulted. Задача прекратила выполнение по причине возникновения исключения.
В следующем упражнении вы предпримете попытку создания отчета о состоянии каждой задачи, чтобы получить возможность узнать о ее завершении или отмене.
Отмена выполняемого в параллельном режиме цикла For или loop
Методы Parallel.For и Parallel.ForEach не предоставляют вам прямого доступа к созданным Task-объектам. Фактически вы даже не знаете, сколько задач запущено, — среда .NET Framework использует свои собственные эвристические алгоритмы для определения оптимального количества используемых задач на основе доступных ресурсов и текущей рабочей нагрузки компьютера.
Если нужно остановить работу метода Parallel.For или Parallel.ForEach досрочно, следует воспользоваться объектом типа ParallelLoopState. Метод, указанный в качестве тела цикла, должен включать дополнительный параметр ParallelLoopState. Класс Parallel создает ParallelLoopState-объект и передает его методу в качестве параметра. Класс Parallel использует этот объект для хранения информации о каждом вызове метода. Метод может вызвать метод Stop этого объекта, чтобы выставить признак отмены классом Parallel попыток выполнения любых итераций, кроме тех, которые уже были запущены и завершены. В следующем примере показан метод Parallel.For, вызывающий для каждой итерации метод doLoopWork. Метод doLoopWork исследует значение переменной итерации: если оно больше 600, метод вызывает метод Stop параметра ParallelLoopState. Это приводит к тому, что метод Parallel.For прекращает запуск последующих итераций цикла. (Уже запущенные итерации могут продолжиться до завершения.)
ПРИМЕЧАНИЕ Не забудьте, что итерации в цикле Parallel.For не запускаются в определенной последовательности. Следовательно, прекращение цикла при получении переменной итерации значения 600 не гарантирует, что предыдущие 599 итераций уже отработали. Более того, уже могут быть завершены некоторые итерации со значениями больше 600:
Parallel.For(0, 1000, doLoopWork);
...
private void doLoopWork(int i, ParallelLoopState p)
{
...
if (i > 600)
{
p.Stop();
}
}
Выведите в окно конструктора среды Visual Studio файл MainPage.xaml. Добавьте в XAML-панели для определения формы MainPage следующую разметку, выделенную жирным шрифтом, расположив ее до предпоследнего </Grid>-тега:
<Image x:Name="graphImage" Grid.Column="1" Stretch="Fill" />
</Grid>
<TextBlock x:Name="messages" Grid.Row="4" FontSize="18"
HorizontalAlignment="Left"/>
</Grid>
</Page>
Эта разметка добавляет к нижней части формы элемент управления типа TextBlock по имени messages.
Выведите в окно редактора файл MainPage.xaml.cs и найдите метод plotButton_Click. Добавьте к этому методу код, показанный ниже жирным шрифтом. Имеющиеся в нем инструкции создают строку, которая содержит состояние каждой задачи после завершения ее выполнения, а затем выведите эту строку в элементе управления messages типа TextBlock, который находится в нижней части формы.
private async void plotButton_Click(object sender, RoutedEventArgs e)
{
...
await first;
await second;
duration.Text = $"Duration (ms): {watch.ElapsedMilliseconds}";
string message = $"Status of tasks is {first.Status}, {second.Status}";
messages.Text = message;
...
}
В меню Отладка щелкните на пункте Начать отладку.
В окне GraphDemo щелкните на кнопке Plot Graph, но не щелкайте на кнопке Cancel. Убедитесь в том, что в выведенном сообщении о состоянии задач говорится (два раза), что их состояние соответствует выполнению до полного завершения — RanToCompletion.
Щелкните еще раз в окне GraphDemo на кнопке Plot Graph, а затем без промедления — на кнопке Cancel.
Как ни удивительно, но появившееся сообщение по-прежнему свидетельствует о состоянии каждой задачи RanToCompletion, даже при том что графическое изображение появляется с пустотами (рис. 23.10).
Дело в том, что, несмотря на отправку каждой задаче запроса на отмену путем использования признака отмены, методы, запущенные этими задачами, просто возвращают управление. Среда выполнения .NET Framework не знает, были ли задачи отменены или же им было позволено доработать до конца, и она просто игнорирует запросы на отмену.
Вернитесь в среду Visual Studio и остановите отладку.
Итак, как же тогда создать признак того, что задача была отменена и не получила возможности быть выполненной до конца? Ответ кроется в объекте типа CancellationToken, передаваемом в качестве параметра методу, запускаемому задачей. Класс CancellationToken предоставляет метод по имени Throw-
Рис. 23.10
IfCancellationRequested. Этот метод тестирует свойство IsCancellationRequested признака отмены: если оно имеет значение true, метод выдает исключение OperationCanceledException и прерывает выполнение метода, запущенного задачей.
Приложение, запустившее поток, должно быть подготовлено к перехвату и обработке этого исключения, но тогда возникает еще один вопрос. Если задача прерывается путем выдачи исключения, то фактически она возвращается к состоянию Faulted. Это касается даже такого исключения, как OperationCanceledException. Задача входит в состояние Canceled, только если она отменяется без выдачи исключения. Так как же задаче выдать исключение OperationCanceledException, чтобы оно не рассматривалось в качестве исключения?
На этот раз ответ кроется в самой задаче. Чтобы задача в управляемом режиме признала, что исключение OperationCanceledException стало результатом отмены, а не просто исключением, вызванным другими обстоятельствами, ей следует знать, что операция была на самом деле отменена. Сделать это можно, только если ей удастся исследовать признак отмены. Вы передаете этот признак в качестве параметра тому методу, который запускается задачей, но задача фактически не проверяет какой-либо из этих параметров. Вместо этого вы указываете признак отмены при создании и запуске задачи. Код, показанный в следующем примере, основан на приложении GraphDemo. Обратите внимание не только на передачу признака отмены методу generateGraphData (как и прежде), но и на передачу его в качестве параметра методу Run:
tokenSource = new CancellationTokenSource();
CancellationToken token = tokenSource.Token;
...
Task first = Task.Run(() => generateGraphData(data, 0, pixelWidth / 4, token),
token);
Теперь, как только метод, запущенный задачей, выдаст исключение OperationCanceledException, инфраструктура, стоящая за задачей, изучает значение переменной типа CancellationToken. Если это значение свидетельствует об отмене задачи, инфраструктура устанавливает состояние задачи в Canceled. Если для ожидания завершения задач используется оператор await, вам также понадобится быть готовыми к перехвату и обработке исключения OperationCanceledException. Именно этим вы и займетесь в следующем упражнении.
Вернитесь в среде Visual Studio в окно редактора, показывающее содержимое файла MainPage.xaml.cs. В методе plotButton_Click внесите изменения в инструкции, показанные далее жирным шрифтом, создающие и запускающие задачи и указывающие CancellationToken-объект в качестве второго параметра для метода Run (а также в качестве параметра для метода generateGraphData):
private async void plotButton_Click(object sender, RoutedEventArgs e)
{
...
tokenSource = new CancellationTokenSource();
CancellationToken token = tokenSource.Token;
...
Task first = Task.Run(() => generateGraphData(data, 0, pixelWidth / 4,
token), token);
Task second = Task.Run(() => generateGraphData(data, pixelWidth / 4,
pixelWidth / 2, token), token);
...
}
Заключите инструкции, создающие и запускающие задачи, ожидающие их выполнения и выводящие затраченное время, в блок try. Добавьте блок catch, обрабатывающий исключение OperationCanceledException. В этом обработчике исключений выведите в элементе управления типа TextBlock по имени duration причину выдачи исключения, отчет о котором находится в свойстве Message объекта исключения. Все вносимые изменения показаны далее жирным шрифтом:
private async void plotButton_Click(object sender, RoutedEventArgs e)
{
...
try
{
await first;
await second;
duration.Text = $"Duration (ms): {watch.ElapsedMilliseconds}";
}
catch (OperationCanceledException oce)
{
duration.Text = oce.Message;
}
string message = $"Status of tasks is {first.Status, {second.Status}";
...
}
Закомментируйте в методе generateGraphData инструкцию if, которая проверяет значение свойства IsCancellationRequested объекта типа CancellationToken, и добавьте инструкцию, выделенную далее жирным шрифтом, которая вызывает метод ThrowIfCancellationRequested:
private void generateGraphData(byte[] data, int partitionStart,
int partitionEnd, CancellationToken token)
{
...
for (int x = partitionStart; x < partitionEnd; x++);
{
...
for (double i = -p; i < p; i += 3)
{
//if (token.IsCancellationRequested)
//{
// return;
//}
token.ThrowIfCancellationRequested();
...
}
}
...
}
Укажите в меню Отладка на пункт Окна и щелкните после этого на пункте Параметры исключений. Снимите в окне параметров исключений флажок Common Language Runtime Exceptions. Щелкните правой кнопкой мыши на пункте Common Language Runtime Exceptions и убедитесь в том, что режим Continue When Unhandled in User Code (Продолжить после необработанной ошибки в коде пользователя) включен (рис. 23.11).
Рис. 23.11
Такая настройка необходима для того, чтобы отладчик среды Visual Studio не перехватывал исключение OperationCanceledException, которое будет выдано при запуске приложения в режиме отладки.
В меню Отладка щелкните на пункте Начать отладку.
В окне Graph Demo щелкните на кнопке Plot Graph, дождитесь появления графического изображения и убедитесь в том, что состояние обеих задач отображено как RanToCompletion и изображение создано.
Щелкните еще раз на кнопке Plot Graph и тут же щелкните на кнопке Cancel.
Если второй щелчок был сделан без промедления, состояние обеих задач будет отображено как Canceled, в элементе управления типа TextBox под названием duration должен быть выведен текст «The operation was canceled», а графическое изображение должно быть выведено на экран с пустотами. Если вы все же промедлили, попробуйте выполнить последнее действие еще раз (рис. 23.12).
Вернитесь в среду Visual Studio и остановите отладку.
Укажите в меню Отладка на пункт Окна и щелкните после этого на пункте Параметры исключений. В панели инструментов окна Параметры исключений щелкните на кнопке Восстановить для списка параметры по умолчанию, после чего щелкните на кнопке OK.
Рис. 23.12
Обработка исключений задач путем использования класса AggregateException
На основе изученного в книге материала уже стало ясно, что обработка исключений является весьма важным элементом любого коммерческого приложения. Те конструкции обработки исключений, которые вам уже попадались, весьма просты в использовании, и при их аккуратном применении перехватить исключения и определить ту часть кода, из которой они были выданы, не составит труда. Но когда вы начинаете разбивать работу на несколько одновременно выполняемых задач, отслеживание и обработка исключений усложняются. В предыдущем упражнении было показано, как можно перехватить исключение OperationCanceledException, выдаваемое при отмене задачи. Но существует множество других исключений, которые также могут быть выданы, и различные задачи могут также выдавать собственные исключения. Поэтому вам нужен способ для перехвата и обработки нескольких исключений, которые могут быть выданы одновременно.
Когда для ожидания завершения нескольких задач используется один из методов ожидания класса Task (экземпляр метода Wait или статические методы Task.WaitAll и Task.WaitAny), любые исключения, выдаваемые методами, запускаемыми этими задачами, собираются вместе в одно исключение, известное нам как исключение AggregateException. Это исключение ведет себя как оболочка для коллекции исключений. Каждое из исключений в коллекции может быть выдано различными задачами. В своем приложении вы можете перехватить исключение AggregateException, а затем выполнить перебор элементов этой коллекции и любую необходимую обработку. Вам в помощь класс AggregateException предоставляет метод Handle. Этот метод получает делегата Func<Exception, bool>, ссылающегося на метод, получающий в качестве параметра объект типа Exception и возвращающий булево значение. При вызове метода Handle для каждого исключения в коллекции в объекте типа AggregateException запускается метод, на который имеется ссылка. Этот запускаемый по ссылке метод может исследовать исключение и предпринять соответствующее действие. Если этот метод обрабатывает исключение, он должен вернуть значение true. Если нет, он должен вернуть false. Когда метод Handle завершает свою работу, любые необработанные исключения собираются вместе в новое выдаваемое исключение AggregateException. Затем это исключение может перехватить и обработать следующий внешний обработчик исключений.
В следующем фрагменте кода показан пример метода, который может быть зарегистрирован в качестве обработчика исключения AggregateException. Этот метод при обнаружении исключения DivideByZeroException просто выводит на экран сообщение о попытке деления на нуль «Division by zero occurred» или при выдаче исключения IndexOutOfRangeException выводит на экран сообщение о выходе за пределы индексации массива «Array index out of bounds». Любые другие исключения остаются необработанными.
private bool handleException(Exception e)
{
if (e is DivideByZeroException)
{
displayErrorMessage("Division by zero occurred");
return true;
}
if (e is IndexOutOfRangeException)
{
displayErrorMessage("Array index out of bounds");
return true;
}
return false;
}
Когда используются методы ожидания класса Task, можно перехватить исключение AggregateException и зарегистрировать метод handleException:
try
{
Task first = Task.Run(...);
Task second = Task.Run(...);
Task.WaitAll(first, second);
}
catch (AggregateException ae)
{
ae.Handle(handleException);
}
Если любая из задач выдает исключение DivideByZeroException или исключение IndexOutOfRangeException, метод handleException выведет на экран соответствующее сообщение и подтвердит, что исключение обработано. Любые другие исключения классифицируются как необработанные и распространяются из обработчика исключения AggregateException обычным способом.
Но есть одно осложнение, о котором нужно знать. При отмене задачи вы видели, что среда CLR выдает исключение OperationCanceledException, и отчет об этом исключении выводится в том случае, если для ожидания завершения задачи используется оператор await. Но если вы используете один из методов ожидания класса Task, это исключение трансформируется в исключение TaskCanceledException, и это тот самый тип исключения, к обработке которого нужно подготовиться в обработчике исключения AggregateException.
Если вам нужно выполнить дополнительную работу, когда задача была отменена или выдала необработанное исключение, запомните, что для этого можно воспользоваться методом ContinueWith с соответствующим TaskContinuationOptions-значением. Например, следующий код создает задачу, которая выполняет метод doWork. Если задача отменена, метод ContinueWith указывает на то, что должна быть создана одна задача, запускающая метод doCancellationWork. Этот метод может выполнить ряд простых регистрационных записей или навести порядок в использовании ресурсов. Если задача не отменена, продолжение не запускается.
Task task = new Task(doWork);
task.ContinueWith(doCancellationWork, TaskContinuationOptions.OnlyOnCanceled);
task.Start();
...
private void doWork()
{
// Задача выполняет этот код после запуска
...
}
...
private void doCancellationWork(Task task)
{
// Задача выполняет этот код, когда работа метода doWork завершается
...
}
Кроме того, определив значение TaskContinuationOptions.OnlyOnFaulted, можно указать продолжение, которое будет запущено при условии выдачи необработанного исключения исходным методом, запускаемым задачей.
В этой главе вы узнали о том, насколько важно создавать приложения, способные распространять свое выполнение на несколько процессоров и процессорных ядер. Вы увидели, как класс Task используется для выполнения операций в параллельном режиме и как синхронизируются одновременно выполняемые операции и задается ожидание их завершения. Вы научились использовать класс Parallel для распараллеливания кода в некоторых наиболее распространенных конструкциях программирования, а также увидели, когда распараллеливать код не имеет смысла. Вы использовали вместе задачи и потоки в графическом пользовательском интерфейсе с целью повышения отзывчивости приложения и скорости обработки данных и увидели, как задачи отменяются вполне понятным и контролируемым образом.
Если хотите продолжить работу и изучить следующую главу, оставьте открытой среду Visual Studio 2015 и переходите к главе 24.
Если сейчас вы хотите выйти из среды Visual Studio 2015, то в меню Файл щелкните на пункте Выход. Увидев диалоговое окно с предложением сохранить изменения, щелкните на кнопке Да и сохраните проект.
Чтобы | Сделайте следующее |
Создать задачу и запустить ее на выполнение | Чтобы одним действием создать и запустить задачу, воспользуйтесь статическим методом Run класса Task: Task task = Task.Run(() => doWork()); ... private void doWork() { // Задача выполняет этот код после запуска ... } Или создайте новый Task-объект, ссылающийся на выполняемый метод, и вызовите метод Start: Task task = new Task(doWork); task.Start(); |
Дождаться окончания выполнения задачи | Вызовите метод Wait объекта типа Task: Task task = ...; ... task.Wait(); Или воспользуйтесь оператором await (только в async-методе): await task; |
Дождаться окончания выполнения нескольких задач | Вызовите статический метод WaitAll класса Task и укажите задачи, окончания выполнения которых нужно дождаться: Task task1 = ...; Task task2 = ...; Task task3 = ...; Task task4 = ...; ... Task.WaitAll(task1, task2, task3, task4); |
Указать метод для запуска в новой задаче, когда задача завершит свое выполнение | Вызовите в отношении задачи метод ContinueWith и укажите метод, запускаемый в качестве продолжения: Task task = new Task(doWork); task.ContinueWith(doMoreWork, TaskContinuationOptions.NotOnFaulted); |
Выполнить итерации цикла и последовательности инструкций путем использования параллельно запускаемых задач | Для выполнения итераций цикла с использованием задач воспользуйтесь методами Parallel.For и Parallel.ForEach: Parallel.For(0, 100, performLoopProcessing); ... private void performLoopProcessing(int x) { // Выполнение циклической обработки } Для выполнения одновременного вызова методов путем использования отдельных задач воспользуйтесь методом Parallel.Invoke: Parallel.Invoke( doWork, doMoreWork, doYetMoreWork ); |
Обработать исключения, выдаваемые одной или несколькими задачами | Перехватите исключение AggregateException. Воспользуйтесь методом Handle для указания метода, способного обработать каждое исключение в объекте типа AggregateException. Если метод обработки исключения производит такую обработку, возвращайте значение true, а если нет, возвращайте значение false: try { Task task = Task.Run(...); task.Wait(); ... } catch (AggregateException ae) { ae.Handle(handleException); } |
| ... private bool handleException(Exception e) { if (e is TaskCanceledException) { ... return true; } else { return false; } } |
Разрешить отмену задачи | Реализуйте согласованную отмену путем создания CancellationTokenSource-объекта и использования CancellationToken-параметра в методе, запускаемом задачей. Вызовите в этом методе метод ThrowIfCancellationRequested, принадлежащий параметру типа CancellationToken, для выдачи исключения OperationCanceledException и прекращения выполнения задачи: private void generateGraphData(..., CancellationToken token) { ... token.ThrowIfCancellationRequested(); ... } |