Задачи (класс Task)

В основу TPL положен класс Task. Элементарная единица исполнения инкапсулируется в TPL средствами класса Task, а не Thread. Класс Task отличается от класса Thread тем, что он является абстракцией, представляющей асинхронную операцию. А в классе Thread инкапсулируется поток исполнения. Разумеется, на системном уровне поток по-прежнему остается элементарной единицей исполнения, которую можно планировать средствами операционной системы. Но соответствие экземпляра объекта класса Task и потока исполнения не обязательно оказывается взаимно-однозначным.
Кроме того, исполнением задач управляет планировщик задач, который работает с пулом потоков. Это, например, означает, что несколько задач могут разделять один и тот же поток. Класс Task (и вся остальная библиотека TPL) определены в пространстве имен System.Threading.Tasks.
Создание задачи
Создать новую задачу в виде объекта класса Task и начать ее исполнение можно самыми разными способами. Для начала создадим объект типа Task с помощью конструктора и запустим его, вызвав метод Start(). Для этой цели в классе Task определено несколько конструкторов. Ниже приведен тот конструктор, которым мы собираемся воспользоваться:
где действие обозначает точку входа в код, представляющий задачу, тогда как Action — делегат, определенный в пространстве имен System. Форма делегата Action, которой мы собираемся воспользоваться, выглядит следующим образом:
Таким образом, точкой входа должен служить метод, не принимающий никаких параметров и не возвращающий никаких значений. (Как будет показано далее, делегату Action можно также передать аргумент.)
Как только задача будет создана, ее можно запустить на исполнение, вызвав метод Start(). После вызова метода Start() планировщик задач запланирует исполнение задачи.
В приведенной ниже программе все изложенное выше демонстрируется на практике. В этой программе отдельная задача создается на основе метода MyTask(). После того как начнет выполняться метод Main(), задача фактически создается и запускается на исполнение. Оба метода MyTask() и Main() выполняются параллельно:

Следует иметь в виду, что по умолчанию задача исполняется в фоновом потоке. Следовательно, при завершении создающего потока завершается и сама задача. Именно поэтому в рассматриваемой здесь программе метод Thread.Sleep() использован для сохранения активным основного потока до тех пор, пока не завершится выполнение метода MyTask(). Как и следовало ожидать, организовать ожидание завершения задачи можно и более совершенными способами.
В отношении задач необходимо также иметь в виду следующее: после того, как задача завершена, она не может быть перезапущена. Следовательно, иного способа повторного запуска задачи на исполнение, кроме создания ее снова, не существует.
Применение идентификатора задачи
В отличие от класса Thread, в классе Task отсутствует свойство Name для хранения имени задачи. Но вместо этого в нем имеется свойство Id для хранения идентификатора задачи, по которому можно распознавать задачи. Свойство Id доступно только для чтения и относится к типу int.
Каждая задача получает идентификатор, когда она создается. Значения идентификаторов уникальны, но не упорядочены. Поэтому один идентификатор задачи может появиться перед другим, хотя он может и не иметь меньшее значение.
Идентификатор исполняемой в настоящий момент задачи можно выявить с помощью свойства CurrentId. Это свойство доступно только для чтения, относится к типу static и объявляется следующим образом:
Оно возвращает исполняемую в настоящий момент задачу или же пустое значение, если вызывающий код не является задачей.
В приведенном ниже примере программы создаются две задачи и показывается, какая из них исполняется:
Параллельное программирование и библиотека TPL
В эпоху многоядерных машин, которые позволяют параллельно выполнять сразу несколько процессов, стандартных средств работы с потоками в .NET уже оказалось недостаточно. Поэтому во фреймворк .NET была добавлена библиотека параллельных задач TPL (Task Parallel Library), основной функционал которой располагается в пространстве имен System.Threading.Tasks . Данная библиотека упрощает работу с многопроцессорными, многоядерными система. Кроме того, она упрощает работу по созданию новых потоков. Поэтому обычно рекомендуется использовать именно TPL и ее классы для создания многопоточных приложений, хотя стандартные средства и класс Thread по-прежнему находят широкое применение.
В основе библиотеки TPL лежит концепция задач, каждая из которых описывает отдельную продолжительную операцию. В библиотеке классов .NET задача представлена специальным классом — классом Task , который находится в пространстве имен System.Threading.Tasks . Данный класс описывает отдельную задачу, которая запускается асинхронно в одном из потоков из пула потоков. Хотя ее также можно запускать синхронно в текущем потоке.
Для определения и запуска задачи можно использовать различные способы.
Первый способ создание объекта Task и вызов у него метода Start:
В качестве параметра объект Task принимает делегат Action, то есть мы можем передать любое действие, которое соответствует данному делегату, например, лямбда-выражение, как в данном случае, или ссылку на какой-либо метод. То есть в данном случае при выполнении задачи на консоль будет выводиться строка «Hello Task!».
А метод Start() собственно запускает задачу.
Второй способ заключается в использовании статического метода Task.Factory.StartNew() . Этот метод также в качестве параметра принимает делегат Action, который указывает, какое действие будет выполняться. При этом этот метод сразу же запускает задачу:
В качестве результата метод возвращает запущенную задачу.
Третий способ определения и запуска задач представляет использование статического метода Task.Run() :
Метод Task.Run() также в качестве параметра может принимать делегат Action — выполняемое действие и возвращает объект Task.
Определим небольшую программу, где используем все эти способы:
Итак, в данном коде задачи создаются и запускаются, но при выполнении приложения на консоли мы можем не увидеть ничего. Почему? Потому что когда поток задачи запускается из основного потока программы — потока метода Main, приложение может завершить выполнение до того, как все три или даже хотя бы одна из трех задач начнет выполнение. Чтобы этого не произошло, мы можем программным образом ожидать завершения задачи.
Ожидание завершения задачи
Чтобы приложение ожидало завершения задачи, можно использовать метод Wait() объекта Task:
Возможный консольный вывод программы:
Консольный вывод не детерминирован, поскольку задачи не выполняются последовательно. Первая запущенная задача может завершить свое выполнение после последней задачи.
Стоит отметить, что метод Wait() блокирует вызывающий поток, в котором запущена задача, пока эта задача не завершит свое выполнение. Например:
Для эмуляции долговременной работы здесь в задаче task1 устанавливается задержка на 1 секунду. В итоге, когда выполнение дойдет до вызова task1.Wait() основной поток остановит свое выполнение и будет ждать завершения задачи. И мы получим следующий консольный вывод:
Если подобное поведение не принципиально, то ожидание завершения задачи можно поместить в конец метода Main:
В этом случае приложение все равно будет ждать завершения задачи, однако другие синхронные действия в основном потоке не будут блокироваться и ожидать завершения задачи.
Синхронный запуск задачи
По умолчанию задачи запускаются асинхронно. Однако с помощью метода RunSynchronously() можно запускать синхронно:
Свойства класса Task
Класс Task имеет ряд свойств, с помощью которых мы можем получить информацию об объекте. Некоторые из них:
AsyncState : возвращает объект состояния задачи
CurrentId : возвращает идентификатор текущей задачи (статическое свойство)
Id : возвращает идентификатор текущей задачи
Exception : возвращает объект исключения, возникшего при выполнении задачи
Status : возвращает статус задачи. Представляет перечисление System.Threading.Tasks.TaskStatus , которое имеет следующие значения:
Canceled : задача отменена
Created : задача создана, но еще не запущена
Faulted : в процессе работы задачи произошло исключение
RanToCompletion : задача успешно завершена
Running : задача запущена, но еще не завершена
WaitingForActivation : задача ожидает активации и постановки в график выполнения
WaitingForChildrenToComplete : задача завершена и теперь ожидает завершения прикрепленных к ней дочерних задач
WaitingToRun : задача поставлена в график выполнения, но еще не начала свое выполнение
IsCompleted : возвращает true, если задача завершена
IsCanceled : возвращает true, если задача была отменена
IsFaulted : возвращает true, если задача завершилась при возникновении исключения
IsCompletedSuccessfully : возвращает true, если задача завершилась успешно
Таск что это в программировании
В основу TPL положен класс Task. Элементарная единица исполнения инкапсулируется в TPL средствами класса Task, а не Thread. Класс Task отличается от класса Thread тем, что он является абстракцией, представляющей асинхронную операцию. А в классе Thread инкапсулируется поток исполнения. Разумеется, на системном уровне поток по-прежнему остается элементарной единицей исполнения, которую можно планировать средствами операционной системы. Но соответствие экземпляра объекта класса Task и потока исполнения не обязательно оказывается взаимно-однозначным.
Кроме того, исполнением задач управляет планировщик задач, который работает с пулом потоков. Это, например, означает, что несколько задач могут разделять один и тот же поток. Класс Task (и вся остальная библиотека TPL) определены в пространстве имен System.Threading.Tasks.
Создание задачи
Создать новую задачу в виде объекта класса Task и начать ее исполнение можно самыми разными способами. Для начала создадим объект типа Task с помощью конструктора и запустим его, вызвав метод Start(). Для этой цели в классе Task определено несколько конструкторов. Ниже приведен тот конструктор, которым мы собираемся воспользоваться:
где действие обозначает точку входа в код, представляющий задачу, тогда как Action — делегат, определенный в пространстве имен System. Форма делегата Action, которой мы собираемся воспользоваться, выглядит следующим образом:
Таким образом, точкой входа должен служить метод, не принимающий никаких параметров и не возвращающий никаких значений. (Как будет показано далее, делегату Action можно также передать аргумент.)
Как только задача будет создана, ее можно запустить на исполнение, вызвав метод Start(). После вызова метода Start() планировщик задач запланирует исполнение задачи.
В приведенной ниже программе все изложенное выше демонстрируется на практике. В этой программе отдельная задача создается на основе метода MyTask(). После того как начнет выполняться метод Main(), задача фактически создается и запускается на исполнение. Оба метода MyTask() и Main() выполняются параллельно:

Следует иметь в виду, что по умолчанию задача исполняется в фоновом потоке. Следовательно, при завершении создающего потока завершается и сама задача. Именно поэтому в рассматриваемой здесь программе метод Thread.Sleep() использован для сохранения активным основного потока до тех пор, пока не завершится выполнение метода MyTask(). Как и следовало ожидать, организовать ожидание завершения задачи можно и более совершенными способами.
В отношении задач необходимо также иметь в виду следующее: после того, как задача завершена, она не может быть перезапущена. Следовательно, иного способа повторного запуска задачи на исполнение, кроме создания ее снова, не существует.
Применение идентификатора задачи
В отличие от класса Thread, в классе Task отсутствует свойство Name для хранения имени задачи. Но вместо этого в нем имеется свойство Id для хранения идентификатора задачи, по которому можно распознавать задачи. Свойство Id доступно только для чтения и относится к типу int.
Каждая задача получает идентификатор, когда она создается. Значения идентификаторов уникальны, но не упорядочены. Поэтому один идентификатор задачи может появиться перед другим, хотя он может и не иметь меньшее значение.
Идентификатор исполняемой в настоящий момент задачи можно выявить с помощью свойства CurrentId. Это свойство доступно только для чтения, относится к типу static и объявляется следующим образом:
Оно возвращает исполняемую в настоящий момент задачу или же пустое значение, если вызывающий код не является задачей.
В приведенном ниже примере программы создаются две задачи и показывается, какая из них исполняется:
Параллельное программирование в C#: создание и выполнение задач (Task)
Класс Task
Варианты определение и запуска задач Task
1. Вызова конструктора задачи и запуск путем вызова метода Start()
В качестве параметра в конструкторе Task используется делегат Action :
таким образом, мы можем передать в качестве параметра любое действие, соответствующее данному делегату например, лямбда-выражение, как в примере выше, или ссылку на какой-либо метод, например,
Таким образом, в примере выше, в консоль будет выведена строка «Action», хотя, никто не запрещает вам наполнить метод MyAction какими-либо более сложными действиями.
2. Создание задачи и её запуск с использованием метода StartNew()
3. Создание задачи и её запуск с использованием метода Run()
Третий способ создания и запуска задачи — использование метода Run() :
4. Создание задачи и её синхронный запуск с использованием метода RunSynchronously()
Какой способ вы бы не использовали, методы создания и запуска задач Task в TPL имеют перегруженные версии, позволяющие указывать различные параметры задач, порядок их выполнения и так далее.
Выполнение и ожидание выполнения задач
Когда вы используете в своей работе приемы параллельного программирования, то без вашего прямого указания последовательности выполнения задач, никто не гарантирует, что задачи будут выполняться в той же последовательности, в которой они создавались. Чтобы продемонстрировать это напишем вот такой небольшой пример:
В итоге, в консоли можно увидеть следующие строки:
task2 Task=2, Thread=6
task Task=1, Thread=5
task4 Task=4, Thread=4
task3 Task=3, Thread=7
Обратите внимание на три момента:
Вывод в консоли в итоге будет следующий:
LongAction ещё выполняется
LongAction выполнена, запускаем остальные задачи
task2 Task=2, Thread=5
task3 Task=3, Thread=6
task4 Task=4, Thread=8
Итого
Сегодня мы, в общих чертах, познакомились с тем как работать с задачами при параллельном программировании в C# с использованием библиотеки TPL. Механизм использования задач (Task) позволяет достаточно просто и интуитивно понятно организовать параллельное выполнение нескольких задач в вашей программе, не прибегая к использованию потоков (Thread).
Tasks и Back Stack

Может возникнуть вопрос: а как же фрагменты? Как они сохраняются в стеке? У них всё устроено несколько иначе, чем у активити: фрагмент помещается в back stack, управляемый активити и то, только если был вызван соответствующий метод ( addToBackStack() ) во время транзакции.

В версии Android 7.0 была добавлена поддержка многооконного режима: пользователь может разделить экран и таким образом работать с несколькими приложениями. В таком режиме система управляет task’ами отдельно для каждого окна, т.е. у каждого окна может быть несколько task’ов.
Визуально task’и можно увидеть на экране последних запущенных задач:

Управление task’ами
Некоторые приложения спроектированы таким образом, что есть несколько точек перемещения к одной и той же активити. Несмотря на то, что такая активити уже может находится в стеке, каждый раз будет создаваться её новый экземпляр и также сохраняться в стек. Таким образом, когда пользователь решит переместиться к самой первой активити, он увидит все открытые им, казалось бы одинаковые активити, но в разном состоянии. Подобного эффекта можно избежать при помощи специальных атрибутов манифеста и флагов для Intent.
Обратите внимание, что иногда атрибуты в манифесте и флаги в Intent могут противоречить друг другу. В этом случаи флаги Intent будут более приоритетны.
Атрибуты
launchMode
Данный атрибут можно указать для каждой активити в манифесте. Имеет несколько значений:
Флаги
Виды флагов:
Очистка стека
Если task долгое время находится в фоне, то система сама чистит его стек, оставляя только корневую активити. Подобное поведение объясняется тем, что по прошествии длительного времени пользователь, вероятно, забыл, что он делал в приложении и открыл его повторно уже с иной целью.
У активити существует три атрибута для изменения такого поведения:
Как работать с классом Task в C#: разбираем на примерах

«Чего так синхронно?»
Содержание статьи:
Задачи класса Task в С#
Давайте рассмотрим каждый случай подробнее:
Run(Action)
Например, можно использовать Run(Action) для программы, которая реализует цикл в цикле, а потом выводит на экран количество произведенных итераций. Код при этом будет выглядеть вот так:
TaskFactory.StartNew
Также в классе Task предусмотрены конструкторы, инициализирующие задачу, которые при этом не планируют ее выполнение.
Ожидание задачи
Реализация задач в потоке ведется асинхронно. Поток продолжает свою работу сразу же после формирования экземпляра. В определенных ситуациях вызывающий поток — главный в программе, поэтому он может завершиться еще до того, как задача начнет выполняться.
Task.Wait
Метод Task.Wait ставит блок на вызывающий поток до окончания действия экземпляра класса. Если метод активировать без дополнительных параметров, произойдет ожидание окончания задачи без условий. Рассмотрим пример, в котором вызов метода Thread.Sleep.Task переключит режим сна на несколько секунд:
Ожидать окончание выполнения задачи можно и другим образом. Например, методы Wait(Int32) и Wait(TimeSpan) устанавливают блокировку на вызывающий поток до момента окончания задачи или периода ожидания:
Давайте рассмотрим пример с временем ожидания запуска в 1 секунду. Это значит, что код блокирует поток до истечения этого времени:
Task.lock
Тask lock в C# — это блокировка задач, которая позволяет им оставаться синхронизированными с другой или с общей задачей даже при запуске других задач. Например:
Токен отмены
Task.WaitAny(tasks)
Task.WaitAll(tasks)
Отметим, что при ожидании одной или более задач все исключения привязываются к методу Wait и распространяются в его потоке.
Свойства класса Task в C#
Класс Task обладает рядом свойств. Давайте познакомимся с ними.
Task vs Thread в С#
Давайте посмотрим на фрагмент кода, в котором генерируются две задачи:

Task parallel library в С# (example)
Также Task используется в библиотеке параллельных задач TPL, которая представляет собой набор открытых типов и API-интерфейсов. При его использовании необходимо поверх вашего класса подключать пространство имен, для чего используется:
Например, создать задачу можно следующим образом:
Вложенные задачи
Вложенные задачи образуются тогда, когда одна задача может запускать внутри себя другую. При этом обе задачи будут работать независимо друг от друга.


Это означает, что внутренняя задача присоединена к наружной. Она будет выполняться как ее часть, а значит внешняя может завершить свою работу только после вложенной.
Массив задач

Напомним, что если требуется, чтобы определенный код работал только после завершения всех задач, то используется метод Task.WaitAll(tasks) :

Task result в С#: результаты работы
Задачи могут выполняться не только как процедуры. Они также могут возвращать результаты.
Давайте разберем подробнее:
Заключение
В статье мы рассмотрели класс Task в языке C#. Подробно изучили задачи и их выполнение, а также принципы работы с классом. Он является основой для асинхронного программирования. Такой подход позволяет полностью использовать мощность многоядерных процессоров.
Видео: вводный рассказ про класс Task и его использование для параллелизации выполнения задач
.NET: Инструменты для работы с многопоточностью и асинхронностью. Часть 1
Публикую на Хабр оригинал статьи, перевод которой размещен в блоге Codingsight.
Вторая часть доступна здесь
Терминология
Процесс (Process) — объект ОС, изолированное адресное пространство, содержит потоки.
Поток (Thread) — объект ОС, наименьшая единица выполнения, часть процесса, потоки делят память и другие ресурсы между собой в рамках процесса.
Многозадачность — свойство ОС, возможность выполнять несколько процессов одновременно
Многоядерность — свойство процессора, возможность использовать несколько ядер для обработки данных
Многопроцессорность — свойство компьютера, возможность одновременно работать с несколькими процессорами физически
Многопоточность — свойство процесса, возможность распределять обработку данных между несколькими потоками.
Параллельность — выполнение нескольких действий физически одновременно в единицу времени
Асинхронность — выполнение операции без ожидания окончания завершения этой обработки, результат же выполнения может быть обработан позднее.
Метафора
Не все определения хороши и некоторые нуждаются в дополнительном объяснении, потому к формально введенной терминологии добавлю метафору о приготовлении завтрака. Приготовление завтрака в этой метафоре — process.
Готовя завтрак с утра я (CPU) прихожу на кухню (Компьютер). У меня 2 руки (Cores). На кухне есть ряд устройств (IO): печь, чайник, тостер, холодильник. Я включаю газ, ставлю на него сковородку и наливаю туда масло, не дожидаясь пока она разогреется (асинхронно, Non-Blocking-IO-Wait), я достаю из холодильника яйца и разбиваю их в тарелку, после чего взбиваю одной рукой (Thread#1), а второй (Thread#2) придерживаю тарелку (Shared Resource). Сейчас бы еще включить чайник, но рук не хватает (Thread Starvation) За это время разогревается сковородка (Обработка результата) куда я выливаю то что взбил. Я дотягиваюсь до чайника и включаю его и тупо смотрю как вода в нем закипает (Blocking-IO-Wait), хотя мог бы за это время вымыть тарелку, где взбивал омлет.
Я готовил омлет используя всего 2 руки, да больше у меня и нет, но при этом в момент взбивания омлета происходило сразу 3 операции: взбивание омлета, придерживание тарелки, разогревание сковородки.CPU — является самой быстрой частью компьютера, IO это то, что чаще всего тормозит, потому часто эффективным решением является занять чем-то CPU пока идет получение данных от IO.
Под инструментами я имею ввиду как программные интерфейсы (API) предоставляемые фреймворком и сторонними пакетами, так и целый программные решения упрощающий поиск каких-либо проблем связанных с многопоточным кодом.
Запуск потока
С момента старта приложение в фоне создает несколько потоков про запас и предоставляет возможность брать их в пользование. Если потоки используются часто и в большом количестве, то пул расширяется, чтобы удовлетворить потребность вызывающего кода. Когда в пуле в нужный момент времени не оказывается свободных потоков он или дождется возврата одного из потоков, либо создаст новый. Из этого следует, что пул потоков отлично подходит для неких коротких действий и плохо подходит, для операций работающих как службы на протяжении всей работы приложений.
Для использования потока из пула, есть метод QueueUserWorkItem, который принимает делегат типа WaitCallback, что по сигнатуре совпадает с ParametrizedThreadStart, а передаваемый в него параметр выполняет туже функцию.
Менее известный метод пула потоков RegisterWaitForSingleObject служит для организации неблокирующих IO операций. Делегат переданный в этот метод будет вызван тогда, когда WaitHandle переданный в метод будет “отпущен”(Released).
Так же есть довольно экзотический способ отправить делегат на выполнение в поток из пула — метод BeginInvoke.
Хочу еще вскользь остановится на функции к вызову которой сводится многие из вышеуказанных методов — CreateThread из Kernel32.dll Win32 API. Существует способ, благодаря механизму extern методов вызвать эту функцию. Я видел такой вызов лишь однажды в жутчайшем примере legacy кода, а мотивация автора сделавшего именно так все еще остается для меня загадкой.
Просмотр и отладка потоков
Task Parallel Library
Рассмотрим варианты запуска и использования Task’а. На примере кода ниже, мы создаем новый таск, который не делает ничего полезного (Thread.Sleep(10000)), но в реальной жизни это должна быть некая сложная задействующая CPU работа.
Task создается с рядом опций:
Последним параметром передан объект scheduler типа TaskScheduler. Этот класс и его наследники предназначены для управления стратегиями распределения Task’ов по потокам, по умолчанию Task будет выполнен на случайном потоке из пула.
К созданному Task’у применен оператор await, а значит код написанный после него, если такой есть будет выполнен в том же контексте (часто это означает что на том же потоке), что и код до await.
Метод помечен как async void, это значит, что в нем допустимо использование оператора await, но вызывающий код не сможет дождаться выполнения. Если такая возможность необходима, то метод должен возвращать Task. Методы помеченные async void встречаются довольно часто: как правило это обработчики событий или другие методы, работающие по принципу выполнить и забыть (fire and forget). Если необходимо не только дать возможность дождаться окончания выполнения, но и вернуть результат, то необходимо использовать Task.
На Task’е что вернул метод StartNew, впрочем как и на любом другом, можно вызвать метод ConfigureAwait с параметром false, тогда выполнение после await продолжится не на захваченном контексте, а на произвольном. Это нужно делать всегда, когда для кода после await не принципиален контекст выполнения. Также это является рекомендацией от MS при написании кода, что будет поставляться упакованном в библиотеку виде.
Давайте еще немного остановимся на том, как можно дождаться окончания выполнения Task’и. Ниже пример кода, с комментариями, когда ожидание сделано условно хорошо и когда условно плохо.
В первом примере мы дожидаемся выполнения Task’и не блокируя вызывающий поток, к обработке результата вернемся лишь когда он уже будет, до тех пор вызывающий поток предоставлен себе.
Во втором варианте мы блокируем вызывающий поток до тех пор пока не будет подсчитан результат метода. Это плохо не только потому, что мы заняли поток, столь ценный ресурс программы, простым безделием, но еще и потому, что если в коде метода что мы вызываем есть await, а контекст синхронизации предполагает возвращение в вызывающий поток после await, то мы получим deadlock: вызывающий поток ждет пока будет вычислен результат асинхронного метода, асинхронный метод тщетно пытается продолжить свое выполнение в вызывающем потоке.
Еще одним недостатком такого подхода является усложненная обработка ошибок. Дело в том, что ошибки в асинхронном коде при использовании async/await обрабатывать очень легко — они ведут себя так же как если бы код был синхронным. В то время, как если мы применяем экзорцизм синхронное ожидание к Task’e оригинальное исключение оборачивается в AggregateException, т.о. Для обработки исключения придется исследовать тип InnerException и самому писать цепочку if внутри одного catch блока или использовать конструкцию catch when, вместо более привычной в C# мире цепочки catch блоков.
Третий и последний примеры так же отмечены плохими по той же причине и содержат все те же проблемы.
Методы WhenAny и WhenAll крайне удобны в ожидании группы Task’ов, они оборачивают группу Task’ов в один, который сработает либо по первому срабатыванию Task’а из группы, либо когда свое выполнение закончат все.
Остановка потоков
Метод Interrupt работает более предсказуемо. Он может прервать поток исключением ThreadInterruptedException только в те моменты, когда поток находится в состоянии ожидания. В такое состояние он переходит подвисая в ожидании WaitHandle, lock или после вызова Thread.Sleep.
Оба описанных выше варианта, плохи своей непредсказуемостью. Выходом является использование структуры CancellationToken и класса CancellationTokenSource. Суть в следующем: создается экземпляр класса CancellationTokenSource и только тот кто им владеет, может остановить операцию вызвав метод Cancel. В саму же операцию передается только лишь CancellationToken. Владельцы CancellationToken не могут сами отменить операцию, а могут лишь проверить не была ли операция отменена. Для этого есть булево свойство IsCancellationRequested и метод ThrowIfCancelRequested. Последний сгенерирует исключение TaskCancelledException если на пародившем CancellationToken экземпляре CancellationTokenSource был вызван метод Cancel. И именно этот метод я рекомендую использовать. Это лучше предыдущих вариантов получением полного контроля над тем в какие моменты исключение операция может быть прервана.
Самым жестоким вариантом остановки потока, является вызов функции Win32 API TerminateThread. Поведение CLR после вызова этой функции может быть непредсказуемым. На MSDN же про эту функцию написано следующее: “TerminateThread is a dangerous function that should only be used in the most extreme cases. “
Преобразование legacy-API в Task Based с помощью метода FromAsync
Это лишь пример и делать такое со встроенными типами вам вряд ли придется, но любой старый проект просто кишит методами BeginDoSomething возвращающими IAsyncResult и методами EndDoSomething его принимающими.
Преобразование legacy-API в Task Based с помощью класса TaskCompletionSource
Еще один важный для рассмотрения инструмент, это класс TaskCompletionSource. По функциям, назначению и принципу работы он чем-то может напомнить метод RegisterWaitForSingleObject класса ThreadPool о котором я писал выше. С помощью этого класса можно легко и удобно оборачивать старые асинхронные API в Task’и.
TaskCompletionSource как раз отлично подходит для обертки в Task’и legacy-API построенных вокруг событийной модели. Суть его работы в следующем: у объекта этого класса есть публичное свойство типа Task состоянием которого можно управлять через методы SetResult, SetException и пр. Класса TaskCompletionSource. В местах же где был применен оператор await к этому Task’у он будет выполнен или обрушен с исключением в зависимости от примененного к TaskCompletionSource метода. Если все еще не понятно, то давайте посмотрим на этот пример кода, где некое старое API времен EAP заворачивается в Task при помощи TaskCompletionSource: при срабатывании события Task будет переведен в состояние Completed, а метод применивший к этому Task’у оператор await возобновит свое выполнение получив объект result.
TaskCompletionSource Tips & Tricks
Обертка старых API это не все что можно провернуть с помощью TaskCompletionSource. Использование этого класса открывает интересную возможность проектирования различных API, на Task’ах, что не занимают потоки. А поток, как мы помним ресурс дорогой и количество их ограничено (в основном объемом RAM). Этого ограничения легко достичь разрабатывая, например, нагруженное web-приложение со сложной бизнес логикой. Рассмотрим те возможности о которых я говорю на реализации такого трюка как Long-Polling.
Если коротко суть трюка вот в чем: вам нужно получать от API информацию о некоторых событиях происходящих на его стороне, при этом API по каким-то причинам не может сообщить о событии, а может лишь вернуть состояние. Пример таких — все API построенные поверх HTTP до времен WebSocket или при невозможности по какой-то причине использовать эту технологию. Клиент может спросить у HTTP сервера. HTTP сервер не может сам спровоцировать общение с клиентом. Простым решением является опрос сервера по таймеру, но это создает дополнительную нагрузку на сервер и дополнительную задержку в среднем TimerInterval / 2. Для обхода этого был изобретен трюк получивший название Long Polling, которые предполагает задержку ответа от сервера до тех пор пока не истечет Timeout или не произойдет событие. Если событие произошло, то оно обрабатывается, если нет, то запрос посылается заново.
Но такое решение покажет себя ужасно, как только число клиентом ожидающих событие вырастет, т.к. Каждый такой клиент в ожидании события занимает целый поток. Да и получаем дополнительную задержку в 1мс на срабатывании события, чаще всего это не существенно, но зачем делать ПО хуже чем оно может быть? Если же убрать Thread.Sleep(1), то зазря загрузим одно ядро процессора на 100% в холостую вращаясь в бесполезном цикле. С помощью TaskCompletionSource можно легко переделать этот код и решить все обозначенные выше проблемы:
Этот код не является production-ready, а лишь демонстрационным. Для использования в реальных случаях нужно еще, как-минимум, обработать ситуацию когда сообщение пришло в момент, когда его никто не ожидает: в таком случае метод AsseptMessageAsync должен вернуть уже завершенный Task. Если же этот случай и является наиболее частым, то можно подумать и об использовании ValueTask.
При получении запроса на сообщение мы создаем и помещаем в словарь TaskCompletionSource, а далее ждем что произойдет первее: истечет заданный интервал времени или будет получено сообщение.
ValueTask: зачем и как
Из-за желания немного оптимизировать, и легкой боязни по-поводу того что сгенерирует Roslyn компилируя этот код, можно этот пример переписать следующим образом:
Действительно же оптимальным решением в этом случае будет оптимизировать hot-path, а именно получение значения из словаря вообще без лишних аллокаций и нагрузки на GC, в то время когда в тех редких случаях, когда нам все таки нужно идти в IO за данными все останется плюс/минус по старому:
Давайте подробнее разберем этот фрагмент кода: при наличиии значения в кеше мы создаем структуру, в противном случае реальный же таск будет завернут в значимый. Вызывающему коду все равно по какому пути выполнялся этот код: ValueTask с точки зрения синтаксиса C# будет вести себя так же как и обычный Task в этом случае.
TaskScheduler’ы: управление стратегиями запуска Task’ов
Для удобной отладки всего связанного с Task’ами в Visual Studio есть окно Tasks. В этом окне можно увидеть текущее состояние задачи и перейти к выполняемой в данный момент строчке кода.
PLinq и класс Parallel
Статический класс Parallel предоставляет методы для параллельного перебора коллекции Foreach, выполнения цикла For и выполнения нескольких делегатов в параллель Invoke. Выполнение текущего потока будет остановлено до окончания выполнения расчетов. Количество потоков можно сконфигурировать передав ParallelOptions последним аргументом. С помощью опций также можно указать TaskScheduler и CancellationToken.
Выводы
Когда я начинал писать эту статью по материалам своего доклада и информации что собрал за время работы после него, я не ожидал, что ее получится так много. Сейчас, когда текстовый редактор в котором я набираю эту статью укоризненно говорит мне о том, что пошла 15я страница, я подведу промежуточные итоги. Другие трюки, API, визуальные инструменты и подводные камни будут рассмотрены в следующей статье.
Name already in use
OAP / articles / t5_thread_async.md
- Go to file T
- Go to line L
- Copy path
- Copy permalink
- Open with Desktop
- View raw
- Copy raw contents Copy raw contents
Copy raw contents
Copy raw contents
Введение в многопоточность. Класс Thread
Одним из ключевых аспектов в современном программировании является многопоточность. Ключевым понятием при работе с многопоточностью является поток. Поток предствляет некоторую часть кода программы. При выполнении программы каждому потоку выделяется определенный квант времени. И при помощи многопоточности мы можем выделить в приложении несколько потоков, которые будут выполнять различные задачи одновременно. Если у нас, допустим, графическое приложение, которое посылает запрос к какому-нибудь серверу или считывает и обрабатывает огромный файл, то без многопоточности у нас бы блокировался графический интерфейс на время выполнения задачи. А благодаря потокам мы можем выделить отправку запроса или любую другую задачу, которая может долго обрабатываться, в отдельный поток. Поэтому, к примеру, клиент-серверные приложения (и не только они) практически не мыслимы без многопоточности.
Основной функционал для использования потоков в приложении сосредоточен в пространстве имен System.Threading. В нем определен класс, представляющий отдельный поток — класс Thread.
Параллельное программирование и библиотека TPL
В эпоху многоядерных машин, которые позволяют параллельно выполнять сразу несколько процессов, стандартных средств работы с потоками в .NET уже оказалось недостаточно. Поэтому во фреймворк .NET была добавлена библиотека параллельных задач TPL (Task Parallel Library), основной функционал которой располагается в пространстве имен System.Threading.Tasks. Данная библиотека позволяет распараллелить задачи и выполнять их сразу на нескольких процессорах, если на целевом компьютере имеется несколько ядер. Кроме того, упрощается сама работа по созданию новых потоков. Поэтому начиная с .NET 4.0. рекомендуется использовать именно TPL и ее классы для создания многопоточных приложений, хотя стандартные средства и класс Thread по-прежнему находят широкое применение.
Задачи и класс Task
В основе библиотеки TPL лежит концепция задач, каждая из которых описывает отдельную продолжительную операцию. В библиотеке классов .NET задача представлена специальным классом — классом Task, который находится в пространстве имен System.Threading.Tasks. Данный класс описывает отдельную задачу, которая запускается асинхронно в одном из потоков из пула потоков. Хотя ее также можно запускать синхронно в текущем потоке.
Для определения и запуска задачи можно использовать различные способы. Первый способ создание объекта Task и вызов у него метода Start:
В качестве параметра объект Task принимает делегат Action, то есть мы можем передать любое действие, которое соответствует данному делегату, например, лямбда-выражение, как в данном случае, или ссылку на какой-либо метод. То есть в данном случае при выполнении задачи на консоль будет выводиться строка «Hello Task!».
А метод Start() собственно запускает задачу.
Второй способ заключается в использовании статического метода Task.Factory.StartNew(). Этот метод также в качестве параметра принимает делегат Action, который указывает, какое действие будет выполняться. При этом этот метод сразу же запускает задачу:
В качестве результата метод возвращает запущенную задачу.
Третий способ определения и запуска задач представляет использование статического метода Task.Run():
Метод Task.Run() также в качестве параметра может принимать делегат Action — выполняемое действие и возвращает объект Task.
Определим небольшую программу, где используем все эти способы:
Важно понимать, что задачи не выполняются последовательно. Первая запущенная задача может завершить свое выполнение после последней задачи.
Или рассмотрим еще один пример:
Класс Task в качестве параметра принимает метод Display, который соответствует делегату Action. Далее чтобы запустить задачу, вызываем метод Start: task.Start() , и после этого метод Display начнет выполняться во вторичном потоке. В конце метода Main выводит некоторый маркер-строку, что метод Main завершился.
Однако в данном случае консольный вывод может выглядеть следующим образом:
То есть мы видим, что даже когда основной код в методе Main уже отработал, запущенная ранее задача еще не завершилась.
Чтобы указать, что метод Main должен подождать до конца выполнения задачи, нам надо использовать метод Wait:
Свойства класса Task
Класс Task имеет ряд свойств, с помощью которых мы можем получить информацию об объекте. Некоторые из них:
AsyncState: возвращает объект состояния задачи
CurrentId: возвращает идентификатор текущей задачи
Exception: возвращает объект исключения, возникшего при выполнении задачи
Status: возвращает статус задачи
Aсинхронное программирование. Асинхронные методы, async и await
Асинхронность позволяет вынести отдельные задачи из основного потока в специальные асинхронные методы или блоки кода. Особенно это актуально в графических программах, где продолжительные задачи могу блокировать интерфейс пользователя. И чтобы этого не произошло, нужно задействовать асинхронность. Также асинхронность несет выгоды в веб-приложениях при обработке запросов от пользователей, при обращении к базам данных или сетевым ресурсам. При больших запросах к базе данных асинхронный метод просто уснет на время, пока не получит данные от БД, а основной поток сможет продолжить свою работу. В синхронном же приложении, если бы код получения данных находился в основном потоке, этот поток просто бы блокировался на время получения данных.
Ключевыми для работы с асинхронными вызовами в C# являются два ключевых слова: async и await, цель которых — упростить написание асинхронного кода. Они используются вместе для создания асинхронного метода.
Асинхонный метод обладает следующими признаками:
В заголовке метода используется модификатор async
Метод содержит одно или несколько выражений await
В качестве возвращаемого типа используется один из следующих:
- void
- Task
- Task<T>
- ValueTask<T>
Асинхронный метод, как и обычный, может использовать любое количество параметров или не использовать их вообще. Однако асинхронный метод не может определять параметры с модификаторами out и ref.
Также стоит отметить, что слово async, которое указывается в определении метода, не делает автоматически метод асинхронным. Оно лишь указывает, что данный метод может содержать одно или несколько выражений await.
Рассмотрим пример асинхронного метода:
Здесь прежде всего определен обычный метод подсчета факториала. Для имитации долгой работы в нем используется задержка на 8 секунд с помощью метода Thread.Sleep() . Условно это некоторый метод, который выполняет некоторую работу продолжительное время. Но для упрощения понимания он просто подсчитывает факториал числа 6.
Также здесь определен асинхронный метод FactorialAsync(). Асинхронным он является потому, что имеет в определении перед возвращаемым типом модификатор async, его возвращаемым типом является void, и в теле метода определено выражение await.
Выражение await определяет задачу, которая будет выполняться асинхронно. В данном случае подобная задача представляет выполнение функции факториала:
По негласным правилам в названии асинхроннных методов принято использовать суффикс Async — FactorialAsync(), хотя в принципе это необязательно делать.
Сам факториал мы получаем в асинхронном методе FactorialAsync. Асинхронным он является потому, что он объявлен с модификатором async и содержит использование ключевого слова await.
И в методе Main мы вызываем этот асинхронный метод.
Посмотрим, какой у программы будет консольный вывод:
Разберем поэтапно, что здесь происходит:
Запускается метод Main, в котором вызывается асинхронный метод FactorialAsync.
Метод FactorialAsync начинает выполняться синхронно вплоть до выражения await.
Выражение await запускает асинхронную задачу Task.Run(()=>Factorial())
Пока выполняется асинхронная задача Task.Run(()=>Factorial()) (а она может выполняться довольно продожительное время), выполнение кода возвращается в вызывающий метод — то есть в метод Main. В методе Main нам будет предложено ввести число для вычисления квадрата числа.
В этом и преимущество асинхронных методов — асинхронная задача, которая может выполняться довольно долгое время, не блокирует метод Main, и мы можем продолжать работу с ним, например, вводить и обрабатывать данные.
Когда асинхронная задача завершила свое выполнение (в случае выше — подсчитала факториал числа), продолжает работу асинхронный метод FactorialAsync, который вызвал асинхронную задачу.
Функция факториала, возможно, представляет не самый показательный пример, так как в реальности в данном случае нет смысла делать ее асинхронной. Но рассмотрим другой пример — чтение-запись файла:
Асинхронный метод ReadWriteAsync() выполняет запись в файл некоторой строки и затем считывает записанный файл. Подобные операции могут занимать продолжительное время, особенно при больших объемах данных, поэтому такие операции лучше делать асинхронными.
Фреймворк .NET уже имеет встроенную поддержку таких операций. Например, в классе StreamWriter определен метод WriteLineAsync(). По сути он уже представляет асинхронную операцию и принимает в качестве параметра некоторую строку, которую надо записать в файл. Поскольку этот метод представляет асинхронную операцию, то вызов этого метода мы можем оформить в выражение await:
Аналогично в классе StreamReader определен метод ReadToEndAsync(), который также представляет асинхронную операцию и который возвращает весь считанный текст.
Во фреймворке определено много подобных методов. Как правило, они связаны с работой с файлами, отправкой сетевых запросов или запросов к базе данных. Их легко узнать по суффиксу Async. То есть если метод имеет подобный суффикс в названии, то с большей степенью вероятности его можно использовать в выражении await.
Далее в методе Main вызывается асинхронный метод ReadWriteAsync:
И опять же, когда выполнение в методе ReadWriteAsync доходит до первого выражения await, управление возвращается в метод Main, и мы можем продолжать с ним работу. Запись в файл и считывание файла будут производиться параллельно и не будут блокировать работу метода Main.
Определение асинхронной операции
Как выше уже было сказано, фреймворк .NET имеет много встроенных методов, которые представляют асинхронную операцию. Они заканчиваются на суффикс Async. И перед вызывами подобных методов мы можем указывать оператор await. Например:
Либо мы сами можем определить асинхронную операцию, используя метод Task.Run():
Можно определить асинхронную операцию с помощью лямбда-выражения:
Передача параметров в асинхронную операцию
Выше вычислялся факториал 6, но, допустим, мы хотим вычислять факториалы разных чисел:
Получение результата из асинхронной операции
Асинхронная операция может возвращать некоторый результат, получить который мы можем так же, как и при вызове обычного метода:
Метод Factorial возвращает значение типа int, это значение мы можем получить, просто присвоив результат асинхронной операции переменной данного типа: int x = await Task.Run(()=>Factorial(n));
Возвращение результата из асинхронного метода
В качестве возвращаемого типа в асинхронном методе должны использоваться типы void, Task, Task<T> или ValueTask<T>
При использовании ключевого слова void асинхронный метод ничего не возвращает:
Возвращение объекта типа Task:
Формально метод FactorialAsync не использует оператор return для возвращения результата. Однако если в асинхронном методе выполняется в выражении await асинхронная операция, то мы можем возвращать из метода объект Task.
Метод может возвращать некоторое значение. Тогда возвращаемое значение оборачивается в объект Task, а возвращаемым типом является Task<T>:
В данном случае функция Factorial возвращает значение типа int. В асинхронном методе FactorialAsync мы получаем и возвращаем это число. Поэтому возвращаемым типом в данном случае является типа Task<int>. Если бы метод Factorial возвращал строку, то есть данные типа string, то возвращаемым типом асинхронного метода был бы тип Task<string>
Чтобы получить результат асинхронного метода в методе Main, который тоже определен как асинхронный, применяем оператор await при вызове FactorialAsync.
Использование типа ValueTask<T> во многом аналогично применению Task<T> за исключением некоторых различий в работе с памятью, поскольку ValueTask — структура, а Task — класс. По умолчанию тип ValueTask недоступен, и чтобы использовать его, вначале надо установить через NuGet пакет System.Threading.Tasks.Extensions.
Последовательный и параллельный вызов асинхронных операций
Асинхронный метод может содержать множество выражений await. Когда система встречает в блоке кода оператор await, то выполнение в асинхронном методе останавливается, пока не завершится асинхронная задача. После завершения задачи управление переходит к следующему оператору await и так далее. Это позволяет вызывать асинхронные задачи последовательно в определенном порядке. Например:
Консольный вывод данной программы:
То есть мы видим, что факториалы вычисляются последовательно. И в данном случае вывод строго детерминирован.
Нередко такая последовательность бывает необходима, если одна задача зависит от результатов другой.
Однако не всегда существует подобная зависимость между задачами. В этом случае мы можем запустить все задачи параллельно и через метод Task.WhenAll отследить их завершение. Например, изменим метод FactorialAsync:
Вначале запускаются три задачи. Затем Task.WhenAll создает новую задачу, которая будет автоматически выполнена после выполнения всех предоставленных задач, то есть задач t1, t2, t3. А с помощью оператора await ожидаем ее завершения.
В итоге все три задачи теперь будут запускаться параллельно, однако вызывающий метод FactorialAsync благодаря оператору await все равно будет ожидать, пока они все не завершатся. И в этом случае вывод программы не детерминирован. Например, он может быть следующим:
И если задача возвращает какое-нибудь значение, то это значение потом можно получить с помощью свойства Result.
Обработка ошибок в асинхронных методах
Обработка ошибок в асинхронных методах, использующих ключевые слова async и await, имеет свои особенности.
Для обработки ошибок выражение await помещается в блок try:
В данном случае метод Factorial генерирует исключение, если методу передается число меньше 1.
Для обработки исключения в методе FactorialAsync выражение await помещено в блок try.
В методе Main вызывается асинхронный метод с передачей ему отрицательного числа: FactorialAsync(-4) , что привет к генерации исключения. Однако программа не остановит аварийно свою работу, а обработает исключение и продолжит дальнейшие вычисления.
Следует учитывать, что если мы запускаем данный код в режиме отладки в Visual Studio, то VS просигнализирует нам о генерации исключении и остановит выполнение а строке throw new Exception($»
При возникновении ошибки у объекта Task, представляющего асинхронную задачу, в которой произошла ошибка, свойство IsFaulted имеет значение true. Кроме того, свойство Exception объекта Task содержит всю информацию об ошибке. Чтобы проинспектировать свойство, изменим метод FactorialAsync следующим образом:
И если мы передадим в метод число -1, то task.IsFaulted будет равно true.
Обработка нескольких исключений. WhenAll
Если мы ожидаем выполнения сразу нескольких задач, например, с помощью Task.WhenAll, то мы можем получить сразу несколько исключений одномоментно для каждой выполняемой задачи. В этом случае мы можем получить все исключения из свойства Exception.InnerExceptions:
Здесь в три вызова метода факториала передаются заведомо некорректные числа: -3, -5, -10. Таким образом, при всех трх вызовах будет сгенерирована ошибка.
Хотя блок catch через переменную Exception ex будет получать одно перехваченное исключение, но с помощью коллекции Exception.InnerExceptions мы сможем получить инфрмацию обо всех возникших исключениях.
В итоге при выполнении этого метода мы получим следующий консольный вывод:
await в блоках catch и finally
Начиная с версии C# 6.0 в язык была добавлена возможность вызова асинхронного кода в блоках catch и finally. Так, возьмем предыдущий пример с подсчетом факториала:
Отмена асинхронных операций
Для отмены асинхронных операций используются классы CancellationToken и CancellationTokenSource.
CancellationToken содержит информацию о том, надо ли отменять асинхронную задачу. Асинхронная задача, в которую передается объект CancellationToken, периодически проверяет состояние этого объекта. Если его свойство IsCancellationRequested равно true, то задача должна остановить все свои операции.
Для создания объекта CancellationToken применяется объект CancellationTokenSource. Кроме того, при вызове у CancellationTokenSource метода Cancel() у объекта CancellationToken свойство IsCancellationRequested будет установлено в true.