ГЛАВА 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(), подсчет равен 9MyTask завершен.........Основной поток завершен.Следует иметь в виду, что по умолчанию задача исполняется в фоновом потоке.Следовательно, при завершении создающего потока завершается и сама задача. Именно поэтому в рассматриваемой здесь программе метод 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, подсчет равен 9MyTask №1 завершенВ методе MyTask() №2, подсчет равен 9MyTask №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 и tsk2tsk.Wait();tsk2.Wait();Console.WriteLine("Основной поток завершен.");}}При выполнении этой программы получается следующий результат.Основной поток запущенИдентификатор задачи tsk: 1Идентификатор задачи tsk2: 2MyTask() №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, подсчет равен 9MyTask №1 завершенВ методе MyTask() №2, подсчет равен 9MyTask №2 завершенОсновной поток завершен.Как следует из приведенного выше результата, выполнение метода Main() приостанавливается до тех пор, пока не завершатся обе задачи tsk и tsk2. Следует, однако, иметьв виду, что в рассматриваемой здесь программе последовательность завершения задачtsk и tsk2 не имеет особого значения для вызовов метода Wait(). Так, если первой завершается задача tsk2, то в вызове метода tsk.Wait() будет по-прежнему ожидатьсязавершение задачи tsk. В таком случае вызов метода tsk2.Wait() приведет к выполнению и немедленному возврату из него, поскольку задача tsk2 уже завершена.В данном случае оказывается достаточно двух вызовов метода Wait(), но того жерезультата можно добиться и более простым способом, воспользовавшись методомWaitAll(). Этот метод организует ожидание завершения группы задач. Возврата изнего не произойдет до тех пор, пока не завершатся все задачи. Ниже приведена простейшая форма объявления этого метода.public static void WaitAll(params Task[] tasks)Задачи, завершения которых требуется ожидать, передаются с помощью параметра в виде массива tasks. А поскольку этот параметр относится к типу params,то данному методу можно отдельно передать массив объектов типа Task или список задач. При этом могут быть сгенерированы различные исключения, включая иAggregateException.Для того чтобы посмотреть, как метод WaitAll() действует на практике, заменитев приведенной выше программе следующую последовательность вызовов.tsk.Wait();tsk2.Wait();наTask.WaitAll(tsk, tsk2);Программа будет работать точно так же, но логика ее выполнения станет болеепонятной.Организуя ожидание завершения нескольких задач, следует быть особенно внимательным, чтобы избежать взаимоблокировок. Так, если две задачи ожидают завершения друг друга, то вызов метода WaitAll() вообще не приведет к возврату из него.Разумеется, условия для взаимоблокировок возникают в результате ошибок программирования, которых следует избегать. Следовательно, если вызов метода WaitAll()не приводит к возврату из него, то следует внимательно проанализировать, могут лидве задачи или больше взаимно блокироваться. (Вызов метода Wait(), который неприводит к возврату из него, также может стать причиной взаимоблокировок.)Иногда требуется организовать ожидание до тех пор, пока не завершится любая изгруппы задач. Для этой цели служит метод WaitAny(). Ниже приведена простейшаяформа его объявления.public static int WaitAny(params Task[] tasks)Задачи, завершения которых требуется ожидать, передаются с помощью параметра в виде массива tasks объектов типа Task или отдельного списка аргументов типаTask. Этот метод возвращает индекс задачи, которая завершается первой. При этоммогут быть сгенерированы различные исключения.Попробуйте применить метод WaitAny() на практике, подставив в предыдущейпрограмме следующий вызов.Task.WaitAny(tsk, tsk2);Теперь, выполнение метода Main() возобновится, а программа завершится, кактолько завершится одна из двух задач.Помимо рассматривавшихся здесь форм методов Wait(), WaitAll() иWaitAny(), имеются и другие их варианты, в которых можно указывать период простоя или отслеживать признак отмены. (Подробнее об отмене задач речь пойдет далее в этой главе.)Вызов метода Dispose()В классе Task реализуется интерфейс IDisposable, в котором определяется методDispose(). Ниже приведена форма его объявления.public void Dispose()Метод Dispose() реализуется в классе Task, освобождая ресурсы, используемыеэтим классом. Как правило, ресурсы, связанные с классом Task, освобождаются автоматически во время "сборки мусора" (или по завершении программы). Но если этиресурсы требуется освободить еще раньше, то для этой цели служит метод Dispose().Это особенно важно в тех программах, где создается большое число задач, оставляемыхна произвол судьбы.Следует, однако, иметь в виду, что метод Dispose() можно вызывать для отдельной задачи только после ее завершения. Следовательно, для выяснения факта завершения отдельной задачи, прежде чем вызывать метод Dispose(), потребуется некоторый механизм, например, вызов метода Wait(). Именно поэтому так важно былорассмотреть метод Wait(), перед тем как обсуждать метод Dispose(). Ели же попытаться вызвать Dispose() для все еще активной задачи, то будет сгенерировано исключение InvalidOperationException.Во всех примерах, приведенных в этой главе, создаются довольно короткие задачи,которые фазу же завершаются, и поэтому применение метода Dispose() в этих примерах не дает никаких преимуществ. (Именно по этой причине вызывать метод Dispose()в приведенных выше программах не было никакой необходимости. Ведь все они завершались, как только завершалась задача, что в конечном итоге приводило к освобождению отостальных задач.) Но в целях демонстрации возможностей данного метода и во избежание каких-либо недоразумений метод Dispose() будет вызываться явным образом принепосредственном обращении с экземплярами объектов типа Task во всех последующихпримерах программ. Если вы обнаружите отсутствие вызовов метода Dispose() в исходном коде, полученном из других источников, то не удивляйтесь этому. Опять же, еслипрограмма завершается, как только завершится задача, то вызывать метод Dispose() нетникакого смысла — разве что в целях демонстрации его применения.Применение класса TaskFactory для запуска задачиПриведенные выше примеры программы были составлены не так эффективно, какследовало бы, поскольку задачу можно создать и сразу же начать ее исполнение, вызвав метод StartNew(), определенный в классе TaskFactory. В классе TaskFactoryпредоставляются различные методы, упрощающие создание задач и управление ими.По умолчанию объект класса TaskFactory может быть получен из свойства Factory,доступного только для чтения в классе Task. Используя это свойство, можно вызватьлюбые методы класса TaskFactory. Метод StartNew() существует во множествеформ. Ниже приведена самая простая форма его объявления:public Task StartNew(Action action)где action — точка входа в исполняемую задачу. Сначала в методе StartNew() автоматически создается экземпляр объекта типа Task для действия, определяемого параметром action, а затем планируется запуск задачи на исполнение. Следовательно,необходимость в вызове метода Start() теперь отпадает.Например, следующий вызов метода StartNew() в рассматривавшихся ранее программах приведет к созданию и запуску задачи tsk одним действием.Task tsk = Task.Factory.StartNew(MyTask);После этого оператора сразу же начнет выполняться метод MyTask().Метод StartNew() оказывается более эффективным в тех случаях, когда задача создается и сразу же запускается на исполнение. Поэтому именно такой подход и применяется в последующих примерах программ.Применение лямбда-выражения в качестве задачиКроме использования обычного метода в качестве задачи, существует и другой, более рациональный подход: указать лямбда-выражение как отдельно решаемую задачу.Напомним, что лямбда-выражения являются особой формой анонимных функций. Поэтому они могут исполняться как отдельные задачи. Лямбда-выражения оказываютсяособенно полезными в тех случаях, когда единственным назначением метода являетсярешение одноразовой задачи. Лямбда-выражения могут составлять отдельную задачуиди же вызывать другие методы. Так или иначе, применение лямбда-выражения в качестве задачи может стать привлекательной альтернативой именованному методу.В приведенном ниже примере программы демонстрируется применение лямбда-выражения в качестве задачи. В этой программе код метода MyTask() из предыдущихпримеров программ преобразуется в лямбда-выражение.// Применить лямбда-выражение в качестве задачи.using System;using System.Threading;using System.Threading.Tasks;class DemoLambdaTask {static void Main() {Console.WriteLine("Основной поток запущен.");// Далее лямбда-выражение используется для определения задачи.Task tsk = Task.Factory.StartNew( () => {Console.WriteLine("Задача запущена");for (int count = 0; count < 10; count++) {Thread.Sleep(500);Console.WriteLine("Подсчет в задаче равен " + count );}Console.WriteLine("Задача завершена");} );// Ожидать завершения задачи tsk.tsk.Wait();// Освободить задачу tsk.tsk.Dispose();Console.WriteLine("Основной поток завершен.");}}Ниже приведен результат выполнения этой программы.Основной поток запущен.Задача запущенаПодсчет в задаче равен 0Подсчет в задаче равен 1Подсчет в задаче равен 2Подсчет в задаче равен 3Подсчет в задаче равен 4Подсчет в задаче равен 5Подсчет в задаче равен 6Подсчет в задаче равен 7Подсчет в задаче равен 8Подсчет в задаче равен 9Задача завершенаОсновной поток завершен.Помимо применения лямбда-выражения для описания задачи, обратите такжевнимание в данной программе на то, что вызов метода tsk.Dispose() не делается дотех пор, пока не произойдет возврат из метода tsk.Wait(). Как пояснялось в предыдущем разделе, метод Dispose() можно вызывать только по завершении задачи.Для того чтобы убедиться в этом, попробуйте поставить вызов метода tsk.Dispose()в рассматриваемой здесь программе перед вызовом метода tsk.Wait(). Вы сразу жезаметите, что это приведет к исключительной ситуации.Создание продолжения задачиОдной из новаторских и очень удобных особенностей библиотеки TPL является возможность создавать продолжение задачи. Продолжение — это одна задача, которая автоматически начинается после завершения другой задачи. Создать продолжение можно, в частности, с помощью метода ContinueWith(), определенного в классе Task.Ниже приведена простейшая форма его объявления:public Task ContinueWith(Action действиепродолжения)где действиепродолжения обозначает задачу, которая будет запущена на исполнение по завершении вызывающей задачи. У делегата Action имеется единственный параметр типа Task. Следовательно, вариант делегата Action, применяемого в данномметоде, выглядит следующим образом.public delegate void Action(T obj)В данном случае обобщенный параметр T обозначает класс Task.Продолжение задачи демонстрируется на примере следующей программы.// Продемонстрировать продолжение задачи.using System;using System.Threading;using System.Threading.Tasks;class ContinuationDemo {// Метод, исполняемый как задача.static void MyTask() {Console.WriteLine("MyTask() запущен");for(int count = 0; count < 5; count++) {Thread.Sleep(500);Console.WriteLine("В методе MyTask() подсчет равен " + count );}Console.WriteLine("MyTask завершен");}// Метод, исполняемый как продолжение задачи.static void ContTask(Task t) {Console.WriteLine("Продолжение запущено");for(int count = 0; count < 5; count++) {Thread.Sleep(500);Console.WriteLine("В продолжении подсчет равен " + count );}Console.WriteLine("Продолжение завершено");}static void Main() {Console.WriteLine("Основной поток запущен.");// Сконструировать объект первой задачи.Task tsk = new Task(MyTask);// А теперь создать продолжение задачи.Task taskCont = tsk.ContinueWith(ContTask);// Начать последовательность задач.tsk.Start();// Ожидать завершения продолжения.taskCont.Wait();tsk.Dispose();taskCont.Dispose();Console.WriteLine("Основной поток завершен.");}}Ниже приведен результата выполнения данной программы.Основной поток запущен.MyTask() запущенВ методе MyTask() подсчет равен 0В методе MyTask() подсчет равен 1В методе MyTask() подсчет равен 2В методе MyTask() подсчет равен 3В методе MyTask() подсчет равен 4MyTask() завершенПродолжение запущеноВ продолжении подсчет равен 0В продолжении подсчет равен 1В продолжении подсчет равен 2В продолжении подсчет равен 3В продолжении подсчет равен 4Продолжение завершеноОсновной поток завершен.Как следует из приведенного выше результата, вторая задача не начинается до техпор, пока не завершится первая. Обратите также внимание на то, что в методе Main()пришлось ожидать окончания только продолжения задачи. Дело в том, что методMyTask() как задача завершается еще до начала метода ContTask как продолжениязадачи. Следовательно, ожидать завершения метода MyTask() нет никакой надобности, хотя если и организовать такое ожидание, то в этом будет ничего плохого.Любопытно, что в качестве продолжения задачи нередко применяется лямбда-выражение. Для примера ниже приведен еще один способ организации продолжениязадачи из предыдущего примера программы.// В данном случае в качестве продолжения задачи применяется лямбда-выражение.Task taskCont = tsk.ContinueWith((first) =>{Console.WriteLine("Продолжение запущено");for(int count = 0; count < 5; count++) {Thread.Sleep(500);Console.WriteLine("В продолжении подсчет равен " + count );}Console.WriteLine("Продолжение завершено");}};В этом фрагменте кода параметр first принимает предыдущую задачу (в данномслучае — tsk).Помимо метода ContinueWith(), в классе Task предоставляются и другие методы,поддерживающие продолжение задачи, обеспечиваемое классом TaskFactory. К их числу относятся различные формы методов ContinueWhenAny() и ContinueWhenAll(),которые продолжают задачу, если завершится любая или все указанные задачи соответственно.Возврат значения из задачиЗадача может возвращать значение. Это очень удобно по двум причинам.Во-первых, это означает, что с помощью задачи можно вычислить некоторый результат. Подобным образом поддерживаются параллельные вычисления. И во-вторых, вызывающий процесс окажется блокированным до тех пор, пока не будет получен результат. Это означает, что для организации ожидания результата не требуется никакойособой синхронизации.Для того чтобы возвратить результат из задачи, достаточно создать эту задачу, используя обобщенную форму Task класса Task. Ниже приведены два конструктора этой формы класса Task:public Task(Func функция)public Task(Func функция, Object состояние)где функция обозначает выполняемый делегат. Обратите внимание на то, что он должен быть типа Func, а не Action. Тип Func используется именно в тех случаях, когдазадача возвращает результат. В первом конструкторе создается задача без аргументов,а во втором конструкторе — задача, принимающая аргумент типа Object, передаваемый как состояние. Имеются также другие конструкторы данного класса.Как и следовало ожидать, имеются также другие варианты метода StartNew(),доступные в обобщенной форме класса TaskFactory и поддерживающиевозврат результата из задачи. Ниже приведены те варианты данного метода, которыеприменяются параллельно с только что рассмотренными конструкторами классаTask.public Task StartNew(Func функция)public Task StartNew(Func функция, Object состояние)В любом случае значение, возвращаемое задачей, подучается из свойства Result вклассе Task, которое определяется следующим образом.public TResult Result { get; internal set; }Аксессор set является внутренним для данного свойства, и поэтому оно оказывается доступным во внешнем коде, по существу, только для чтения. Следовательно, задачаполучения результата блокирует вызывающий код до тех пор, пока результат не будетвычислен.В приведенном ниже примере программы демонстрируется возврат задачей значений. В этой программе создаются два метода. Первый из них, MyTask(), не принимаетпараметров, а просто возвращает логическое значение true типа bool. Второй метод,SumIt(), принимает единственный параметр, который приводится к типу int, и возвращает сумму из значения, передаваемого в качестве этого параметра.// Возвратить значение из задачи.using System;using System.Threading;using System.Threading.Tasks;class DemoTask {// Простейший метод, возвращающий результат и не принимающий аргументов.static bool MyTask() {return true;}// Этот метод возвращает сумму из положительного целого значения,// которое ему передается в качестве единственного параметраstatic int Sumlt(object v) {int x = (int) v;int sum = 0;for(; x > 0; x--)sum += x;return sum;}static void Main() {Console.WriteLine("Основной поток запущен.");// Сконструировать объект первой задачи.Task tsk = Task.Factory.StartNew(MyTask);Console.WriteLine("Результат после выполнения задачи MyTask: " +tsk.Result);// Сконструировать объект второй задачи.Task tsk2 = Task.Factory.StartNew(Sumlt, 3);Console.WriteLine("Результат после выполнения задачи Sumlt: " +tsk2.Result);tsk.Dispose();tsk2.Dispose();Console.WriteLine("Основной поток завершен.");}}Выполнение этой программы приводит к следующему результату.Основной поток запущен.Результат после выполнения задачи MyTask: TrueРезультат после выполнения Sumlt: 6Основной поток завершен.Помимо упомянутых выше форм класса Task и методаStartNew, имеются также другие формы. Они позволяют указывать другиедополнительные параметры.Отмена задачи и обработка исключения AggregateExceptionВ версии 4.0 среды .NET Framework внедрена новая подсистема, обеспечивающаяструктурированный, хотя и очень удобный способ отмены задачи. Эта новая подсистема основывается на понятии признака отмены. Признаки отмены поддерживаются вклассе Task, среди прочего, с помощью фабричного метода StartNew().ПРИМЕЧАНИЕНовую подсистему отмены можно применять и для отмены потоков, рассматривавшихсяв предыдущей главе, но она полностью интегрирована в TPL и PLINQ. Именно поэтому этаподсистема рассматривается в этой главе.Отмена задачи, как правило, выполняется следующим образом. Сначала получается признак отмены из источника признаков отмены. Затем этот признак передается задаче, после чего она должна контролировать его на предмет получения запроса на отмену. (Этот запрос может поступить только из источника признаковотмены.) Если получен запрос на отмену, задача должна завершиться. В одних случаях этого оказывается достаточно для простого прекращения задачи без каких-либо дополнительных действий, а в других — из задачи должен быть вызван методThrowIfCancellationRequested() для признака отмены. Благодаря этому в отменяющем коде становится известно, что задача отменена. А теперь рассмотрим процессотмены задачи более подробно.Признак отмены является экземпляром объекта типа CancellationToken,т.е. структуры, определенной в пространстве имен System.Threading. В структуре CancellationToken определено несколько свойств и методов, но мы воспользуемся двумя из них. Во-первых, это доступное только для чтения свойствоIsCancellationRequested, которое объявляется следующим образом.public bool IsCancellationRequested { get; }Оно возвращает логическое значение true, если отмена задачи была запрошена длявызывающего признака, а иначе — логическое значение false. И во-вторых, это методThrowIfCancellationRequested(), который объявляется следующим образом.public void ThrowIfCancellationRequested()Если признак отмены, для которого вызывается этот метод, получил запрос на отмену, то в данном методе генерируется исключение OperationCanceledException.В противном случае никаких действий не выполняется. В отменяющем коде можноорганизовать отслеживание упомянутого исключения с целью убедиться в том, чтоотмена задачи действительно произошла. Как правило, с этой целью сначала перехватывается исключение AggregateException, а затем его внутреннее исключениеанализируется с помощью свойства InnerException или InnerExceptions. (Свойство InnerExceptions представляет собой коллекцию исключений. Подробнее о коллекциях речь пойдет в главе 25.)Признак отмены получается из источника признаков отмены, который представляет собой объект класса CancellationTokenSource, определенного в пространстве имен System. Threading. Для того чтобы получить данный признак, нужно создать сначала экземпляр объекта типа CancellationTokenSource. (С этойцелью можно воспользоваться вызываемым по умолчанию конструктором классаCancellationTokenSource.) Признак отмены, связанный с данным источником, оказывается доступным через используемое только для чтения свойство Token, котороеобъявляется следующим образом.public CancellationToken Token { get; }Это и есть тот признак, который должен быть передан отменяемой задаче.Для отмены в задаче должна быть получена копия признака отмены и организован контроль этого признака с целью отслеживать саму отмену. Такое отслеживаниеможно организовать тремя способами: опросом, методом обратного вызова и с помощью дескриптора ожидания. Проще всего организовать опрос, и поэтому здесь будет рассмотрен именно этот способ. С целью опроса в задаче проверяется упомянутоевыше свойство IsCancellationRequested признака отмены. Если это свойство содержит логическое значение true, значит, отмена была запрошена, и задача должна быть завершена. Опрос может оказаться весьма эффективным, если организоватьего правильно. Так, если задача содержит вложенные циклы, то проверка свойстваIsCancellationRequested во внешнем цикле зачастую дает лучший результат, чемего проверка на каждом шаге внутреннего цикла.Для создания задачи, из которой вызывается метод ThrowIfCancellationRequested(),когда она отменяется, обычно требуется передать признак отмены как самой задаче,так и конструктору класса Task, будь то непосредственно или же косвенно через методStartNew(). Передача признака отмены самой задаче позволяет изменить состояние отменяемой задачи в запросе на отмену из внешнего кода. Далее будет использована следующая форма метода StartNew().public Task StartNew(Action