не хватает одного параметра: static void Main(string[] args) |
Пул потоков и библиотека параллельных задач
Библиотека TPL. Обзор
Мы познакомились с основами работы с выполняемыми параллельно задачами, используя библиотечные объекты класса Task. Понимая детали, постараемся охватить всю картину в целом.
Пространство имен Threading.Tasks
Пространство имен Threading.Tasks содержит много классов, полезных при организации параллельных вычислений. Можно считать, что классы этого пространства группируются в два семейства. Центральным представителем первого семейства является класс Task, о котором мы уже многое знаем и котором еще будем говорить. Важным классом этого семейства является класс Task<TResult>, который описывает задачи, возвращающие результат. При работе с объектами класса Task нам пришлось затратить определенные усилия на получение результатов задачи, вводить иногда глобальные объекты. При работе с классом Task<TResult> эта работа существенно упрощается. Класс TaskFactory полезен, когда приходится работать с группами задач
Второе важное семейство составляет класс Parallel и сопровождающие его классы. Эти классы позволяют автоматическое распараллеливание благодаря расширению языка новыми параллельными конструкциями цикла и вызова. Этому семейству классов из-за его важности посвятим отдельную главу.
Класс Threading.Task
Нет возможности подробного знакомства со всеми классами, поддерживающими работу с задачами. Основы работы с задачами уже разобраны. На примерах мы познакомились с тем как создавать задачи, запускать их на выполнение, синхронизировать их работу. Этого достаточно для первоначальной организации параллельных вычислений в своем приложении. Сейчас же рассмотрим общую картину тех свойств и методов, которые экспонирует класс Task.
При создании объектов класса Task мы использовали конструктор, принимающий два параметра - метод и информацию, передаваемую методу. Но конструкторов класса, как правило, много. У класса Task их восемь и им можно передавать от одного до четырех параметров. Первым параметром всегда идет метод, связываемый с задачей, остальные параметры позволяют задать дополнительные параметры. Основной конструктор мы разобрали.
У класса есть десяток свойств. Группа свойств Is позволяет выяснить закончилась ли задача, по какой причине она закончилась - вследствие возникшей ошибки или снята по требованию родительской задачи. Статическое свойство Factory открывает доступ к фабричным методам. В частности важную роль играет метод Task.Factory.StartNew, который позволяет создать новую задачу и запустить на выполнение. Считается, что такой способ выигрывает по производительности в сравнении с подходом, применяемым в наших примерах, когда создание задачи и ее запуск разделены.
У класса есть несколько десятков методов. С основными методами Start и Wait мы познакомились. У каждого из этих методов есть ряд модификаций. Ждать завершения задачи можно не бесконечно долго, а лишь в течение заданного интервала времени, после чего предпринимать определенные действия. Большую группу составляют методы ContinueWith, позволяющие организовать цепочку исполняемых задач, когда по завершении одной задачи начинает выполняться преемник задачи. Более того, преемник задачи может в качестве входных данных использовать результаты работы предшественника.
Статические методы WaitAll и WaitAny в разных модификациях позволяют организовать ожидание для массива задач.
Конечно же, предусмотрена обработка исключительных ситуаций, возникающих при выполнении задач. Есть свойство Exception, свойство IsFaulted, есть классы, задающие соответствующие исключительные ситуации.
Сделанный обзор классов, входящих в пространство имен Threading.Tasks, также как обзор свойств и методов одного класса Task, далеко не полон. Но есть документация, к которой всегда можно обратиться.
Реализация параллельных алгоритмов Task объектами
Займемся теперь наиболее интересным делом. Давайте посмотрим насколько просто применить механизм параллельных задач к тем параллельным алгоритмам, рассмотренных в "Параллельные алгоритмы" и "Потоки и параллельные вычисления" главе. Какой эффект дает переход к параллельным задачам при реализации этих алгоритмов.
Вычисление интеграла и параллельные задачи
В "Потоки и параллельные вычисления" построен метод ParallelIntegralWithThreads, позволяющий распараллелить вычисление определенного интеграла за счет введения потоков. В этом методе мы явно работаем с потоками - объектами класса Threads. Трудно ли модифицировать этот метод и перейти от непосредственной работы с потоками к работе с задачами - объектами класса Task? Отвечая на этот вопрос, Холмс сказал бы: "Элементарно, Ватсон". Все изменения можно внести в течение одной минуты, по сути заменяя слово Thread на Task, а Join на Wait. Дело в том, что механизм связывания метода, исполняемого в потоке, и механизм связывания метода, исполняемого в задаче, остается один и тот же, потому и изменения минимальны. Приведу текст метода, осуществляющего параллельное вычисление интеграла с использованием объектов класса Task:
/// <summary> /// Параллельное вычисление интеграла /// несколькими задачами, каждой из которых /// передается объект класса IntegralOne /// </summary> /// <param name="a">параметр метода DefiniteIntegral</param> /// <param name="b">параметр метода DefiniteIntegral</param> /// <param name="f">параметр метода DefiniteIntegral</param> /// <param name="eps">параметр метода DefiniteIntegral</param> /// <param name="p">число задач</param> /// <returns>значение интеграла</returns> public double ParallelIntegralWithTasks(double a, double b, Integral_function f, double eps, int p) { Task[] tasks = new Task[p]; IntegralOne[] integrals = new IntegralOne[p]; double[] results = new double[p]; double dx = (b - a) / p; double result = 0; for (int i = 0; i < p; i++) { integrals[i] = new IntegralOne(a + i * dx, a + (i + 1) * dx, f, eps); tasks[i] = new Task(integrals[i].DefiniteIntegral); tasks[i].Start(); } for (int i = 0; i < p; i++) { tasks[i].Wait(); result += integrals[i].Result; } return result; }
Здесь создается массив задач, каждой задаче tasks[i] передается объект intergrals[i], вызывающий метод DefiniteIntegral. Вся информация, необходимая методу, содержится в полях вызывающего объекта. Результат вычислений заносится в поле result этого объекта.
Следует обратиться к "Потоки и параллельные вычисления" , если требуется посмотреть определение метода DefiniteIntegral или класса IntegralOne. Рекомендуется также сравнить коды двух методов: ParallelIntegralWithThreads и ParallelIntegralWithTasks.
Итак, отвечая на первый вопрос, можно сказать, что реализация параллельных алгоритмов с использованием механизма параллельных задач достаточна проста. По крайней мере, она не сложнее, чем использование для этих целей объектов класса Thread.
Осталось ответить на второй вопрос, - эффективнее ли этот подход в сравнении с использованием потоков - объектов класса Thread. Приведу результаты экспериментов, где будут сравниваться четыре алгоритма - строго последовательный, потенциально параллельный алгоритм, но без реального распараллеливания, реализация параллельного алгоритма, основанная на использовании потоков, реализация параллельного алгоритма, основанная на использовании задач. Результаты вычислений приведены в следующей таблице:
Число задач | Последовательный алгоритм | Параллельный алгоритм | Параллельный алгоритм с потоками | Параллельный алгоритм с задачами |
---|---|---|---|---|
1 | 5 730 328 | 5 730 330 | 5 830 334 | 5 940 340 |
2 | - | 8 630 494 | 5 970 342 | 6 030 345 |
4 | - | 7 470 428 | 3 160 181 | 3 070 176 |
8 | - | 3 780 216 | 1 360 078 | 920053 |
16 | - | 1 880 107 | 580 034 | 430 024 |
32 | - | 980 056 | 470 027 | 220 013 |
64 | - | 490 028 | 660 038 | 140008 |
128 | - | 250 015 | 1 250 071 | 80 005 |
256 | - | 130 008 | 2 350 134 | 60 004 |
512 | - | 60 003 | 4 720 230 | 60 003 |
Анализ этой таблицы показывает, что алгоритм с задачами показывает лучшие результаты, чем алгоритм с потоками. При увеличении числа задач алгоритм с потоками имеет точку минимума, после которой время решения увеличивается. Алгоритм с задачами дает улучшение результатов при увеличении числа задач до 256. При этом время решения по сравнению с последовательным алгоритмом уменьшается на два порядка, что следует признать прекрасным результатом. На компьютере с четырьмя ядрами удается существенно уменьшить время решения задачи вычисления интеграла за счет корректного распараллеливания алгоритма.
Сортировка массива и параллельные задачи
Посмотрим теперь, что может дать механизм параллельных задач при сортировке массива. Мы опять-таки модифицируем алгоритмы, приведенные в "Потоки и параллельные вычисления" , заменяя работу с потоками на работу с параллельными задачами.
Как уже говорилось, реализации параллельных алгоритмов, построенные на потоках и на задачах практически идентичны с точностью до класса. В одном случае используется класс Thread, в другом - Task. Преобразуем метод BubbleSortWithTreads, приведенный в "Потоки и параллельные вычисления" , в параллельный метод пузырьковой сортировки, построенный на задачах.
/// <summary> /// Версия параллельного алгоритма /// пузырьковой сортировки с введением задач /// для сортировки подмножеств массива /// </summary> /// <param name="mas">сортируемый массив</param> /// <param name="processors">число подмножеств</param> public void BubbleSortWithTasks(double[] mas, int processors) { Task[] tasks = new Task[processors]; SortOne[] sorts = new SortOne[processors]; //Создание объектов SortOne, //передаваемых создаваемым задачам for (int i = 0; i < processors; i++) { sorts[i] = new SortOne(mas, i, processors); tasks[i] = new Task(sorts[i].BubbleSortPart); tasks[i].Start(); } //Синхронизация for (int i = 0; i < processors; i++) { tasks[i].Wait(); } //Слияние отсортированных последовательностей Merge(mas, processors); }
Каждой задаче tasks[i] передается метод BubbleSortPart и объект sorts[i], вызывающий этот метод. Вся информация, необходимая методу, содержится в полях объекта.
Аналогичную операцию проведем с методом QSortWithThreads, преобразовав его в метод QSortWithTasks. Приведу результат преобразования:
public void QSortWithTasks(double[] mas, int p) { Task[] tasks = new Task[p]; StructOne[] sorts = new StructOne[p]; int start = 0, finish = 0, n = mas.Length, m = n / p; for (int i = 0; i < p; i++) { start = i * m; finish = i != p - 1 ? start + m - 1 : n - 1; sorts[i] = new StructOne(mas, start, finish); tasks[i] = new Task(QSortStruc, sorts[i]); tasks[i].Start(); } for (int i = 0; i < p; i++) { tasks[i].Wait(); } //Слияние отсортированных последовательностей MergeQ(mas, p); }
Что же дает переход от работы с потоками к работе с задачами? Получаем ли мы помимо концептуальной ясности выигрыш в эффективности по времени работы? Приведу результаты экспериментов для задачи сортировки массивов. Сравниваться будут последовательные версии пузырьковой и быстрой сортировки и версии, построенные на потоках и на массивах. Результаты сравнения для сортировки массива вещественных чисел из 100 000 элементов приведены в следующей таблице:
Число задач | 1 | 4 | 16 | 32 | 64 | 128 | 256 |
---|---|---|---|---|---|---|---|
Bubble последовательный | 463 008 813 | - | - | - | - | - | - |
Bubble параллельный на потоках | 608 713 069 | 51 948 091 | 10 452 018 | 6 396 011 | 3 744 006 | 2 808 005 | 2 964 005 |
Bubble параллельный на задачах | 618 697 087 | 51 636 090 | 9 984 017 | 6 089 011 | 3 588 006 | 2 652 005 | 2 964 005 |
QSort последовательный | 156 000 | - | - | - | - | - | - |
QSort параллельный на потоках | 312 000 | 312 000 | 312 000 | 624 001 | 780 001 | 1 248 002 | 2 340 004 |
QSort параллельный на задачах | 312 000 | 312 000 | 312 000 | 468 001 | 624 001 | 1 092 002 | 2 028 003 |
Анализ результатов приводит к ожидаемым выводам:
- Переход от потоков к задачам приводит к повышению эффективности - сокращению времени работы, хотя на задачах сортировки этот выигрыш не столь заметен, как в задаче вычисления интеграла.
- Существует оптимальное число задач, на котором достигается минимальное время вычислений.
- Параллельные алгоритмы существенно выигрывают у последовательного алгоритма пузырьковой сортировки. Время сортировки сокращается на два порядка (более чем в 100 раз).
- Для быстрой сортировки последовательный рекурсивный алгоритм дает лучшие результаты, чем предложенные версии параллельных алгоритмов. Не всякий алгоритм следует распараллеливать.