Параллельные вычисления и многопоточное программирование

Пул потоков и библиотека параллельных задач

Показывать лекцию целиком

Класс ThreadPool

Для работы с потоками в пространстве имен Threading есть много полезных классов. Центральную роль играет класс Thread, объекты которого представляют отдельные потоки. Две предыдущие главы были посвящены работе с объектами этого класса. В этой главе рассмотрим еще один весьма полезный и часто используемый в приложениях класс ThreadPool. В отличие от класса Thread класс ThreadPool является статическим классом. Объект этого класса существует в единственном экземпляре, имя его совпадает с именем класса. Объект создается системой автоматически и предоставляется программисту для использования. Этот единственный объект описывает пул потоков - некоторое множество потоков. Управляет потоками операционная система. Программист не может ни создать пул потоков, ни удалить пул, не может добавить поток в пул, не может удалить ни один из потоков, входящих в пул.

Что же тогда может программист, что дает ему пул потоков? Он может стать в очередь, записавшись на использование одного из потоков пула для выполнения своей задачи. Задача, выполняемая потоком, представляет метод с фиксированной сигнатурой.

Когда в пуле потоков есть свободные потоки, а в очереди есть задачи на выполнение, то задача в порядке очереди связывается с потоком и начинает выполняться в соответствии с общей стратегией выполнения потоков. По завершении задачи, поток не уничтожается, а возвращается в пул потоков. Поток из пула не только никогда не удаляется, он никогда и не завершается, всегда существует, время от времени исполняя различные задачи. Как следствие, поток, поставивший задачу в очередь, не может узнать о ее завершении, используя метод Join, как это делалось для синхронизации работы потоков - объектов класса Thread. Здесь для синхронизации нужны другие механизмы.

Когда программисту для решения его задачи нужно создать один - два потока, то это может быть приемлемым решением. Но когда требуется создавать множество потоков, то накладные расходы, связанные с созданием потока и его последующим удалением, становятся непомерными и могут уничтожить все преимущества, предоставляемые распараллеливанием вычислений. В этих случаях работа с пулом потоков в том или ином виде становится совершенно необходимым делом.

Потоки из пула выполняются в фоновом режиме. Поэтому их работа должна быть завершена до окончания работы потока, выполняемого в режиме переднего плана. В противном случае по завершении работы основного потока завершится и выполнение потоков фона.

Требования к методу, передаваемому в пул потоков

Метод, передаваемый потоку, отвечает обычным требованиям. Во-первых, это может быть void метод с одним параметром типа object, - метод, принадлежащий классу, задаваемым делегатом WaitCallback.

Если методу не нужно передавать информацию, то в момент вызова фактический параметр, соответствующий параметру obj, просто не задается. Если же нужно передать информацию, то фактический параметр может быть объектом, содержащим всю необходимую информацию. Естественно, что в этом случае в методе, прежде чем использовать информацию, необходимо привести параметр obj к требуемому типу.

Еще одна возможность состоит в том, чтобы передать потоку не просто метод, а объект, вызывающий метод с требуемой сигнатурой. Тогда вся требуемая методу информация может содержаться в полях объекта. Этот способ передачи данных при работе с потоками, как мы знаем, является одним из наиболее надежных.

Ну и наконец, можно передать потоку анонимный метод. Преимущество этого способа в том, что в этом случае анонимный метод может вызывать метод с произвольной сигнатурой. Напомню, что в четвертой главе был приведен пример вызова анонимного метода, приводящий к некорректной работе приложения. Так что при работе с анонимными методами следует быть осторожным.

При работе с объектами класса Thread, метод, который должен выполнять поток, передавался конструктору объектов класса Thread. После этого для запуска созданного потока вызывался метод Start, которым обладают объекты класса Thread. При работе с пулом потоков приходится идти другим путем, поскольку нет конструкторов класса ThreadPool, нет создаваемых объектов этого класса. Вот как происходит связывание определяющего задачу метода с потоком из пула. У класса ThreadPool есть статический метод QueueUserWorkItem, позволяющий поставить задачу в очередь на выполнение. Метод является функцией, возвращающей true, если задача успешно поставлена в очередь, и false - в противном случае. Метод перегружен. Ему можно передавать один или два параметра. Первым параметром является объект класса WaitCallback, представляющий передаваемый потоку метод. Вторым параметром, если он задан, является объект универсального класса object. Первый параметр несет информацию о методе, передаваемом потоку, второй - содержит информацию, передаваемую методу, являясь фактическим параметром метода.

Давайте перейдем к примеру, в котором продемонстрируем разные способы передачи методов пулу потоков:

static void Main(string[] args)
        {
            //Потоку передается метод со стандартной сигнатурой void(object)
            //Информация через параметр методу не передается
            //ThreadPool.QueueUserWorkItem(new WaitCallback(WorkerOne)) ;
            ThreadPool.QueueUserWorkItem(WorkerOne);

В этом фрагменте процедуры Main пулу потоков передается метод WorkerOne. В закомментированной строке демонстрируется явный способ конструирования объекта, заданного делегатом WaitCallback. Проще указать имя метода, выдержав требования к сигнатуре.

Приведу текст метода WorkerOne:

static  void WorkerOne(object info)
        {
            Console.WriteLine("Я работник первый!"); 
        }

Как видите, метод соответствует требуемой сигнатуре void (object). Заметьте также, что метод не использует информацию, передаваемую параметром info.

Добавим в Main следующий фрагмент кода:

//Потоку передается метод со стандартной сигнатурой void(object)
            //методу передается информация через параметр 
            object inf = new Info("Феликс", 50);
            ThreadPool.QueueUserWorkItem(new WaitCallback(WorkerTwo), inf);

Метод WorkerTwo, передаваемый пулу потоков, будет использовать передаваемую ему информацию. Вот текст этого метода:

static void WorkerTwo(object info)
       {
           Info inf = (Info)info;
           Console.WriteLine("Я работник второй!");
           Console.WriteLine("Меня зовут " + inf.Name);
           Console.WriteLine("Мне" + inf.Age.ToString() );
       }

Переданный методу параметр info приводится к типу Info и затем с ним можно работать. Тип Info задается следующей структурой:

struct Info
    {
        string name;
        int age;
        public Info(string name, int age)
        {
            this.name = name;
            this.age = age;
        }
        public string Name
        {
            get { return name; }
        }
        public int Age
        {
            get { return age; }
        }
    }

Следующий фрагмент кода, добавляемый в процедуру Main, демонстрирует передачу потоку анонимного метода:

//Потоку передается анонимный метод с произвольной  сигнатурой 
 //Информация методу передается в момент вызова
 //ThreadPool.QueueUserWorkItem
//(new WaitCallback((name) => { WorkerThree("Дмитрий"); }));

 ThreadPool.QueueUserWorkItem((name) => { WorkerThree("Дмитрий"); });

Заметьте, и в случае анонимного метода явно конструировать объект WaitCallback не требуется. Приведу текст метода WorkerThree, вызываемого анонимным методом:

static void WorkerThree(string name)
       {
           Console.WriteLine("Я работник третий!");
           Console.WriteLine("Меня зовут " + name);
       }

Добавим еще один фрагмент в процедуру Main, демонстрирующий передачу пулу потоков метода вместе с объектом, вызывающим этот метод:

//Потоку передается экземплярный метод со стандартной сигнатурой void(object)
          //Информация методу содержится в полях объекта, вызывающего метод             
          Simple sim = new Simple("Олег", 33, "программист");
          ThreadPool.QueueUserWorkItem(new WaitCallback(sim.About));
          object inf_add = "Прекрасный работник";
          ThreadPool.QueueUserWorkItem(new WaitCallback(sim.About), inf_add);

Здесь вначале создается объект sim класса Simple. Затем пулу потоков передается этот объект, вызывающий метод About из класса Simple. Операция выполняется дважды, во втором случае помимо информации, содержащейся в самом вызывающем объекте sim, метод может использовать информацию, передаваемую через параметр inf_add. Рассмотрим текст класса Simple:

class Simple
    {
        string name;
        int age;
        string profession;
        public Simple(string name, int age, string profession)
        {
            this.name = name;
            this.age = age;
            this.profession = profession;
        }
        public void About(object inf)
        {
          Console.WriteLine("Новый работник. Мое имя - {0}, " +
                " возраст - {1} профессия - {2}",
                name, age, profession);
            if (inf != null)
                Console.WriteLine(inf.ToString());
        }
    }

В результате приведенных фрагментов кода в очередь к пулу потоков поставлены пять наших задач. Осталось синхронизировать работу потоков из пула потоков и основного потока, выполняющего процедуру Main. Рассмотрим простейший способ синхронизации, когда основной поток засыпает на некоторое время, давая шанс на выполнение потокам в фоновом режиме. Вот как выглядит заключительный фрагмент процедуры Main, работающий после того, как задачи поставлены в очередь к пулу потоков:

//Основной поток засыпает
            //В это время потоки пула выполняются
            Thread.Sleep(100);

            //Основной поток проснулся и продолжил работу
            Console.WriteLine("Я управляющий!");

Осталось посмотреть на результаты работы потоков:

(рис 6.1) Результаты работы задач, выполняемых пулом потоков

Можно видеть, что все задачи успешно выполнились. Но, обратите внимание, потоки работали параллельно и для вывода своих результатов использовали общий ресурс - Console.

По этой причине результаты разных потоков идут вперемешку и не в том порядке, как задачи ставились в очередь. Для синхронизации, поддерживающей строгий порядок выполнения, требуются определенные усилия. Пока же наш пример явно демонстрирует параллелизм вычислений задач, выполняемых пулом потоков. В этом и была цель нашего примера.

Пример. Обсуждение

Достоинства и недостатки последнего примера заслуживают более серьезного обсуждения. Несомненным достоинством является относительная простота и возможность вариации при связывании задач с потоками.

О недостатках стоит поговорить подробнее. Первый очевидный недостаток состоит в том, что результаты работы отдельных задач при выводе на консоль перемешиваются, создавая эффект "блюда спагетти". Этот недостаток целиком лежит на нашей совести. Мы допустили типичную ошибку при работе с потоками, заставив потоки участвовать в Data Race (Condition) - гонке данных (условий) в борьбе за единый разделяемый ресурс - консоль вывода. Как необходимо было организовать процесс получения результатов? Задаче следует передавать входные данные, а она должна формировать результаты - выходные данные, не нарушая важный принцип отделения бизнес-логики от интерфейса. Вывод на консоль должен осуществлять интерфейсный метод, в данном случае основной поток, который должен получить результаты решения задач, выполненных в дочерних потоках, и организовать выдачу этих результатов в нужном порядке. Если следовать правилам технологии, то данный недостаток нашего примера легко устраним.

Второй, более серьезный недостаток не преодолевается столь просто, поскольку он характерен для класса ThreadPool. Возможности этого класса не позволяют нужным образом организовать синхронизацию работы основного потока и задач, исполняемых в пуле потоков. В нашем примере для того, чтобы запустить на выполнение потоки пула, основной поток засыпал на некоторое время. Это прием годится для примеров, таких как наш, но непригоден для серьезных приложений, где непонятно, какое время понадобится для выполнения задач, решаемых в пуле потоков. Плохо и то, что потоки в пуле обезличены, мы не знаем "кто сшил костюм", мы не можем выполнить операцию, аналогичную Join, чтобы гарантировано дождаться завершения определенной задачи.

Библиотека параллельных задач

Библиотека параллельных задач - TPL (Task Parallel Library), представленная в четвертой версии .Net Framework 4.0, позволяет справиться как с проблемой синхронизации задач, выполняемых в пуле потоков, так и предоставить много новых дополнительных возможностей. На сегодняшний день лучший способ создания многопоточного приложения предполагает работу с объектами библиотеки TPL.

Понятие "задача" является одним из центральных понятий в параллельном программировании. Распараллеливание "по задачам" и распараллеливание "по данным" - два основных принципа параллельных вычислений. Вполне естественно введение класса Task, объекты которого представляют задачи. В .Net Framework 4.0 в пространстве имен Threading выделено пространство Threading.Tasks, содержащее как класс Task, так и другие классы, поддерживающие работу с задачами, возможности их параллельного выполнения. Эти классы, являясь надстройкой над пулом потоков, позволяют полностью абстрагироваться от "потоков" - низкоуровневых механизмов и сосредоточиться на работе с объектами более высокого уровня - "задачами", отражающими суть приложения. Тем не менее, корректная работа с задачами предполагает, а на самом деле невозможна без понимания всех проблем, присущих параллельным вычислениям - синхронизации, блокировкам, гонки данных и клинчам.

Прежде чем формально описать возможности класса Task и других классов пространства System.Threading.Tasks, давайте модифицируем наш предыдущий пример с несколькими работниками, выразив его в терминах задач, не обращаясь явно к пулу потоков. Класс ThreadingPool и пул потоков теперь неявно будут присутствовать "за сценой", а на сцене будут выступать "задачи". С помощью новых актеров постараемся избавиться от недостатков, присущих предыдущему примеру.

Методы, подлежащие выполнению, будем теперь связывать не с потоками, а с задачами. Требования к методам остаются прежними. Методы, как и ранее, могут быть трех типов. Это может быть метод с фиксированной сигнатурой void (object). Это может быть метод с такой же сигнатурой, вызываемый объектом некоторого класса. Это может быть анонимный метод с такой же сигнатурой, способный вызвать метод с произвольной сигнатурой. Все эти возможности были продемонстрированы в предыдущем примере. Слегка модифицируя наш пример, реализуем эти три способа, оперируя уже с задачами. Начнем с анонимного метода. В новом проекте добавим в процедуру Main следующий фрагмент кода:

// Создание первой задачи task1
            //С задачей связывается анонимный метод,
            //вызывающий метод с произвольной сигнатурой
            string res ="";
            Task task1 = new Task((object inf) =>
                { WorkerOne("Дмитрий", 33, inf, out res ); },
                "отличный работник");
            //Запуск задачи и ожидание ее завершения
            task1.Start();
            task1.Wait();
            //Печать результатов работы метода в основном потоке
            Console.WriteLine(res);

Метод WorkerOne, вызываемый анонимным методом, может иметь произвольную сигнатуру. В момент создания ему можно передать фактические параметры. Заметьте, в нашем примере методу также передается значение параметра inf - единственного параметра анонимного метода. Метод WorkerOne теперь не будет выводить результаты своей работы на консоль. Он, как и положено добропорядочному методу, имеет выходной параметр res, содержащий результат работы метода.

В целом схема работы с задачей проста и естественна. Создается объект task1, характеризующий задачу. В момент создания с ним связывается метод, который должна выполнить задача. Затем задача стартует. Основной поток в этот момент приостанавливается, ожидая завершения задачи. Когда это событие происходит, основной поток продолжает свое выполнение, выводя на консоль результаты решения задачи task1.

Приведу текст метода WorkerOne:

static void WorkerOne(string name, int age, object inf, out string res)
        {
            res = string.Format("Я первый работник. Имя: {0}," +
            "возраст: {1} , рекомендация: {2}", name, age, inf);
        }

Рассмотрим теперь вторую возможность - передачу задаче метода с фиксированной сигнатурой void (object). При создании задачи конструктору класса Task передаются два параметра. Первый параметр функционального типа, задаваемый делегатом Action, несет информацию о методе, второй параметр типа object передает методу всю входную информацию, необходимую для работы метода.

При работе с задачей task1 мы не стали явно создавать объект класса Action. Теперь же явно создадим этот объект, что хотя и не обязательно, но облегчает понимание текста программы. Вот как выглядит следующий фрагмент кода, добавляемый в процедуру Main:

Console.WriteLine("Задача task1 отработала. Стартуют задачи task2 и task3!");
      //Создание объектов: action2, info, task2
      //action2 - исполняемый метод с сигнатурой void (object)
      //info - глобальный объект - содержит информацию, передаваемую методу
      // task2 - задача, которой передается action2 и info          
            Action<object> action2 = WorkerTwo; 
            info = new Info("Феликс", 50);            
            Task task2 = new Task(action2, info);

Параметр action2 задает выполняемый метод, info - информацию, передаваемую методу. Класс Info, определяющий объект info, задается следующей структурой:

struct Info
        {
            string name;
            int age;
            string result ;
            public Info(string name, int age)
            {
                this.name = name;
                this.age = age;
                result = "";
            }
            public string Name
            {
                get { return name; }
            }
            public int Age
            {
                get { return age; }
            }
            public string Result
            {
                set { result = value; }
                get { return result; }
            }
        }

Параметры name и age содержательно являются входными параметрами, result - выходной параметр.

Метод WorkerTwo выглядит следующим образом:

static void WorkerTwo(object inf)
        {
            info.Result = "Я работник второй! " + "Меня зовут " +
            ((Info)inf).Name + " Мне " + ((Info)inf).Age.ToString();
        }

Следует обратить внимание на одно важное обстоятельство. Цель, которую мы поставили, состоит в том, чтобы метод WorkerTwo результат своей работы сохранял в поле result структуры Info, передаваемой методу в параметре inf. Но беда в том, что параметр типа object может передать методу входную информацию, но не может передать во внешний мир изменения, сделанные в полях объекта. Все изменения носят локальный характер, доступны только в пределах метода и исчезают, когда тело заканчивает свою работу. Поэтому метод для сохранения результатов своей работы использует глобальный объект info, определенный в классе Program, содержащем как метод Main, так и метод WorkerTwo:

static Info info;

Обратите внимание, входные данные, нужные методу WorkerTwo, берутся из объекта inf, а результат записывается в поле result глобального объекта info.

Прежде чем запустить задачу task2 на выполнение, создадим еще одну задачу task3, в которой присоединяемый к задаче метод будет вызываться объектом sim класса Simple. Добавим в Main следующий фрагмент кода:

///Создание объекта sim класса Simple
            ///При создании задачи task3 ей передается 
            ///объект sim, вызывающий метод About
            ///и информация, передаваемая методу
            Simple sim = new Simple("Виктор", 27, "программист");
            Task task3 = new Task(sim.About, "супер компьютерщик");

Класс Simple устроен следующим образом:

class Simple
        {
            string name;
            int age;
            string profession;
            string result;

            public Simple(string name, int age, string profession)
            {
                this.name = name;
                this.age = age;
                this.profession = profession;
            }
            /// <summary>
            /// Метод About этого класса при формировании результата
            /// использует информацию из полей класса
            /// и дополнительную информацию, передаваемую в объекте inf
            /// </summary>
            public void About(object inf)
            {
                result = string.Format("Новый работник. Мое имя - {0}, " +
                    " возраст - {1} профессия - {2}",
                    name, age, profession);
                if (inf != null)
                   result += "  " + inf.ToString();
            }
            public string Result
            {
                get { return result; }
            }
        }

Метод About, выполняемый задачей task3, формирует результат работы в поле result класса Simple. Задачи task2 и task3 созданы в основном потоке, но еще не запущены. Запустим их на выполнение, прервав основной поток до завершения работы задач:

///Задачи task2 и task3 стартуют одновременно
            task2.Start();
            task3.Start();
            ///основной поток приостанавливается
            ///ожидая завершения работы задач
            //Task.WaitAll();
            task2.Wait();
            task3.Wait();

Замечу, что метод WaitAll класса Task применим к массиву задач, но не к последовательности задач. Поэтому в примере он присутствует в закомментированном виде и включено явное ожидание завершения для каждой задачи в отдельности. По завершении работы задач основной поток возобновляет свою работу и выводит на консоль результаты работы:

///основной поток возобновляет работу
            ///дождавшись завершения задач
            ///выводит на консоль результаты работы задач
            Console.WriteLine("Задача task2 отработала.");
            Console.WriteLine(info.Result);
            Console.WriteLine("Задача task3 отработала.");
            Console.WriteLine(sim.Result);

Осталось взглянуть на результаты проделанной работы:

(рис 6.2) Результаты работы параллельно исполняемых задач

Подведем первые итоги работы с библиотекой TPL. Нам удалось создать несколько задач, используя разные приемы при их создании. Задачи просто запускаются на параллельное выполнение. При этом нам нет необходимости думать о числе процессоров компьютера, о потоках операционной системы. Вся работа идет на высоком абстрактном уровне объектов, отвечающих сути нашего приложения. Вопросы синхронизации работы задач решаются просто. Результаты решения выводятся на консоль в нужном порядке. Проблемы, обсуждаемые при построении первого примера, успешно разрешены, так что можно двигаться дальше.

Библиотека 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 объектами

Займемся теперь наиболее интересным делом. Давайте посмотрим насколько просто применить механизм параллельных задач к тем параллельным алгоритмам, рассмотренных в третьей и четвертой главе. Какой эффект дает переход к параллельным задачам при реализации этих алгоритмов.

Вычисление интеграла и параллельные задачи

В главе 4 построен метод 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 этого объекта.

Следует обратиться к главе 4, если требуется посмотреть определение метода DefiniteIntegral или класса IntegralOne. Рекомендуется также сравнить коды двух методов: ParallelIntegralWithThreads и ParallelIntegralWithTasks.

Итак, отвечая на первый вопрос, можно сказать, что реализация параллельных алгоритмов с использованием механизма параллельных задач достаточна проста. По крайней мере, она не сложнее, чем использование для этих целей объектов класса Thread.

Осталось ответить на второй вопрос, - эффективнее ли этот подход в сравнении с использованием потоков - объектов класса Thread. Приведу результаты экспериментов, где будут сравниваться четыре алгоритма - строго последовательный, потенциально параллельный алгоритм, но без реального распараллеливания, реализация параллельного алгоритма, основанная на использовании потоков, реализация параллельного алгоритма, основанная на использовании задач. Результаты вычислений приведены в следующей таблице:

Последовательный и параллельные алгоритмы вычисления интеграла
Число задачПоследовательный алгоритмПараллельный алгоритмПараллельный алгоритм с потокамиПараллельный алгоритм с задачами
15 730 3285 730 3305 830 3345 940 340
2-8 630 4945 970 3426 030 345
4-7 470 4283 160 1813 070 176
8-3 780 2161 360 078920053
16-1 880 107580 034430 024
32-980 056470 027220 013
64-490 028660 038140008
128-250 0151 250 07180 005
256-130 0082 350 13460 004
512-60 0034 720 23060 003

Анализ этой таблицы показывает, что алгоритм с задачами показывает лучшие результаты, чем алгоритм с потоками. При увеличении числа задач алгоритм с потоками имеет точку минимума, после которой время решения увеличивается. Алгоритм с задачами дает улучшение результатов при увеличении числа задач до 256. При этом время решения по сравнению с последовательным алгоритмом уменьшается на два порядка, что следует признать прекрасным результатом. На компьютере с четырьмя ядрами удается существенно уменьшить время решения задачи вычисления интеграла за счет корректного распараллеливания алгоритма.

Сортировка массива и параллельные задачи

Посмотрим теперь, что может дать механизм параллельных задач при сортировке массива. Мы опять-таки модифицируем алгоритмы, приведенные в главе 4, заменяя работу с потоками на работу с параллельными задачами.

Как уже говорилось, реализации параллельных алгоритмов, построенные на потоках и на задачах практически идентичны с точностью до класса. В одном случае используется класс Thread, в другом - Task. Преобразуем метод BubbleSortWithTreads, приведенный в главе 4, в параллельный метод пузырьковой сортировки, построенный на задачах.

/// <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 элементов приведены в следующей таблице:

Последовательный и параллельные алгоритмы сортировки массива
Число задач14163264128256
Bubble последовательный463 008 813------
Bubble параллельный на потоках608 713 06951 948 09110 452 0186 396 0113 744 0062 808 0052 964 005
Bubble параллельный на задачах618 697 08751 636 0909 984 0176 089 0113 588 0062 652 0052 964 005
QSort последовательный156 000------
QSort параллельный на потоках312 000312 000312 000624 001780 0011 248 0022 340 004
QSort параллельный на задачах312 000312 000312 000468 001624 0011 092 0022 028 003

Анализ результатов приводит к ожидаемым выводам:

  • Переход от потоков к задачам приводит к повышению эффективности - сокращению времени работы, хотя на задачах сортировки этот выигрыш не столь заметен, как в задаче вычисления интеграла.
  • Существует оптимальное число задач, на котором достигается минимальное время вычислений.
  • Параллельные алгоритмы существенно выигрывают у последовательного алгоритма пузырьковой сортировки. Время сортировки сокращается на два порядка (более чем в 100 раз).
  • Для быстрой сортировки последовательный рекурсивный алгоритм дает лучшие результаты, чем предложенные версии параллельных алгоритмов. Не всякий алгоритм следует распараллеливать.
  • Вернуться к учебному плану