ГЛАВА 24. Многопоточное программирование. Часть вторая: библиотека TPL
Вероятно, самым главным среди новых средств, вне дренных в версию 4.0 среды .NET Framework, является библиотека распараллеливания задач (TPL). Эта биб лиотека усовершенствует многопоточное программирова ние двумя основными способами. Во-первых, она упрощает создание и применение многих потоков. И во-вторых, она позволяет автоматически использовать несколько процес соров. Иными словами, TPL открывает возможности для автоматического масштабирования приложений с целью эффективного использования ряда доступных процессо ров. Благодаря этим двух особенностям библиотеки TPL она рекомендуется в большинстве случаев к применению для организации многопоточной обработки. Еще одним средством параллельного программиро вания, внедренным в версию 4.0 среды .NET Framework, является параллельный язык интегрированных запросов (PLINQ). Язык PLINQ дает возможность составлять запро сы, для обработки которых автоматически используется несколько процессоров, а также принцип параллелизма, когда это уместно. Как станет ясно из дальнейшего, запро сить параллельную обработку запроса очень просто. Сле довательно, с помощью PLINQ можно без особого труда внедрить параллелизм в запрос. Главной причиной появления таких важных новшеств, как TPL и PLINQ, служит возросшее значение параллелиз ма в современном программировании. В настоящее время многоядерные процессоры уже стали обычным явлением. Кроме того, постоянно растет потребность в повышении производительности программ. Все это, в свою очередь, вызвало растущую потребность в механизме, который 24 позволял бы с выгодой использовать несколько процессов для повышения произво дительности программного обеспечения. Но дело в том, что в прошлом это было не так-то просто сделать ясным и допускающим масштабирование способом. Изменить это положение, собственно, и призваны TPL и PLINQ. Ведь они дают возможность лег че (и безопаснее) использовать системные ресурсы. Библиотека TPL определена в пространстве имен System.Threading.Tasks. Но для работы с ней обычно требуется также включать в программу класс System. Threading, поскольку он поддерживает синхронизацию и другие средства многопо точной обработки, в том числе и те, что входят в класс Interlocked. В этой главе рассматривается и TPL, и PLINQ. Следует, однако, иметь в виду, что и та и другая тема довольно обширны. Поэтому в этой главе даются самые основы и рассматриваются некоторые простейшие способы применения TPL и PLINQ. Таким образом, материал этой главы послужит вам в качестве удобной отправной точки для дальнейшего изучения TPL и PLINQ. Если параллельное программирование входит в сферу ваших интересов, то именно эти средства .NET Framework вам придется изучить более основательно. ПРИМЕЧАНИЕ Несмотря на то что применение TPL и PLINQ рекомендуется теперь для разработки боль шинства многопоточных приложений, организация многопоточной обработки на основе класса Thread, представленного в главе 23, по-прежнему находит широкое распростране ние. Кроме того, многое из того, что пояснялось в главе 23, применимо и к TPL. Поэтому усвоение материала главы 23 все еще необходимо для полного овладения особенностями организации многопоточной обработки на С#. Два подхода к параллельному программированию
Применяя TPL, параллелизм в программу можно ввести двумя основными способа ми. Первый из них называется параллелизмом данных. При таком подходе одна опера ция над совокупностью данных разбивается на два параллельно выполняемых потока или больше, в каждом из которых обрабатывается часть данных. Так, если изменяется каждый элемент массива, то, применяя параллелизм данных, можно организовать па раллельную обработку разных областей массива в двух или больше потоках. Нетрудно догадаться, что такие параллельно выполняющиеся действия могут привести к значи тельному ускорению обработки данных по сравнению с последовательным подходом. Несмотря на то что параллелизм данных был всегда возможен и с помощью класса Thread, построение масштабируемых решений средствами этого класса требовало не мало усилий и времени. Это положение изменилось с появлением библиотеки TPL, с помощью которой масштабируемый параллелизм данных без особого труда вводится в программу. Второй способ ввода параллелизм называется параллелизмом задач. При таком под ходе две операции или больше выполняются параллельно. Следовательно, паралле лизм задач представляет собой разновидность параллелизма, который достигался в прошлом средствами класса Thread. А к преимуществам, которые сулит применение TPL, относится простота применения и возможность автоматически масштабировать исполнение кода на несколько процессоров. Класс Task В основу TPL положен класс Task. Элементарная единица исполнения инкапсу лируется в TPL средствами класса Task, а не Thread. Класс Task отличается от класса Thread тем, что он является абстракцией, представляющей асинхронную операцию. А в классе Thread инкапсулируется поток исполнения. Разумеется, на системном уров не поток по-прежнему остается элементарной единицей исполнения, которую можно планировать средствами операционной системы. Но соответствие экземпляра объекта класса Task и потока исполнения не обязательно оказывается взаимно-однозначным. Кроме того, исполнением задач управляет планировщик задач, который работает с пу дом потоков. Это, например, означает, что несколько задач могут разделять один и тот же поток. Класс Task (и вся остальная библиотека TPL) определены в пространстве имен System.Threading.Tasks. Создание задачи Создать новую задачу в виде объекта класса Task и начать ее исполнение можно самыми разными способами. Для начала создадим объект типа Task с помощью кон структора и запустим его, вызвав метод Start(). Для этой цели в классе Task опреде лено несколько конструкторов. Ниже приведен тот конструктор, которым мы собира емся воспользоваться: public Task(Action действие) где действие обозначает точку входа в код, представляющий задачу, тогда как Action — делегат, определенный в пространстве имен System. Форма делегата Action, которой мы собираемся воспользоваться, выглядит следующим образом. public delegate void Action() Таким образом, точкой входа должен служить метод, не принимающий никаких параметров и не возвращающий никаких значений. (Как будет показано далее, делега ту Action можно также передать аргумент.) Как только задача будет создана, ее можно запустить на исполнение, вызвав метод Start(). Ниже приведена одна из его форм. public void Start() После вызова метода Start() планировщик задач запланирует исполнение задачи. В приведенной ниже программе все изложенное выше демонстрируется на прак тике. В этой программе отдельная задача создается на основе метода MyTask(). После того как начнет выполняться метод Main(), задача фактически создается и запускается на исполнение. Оба метода MyTask() и Main() выполняются параллельно. // Создать и запустить задачу на исполнение. using System; using System.Threading; using System.Threading.Tasks; class DemoTask { // Метод выполняемый в качестве задачи. static void MyTask() { Console.WriteLine("MyTask() запущен"); for(int count = 0; count < 10; count++) { Thread.Sleep(500); Console.WriteLine ("В методе MyTask(), подсчет равен " + count); } Console.WriteLine("MyTask завершен"); } static void Main() { Console.WriteLine("Основной поток запущен."); // Сконструировать объект задачи. Task tsk = new Task(MyTask); // Запустить задачу на исполнение. tsk.Start(); // метод Main() активным до завершения метода MyTask(). for(int i = 0; i < 60; i++) { Console.Write("."); Thread.Sleep(100); } Console.WriteLine("Основной поток завершен."); } } Ниже приведен результат выполнения этой программы. (У вас он может несколько отличаться в зависимости от загрузки задач, операционной системы и прочих факторов.) Основной поток запущен. .MyTask() запущен .....В методе MyTask(), подсчет равен 0 .....В методе MyTask(), подсчет равен 1 .....В методе MyTask(), подсчет равен 2 .....В методе MyTask(), подсчет равен 3 .....В методе MyTask(), подсчет равен 4 .....В методе MyTask(), подсчет равен 5 .....В методе MyTask(), подсчет равен 6 .....В методе MyTask(), подсчет равен 7 .....В методе MyTask(), подсчет равен 8 .....В методе MyTask(), подсчет равен 9 MyTask завершен .........Основной поток завершен. Следует иметь в виду, что по умолчанию задача исполняется в фоновом потоке. Следовательно, при завершении создающего потока завершается и сама задача. Имен но поэтому в рассматриваемой здесь программе метод Thread.Sleep() использован для сохранения активным основного потока до тех пор, пока не завершится выполне ние метода MyTask(). Как и следовало ожидать, организовать ожидание завершения задачи можно и более совершенными способами, что и будет показано далее. В приведенном выше примере программы задача, предназначавшаяся для парал лельного исполнения, обозначалась в виде статического метода. Но такое требование к задаче не является обязательным. Например, в приведенной ниже программе, которая является переработанным вариантом предыдущей, метод MyTask(), выполняющий роль задачи, инкапсулирован внутри класса. // Использовать метод экземпляра в качестве задачи. using System; using System.Threading; using System.Threading.Tasks; class MyClass { // Метод выполняемый в качестве задачи. public void MyTask() { Console.WriteLine("MyTask() запущен"); for (int count = 0; count < 10; count++) { Thread.Sleep(500); Console.WriteLine("В методе MyTask(), подсчет равен " + count); } Console.WriteLine("MyTask завершен "); } } class DemoTask { static void Main() { Console.WriteLine("Основной поток запущен."); // Сконструировать объект типа MyClass. MyClass mc = new MyClass(); // Сконструировать объект задачи для метода me.MyTask(). Task tsk = new Task(mc.MyTask); // Запустить задачу на исполнение. tsk.Start(); // Сохранить метод Main() активным до завершения метода MyTask(). for(int i = 0; i < 60; i++) { Console.Write("."); Thread.Sleep(100); } Console.WriteLine("Основной поток завершен."); } } Результат выполнения этой программы получается таким же, как и прежде. Един ственное отличие состоит в том, что метод MyTask() вызывается теперь для экземпляра объекта класса MyClass. В отношении задач необходимо также иметь в виду следующее: после того, как за дача завершена, она не может быть перезапущена. Следовательно, иного способа по вторного запуска задачи на исполнение, кроме создания ее снова, не существует. Применение идентификатора задачи В отличие от класса Thread; в классе Task отсутствует свойство Name для хранения имени задачи. Но вместо этого в нем имеется свойство Id для хранения идентификато ра задачи, по которому можно распознавать задачи. Свойство Id доступно только для чтения и относится к типу int. Оно объявляется следующим образом. public int Id { get; } Каждая задача получает идентификатор, когда она создается. Значения идентифи каторов уникальны, но не упорядочены. Поэтому один идентификатор задачи может появиться перед другим, хотя он может и не иметь меньшее значение. Идентификатор исполняемой в настоящий момент задачи можно выявить с помо щью свойства CurrentId. Это свойство доступно только для чтения, относится к типу static и объявляется следующим образом. public static Nullable CurrentID { get; } Оно возвращает исполняемую в настоящий момент задачу или же пустое значение, если вызывающий код не является задачей. В приведенном ниже примере программы создаются две задачи и показывается, какая из них исполняется. // Продемонстрировать применение свойств Id и CurrentId. using System; using System.Threading; using System.Threading.Tasks; class DemoTask { // Метод, исполняемый как задача. static void MyTask() { Console.WriteLine("MyTask() №" + Task.CurrentId + " запущен"); for(int count = 0; count < 10; count++) { Thread.Sleep(500); Console.WriteLine("В методе MyTask() #" + Task.CurrentId + ", подсчет равен " + count ); } Console.WriteLine("MyTask №" + Task.CurrentId + " завершен"); } static void Main() { Console.WriteLine("Основной поток запущен."); // Сконструировать объекты двух задач. Task tsk = new Task(MyTask); Task tsk2 = new Task(MyTask); // Запустить задачи на исполнение, tsk.Start(); tsk2.Start(); Console.WriteLine("Идентификатор задачи tsk: " + tsk.Id); Console.WriteLine("Идентификатор задачи tsk2: " + tsk2.Id); // Сохранить метод Main() активным до завершения остальных задач. for(int i = 0; i < 60; i++) { Console.Write ("."); Thread.Sleep(100); } Console.WriteLine("Основной поток завершен."); } Выполнение этой программы приводит к следующему результату. Основной поток запущен Идентификатор задачи tsk: 1 Идентификатор задачи tsk2: 2 .MyTask() №1 запущен MyTask() №2 запущен .....В методе MyTask() №1, подсчет равен 0 В методе MyTask() №2, подсчет равен 0 .....В методе MyTask() №2, подсчет равен 1 В методе MyTask() №1, подсчет равен 1 .....В методе MyTask() №1, подсчет равен 2 В методе MyTask() №2, подсчет равен 2 .....В методе MyTask() №2, подсчет равен 3 В методе MyTask() №1, подсчет равен 3 .....В методе MyTask() №1, подсчет равен 4 В методе MyTask() №2, подсчет равен 4 .....В методе MyTask() №1, подсчет равен 5 В методе MyTask() №2, подсчет равен 5 .....В методе MyTask() №2, подсчет равен 6 В методе MyTask() №1, подсчет равен 6 .....В методе MyTask() №2, подсчет равен 7 В методе MyTask() №1, подсчет равен 7 .....В методе MyTask() №1, подсчет равен 8 В методе MyTask() №2, подсчет равен 8 .....В методе MyTask() №1, подсчет равен 9 MyTask №1 завершен В методе MyTask() №2, подсчет равен 9 MyTask №2 завершен .........Основной поток завершен. Применение методов ожидания В приведенных выше примерах основной поток исполнения, а по существу, ме тод Main(), завершался потому, что такой результат гарантировали вызовы мето да Thread.Sleep(). Но подобный подход нельзя считать удовлетворительным. Организовать ожидание завершения задач можно и более совершенным способом, применяя методы ожидания, специально предоставляемые в классе Task. Самым про стым из них считается метод Wait(), приостанавливающий исполнение вызывающего потока до тех пор, пока не завершится вызываемая задача. Ниже приведена простей шая форма объявления этого метода. public void Wait() При выполнении этого метода могут быть сгенерированы два исключения. Первым из них является исключение ObjectDisposedException. Оно генерируется в том случае, если задача освобождена посредством вызова метода Dispose(). А второе ис ключение, AggregateException, генерируется в том случае, если задача сама генери рует исключение или же отменяется. Как правило, отслеживается и обрабатывается именно это исключение. В связи с тем что задача может сгенерировать не одно ис ключение, если, например, у нее имеются порожденные задачи, все подобные исклю чения собираются в единое исключение типа AggregateException. Для того чтобы выяснить, что же произошло на самом деле, достаточно проанализировать внутренние исключения, связанные с этим совокупным исключением. А до тех пор в приведенных далее примерах любые исключения, генерируемые задачами, будут обрабатываться во время выполнения. Ниже приведен вариант предыдущей программы, измененный с целью продемон стрировать применение метода Wait() на практике. Этот метод используется внутри метода Main(), чтобы приостановить его выполнение до тех пор, пока не завершатся обе задачи tsk и tsk2. // Применить метод Wait(). using System; using System.Threading; using System.Threading.Tasks; class DemoTask { // Метод, исполняемый как задача. static void MyTask() { Console.WriteLine("MyTask() №" + Task.CurrentId + " запущен"); for(int count = 0; count < 10; count++) { Thread.Sleep(500); Console.WriteLine("В методе MyTask() #" + Task.CurrentId + ", подсчет равен " + count ); } Console.WriteLine("MyTask №" + Task.CurrentId + " завершен"); } static void Main() { Console.WriteLine("Основной поток запущен."); // Сконструировать объекты двух задач. Task tsk = new Task(MyTask); Task tsk2 = new Task(MyTask); // Запустить задачи на исполнение. tsk.Start(); tsk2.Start(); Console.WriteLine("Идентификатор задачи tsk: " + tsk.Id); Console.WriteLine("Идентификатор задачи tsk2: " + tsk2.Id); // Приостановить выполнение метода Main() до тех пор, // пока не завершатся обе задачи tsk и tsk2 tsk.Wait(); tsk2.Wait(); Console.WriteLine("Основной поток завершен."); } } При выполнении этой программы получается следующий результат. Основной поток запущен Идентификатор задачи tsk: 1 Идентификатор задачи tsk2: 2 MyTask() №1 запущен MyTask() №2 запущен В методе MyTask() №1, подсчет равен 0 В методе MyTask() №2, подсчет равен 0 В методе MyTask() №1, подсчет равен 1 В методе MyTask() №2, подсчет равен 1 В методе MyTask() №1, подсчет равен 2 В методе MyTask() №2, подсчет равен 2 В методе MyTask() №1, подсчет равен 3 В методе MyTask() №2, подсчет равен 3 В методе MyTask() №1, подсчет равен 4 В методе MyTask() №2, подсчет равен 4 В методе MyTask() №1, подсчет равен 5 В методе MyTask() №2, подсчет равен 5 В методе MyTask() №1, подсчет равен 6 В методе MyTask() №2, подсчет равен 6 В методе MyTask() №1, подсчет равен 7 В методе MyTask() №2, подсчет равен 7 В методе MyTask() №1, подсчет равен 8 В методе MyTask() №2, подсчет равен 8 В методе MyTask() №1, подсчет равен 9 MyTask №1 завершен В методе MyTask() №2, подсчет равен 9 MyTask №2 завершен Основной поток завершен. Как следует из приведенного выше результата, выполнение метода Main() приоста навливается до тех пор, пока не завершатся обе задачи tsk и tsk2. Следует, однако, иметь в виду, что в рассматриваемой здесь программе последовательность завершения задач tsk и tsk2 не имеет особого значения для вызовов метода Wait(). Так, если первой за вершается задача tsk2, то в вызове метода tsk.Wait() будет по-прежнему ожидаться завершение задачи tsk. В таком случае вызов метода tsk2.Wait() приведет к выполне нию и немед