Асинхронные вычислительные операции

Пул потоков в CLR

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

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

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

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

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

Простые вычислительные операции

В разделе описывается добавление асинхронных вычислительных операций в очередь пула потоков с помощью метода ThreadPool.QueueUserWorkItem().

Контексты исполнения

С каждым потоком связан определённый контекст исполнения, который включает в себя параметры безопасности, параметры хоста и контекстные данные логического вызова.

По умолчанию CLR автоматически копирует контекст исполнения самого первого потока во все вспомогательные потоки. Это гарантирует безопасность, но в ущерб производительности. С помощью класса System.Threading.ExecutionContext можно запретить копирование контекста, что может сильно повысить быстродействие серверных приложений.

Скоординированная отмена

.NET предлагает стандартный паттерн операций отмены. Этот паттерн является скоординированным (cooperative), то есть требует явной поддержки отмены операций. В состав стандартного паттерна скоординированной отмены входят два типа из FCL: CancellationTokenSource (ссылочный тип) и CancellationToken (значимый тип). После создания CancellationTokenSource можно получить один или несколько экземпляров CancellationToken. Затем они передаются операциям, поддерживающим отмену.

Экземпляр CancellationToken относится к упрощённому типу, так как содержит одно закрытое поле: ссылку на свой объект CancellationTokenSource. Цикл вычислительной операции может периодически обращаться к свойству CancellationToken.IsCancellationRequested, чтобы узнать, не требуется ли прерывание операции. Тогда процессор перестаёт совершать операции.

Задания

Самой большой проблемой при вызове метода ThreadPool.QueueUserWorkItem() является отсутствие временного механизма, позволяющего узнать о завершении операции и получить возвращаемое значение. Для обхода этих и других ограничений было введено понятие заданий (tasks), выполнение которых выполняется посредством типов из пространства имён System.Threading.Tasks.

Завершение задания и получение результата

Можно дождаться завершения задания в явном виде с помощью экземплярного метода Task.Wait() и после этого получить результат его выполнения. Для этого можно создать объект типа Task<TResult>, передав в качестве аргумента-типа возвращаемый задачей тип.

При вызове потоком метода Wait(), система проверяет, началось ли выполнение задания, которого ожидает поток. Если да, то поток блокируется до завершения задачи. Но если выполнение ещё не началось, то система может выполнить его при помощи потока, вызывающего метод Wait(). В этом случае поток не блокируется. Он выполняет задание и возвращает управление. Это снижает затраты, так как не приходится создавать поток взамен заблокированного, и повышает производительность, так как на создание потока и переключение контекста не тратится время. Однако если перед вызовом Wait() поток блокируется в рамках синхронизации, то возникает взаимная блокировка (deadlock).

Если задание генерирует исключение, то оно поглощается и сохраняется в коллекции, а поток возвращается в пул. Затем при вызове метода Wait() или свойства Result эти члены выбросят исключение AggregateExceptionopen in new window.

Можно дожидаться завершения не только одного задания, но и массива объектов этого типа. Для этого есть два статических метода WaitAny() и WaitAll().

Отмена задания

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

Автоматический запуск задания по завершении предыдущего

Для написания масштабируемого ПО стоит избегать блокировки потоков. Вызов метода Wait() или свойства Result при незавершённом задании приведёт, скорее всего, к появлению в пуле нового потока, то увеличит расход ресурсов и негативно скажется на расширяемости. Для того, чтобы избежать блокирования потоков, стоит инициировать следующее задание по выполнению предыдущего с помощью методы ContinueWith(). Исполняющий такой код поток не блокируется, ожидая завершения заданий; в это время он может исполнять какой-то другой код или, если это поток из пула, вернуться в пул для решения других задач.

Дочерние задания

Задания поддерживают отношения предок-потомок. Родительское задание выполняется после того, как будут завершены все дочерние задания.

Структура задания

Тип Taskopen in new window обладает набором членов, которые отнимают дополнительные ресурсы. Если эти поля не нужны, то рекомендуется воспользоваться методом ThreadPool.QueueUserWorkItem().

Фабрики заданий

Фабрики заданий (task factory) используются в ситуациях, когда необходимо создать несколько заданий, находящихся в одном и том же состоянии.

Планировщики заданий

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

Методы For, ForEach и Invoke класса Parallel

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

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

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

Встроенный язык параллельных запросов

LINQ предлагает удобный синтаксис запросов к данным, при этом все элементы в наборе данных обрабатываются последовательно одним потоком - это называется последовательным запросом (sequential query). Повысить производительность можно при помощи языка параллельных запросов (Parallel LINQ), позволяющего последовательный запрос превратить в параллельный (parallel query). Последний во внутренней реализации задействует задания (поставленные в очередь планировщиком, используемым по умолчанию), распределяя элементы коллекции среди нескольких процессоров. Выигрыш в производительности происходит в случаях, аналогичных использованию Parallel.

Периодические вычислительные операции

Для выполнения периодических операций в пространстве имён System.Threading определён класс Timer. Данный класс периодически вызывает методы обратного вызова с помощью потоков из пула.

Кроме того, для периодического выполнения операций возможен другой вариант - с использованием статического метода Delay() класса Task в сочетании с ключевыми словами async и await.

Разновидности таймеров

FCL содержит различные таймеры:

  • System.Threading.Timer - лучше других подходит для выполнения повторяющихся заданий с потоками пула.
  • System.Windows.Forms.Timer - вся работа осуществляется одним потоком, что предотвращает параллельное выполнение метода таймера в нескольких потоках.
  • System.Windows.Threading.DispatcherTimer - является эквивалентом System.Windows.Forms.Timer но для приложений Silverlight и WPF.
  • Windows.UI.XAML.DispatcherTimer - является эквивалентом System.Windows.Forms.Timer но для приложений Windows Store.
  • System.Timers.Timer - заставляет CLR по срабатыванию таймера ставить события в очередь пула потоков.

Как пул управляет потоками

Ограничение количества потоков в пуле

Из-за проблем, связанных с зависаниями и взаимными блокировками разработчики CLR постоянно увеличивают заданное по умолчанию максимальное число потоков в пуле. 64-разрядный процесс предлагает 8 Тб адресного пространства, так что теоретически можно создать сотни тысяч потоков, но это будет пустой тратой ресурсов.

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

Управление рабочими потоками

image

На рисунке изображены различные структуры данных, делающие рабочие потоки частью пула. Метод ThreadPool.QueueUserWorkItem() и класс Timer всегда помещают рабочие элементы в глобальную очередь. Рабочие потоки берут элементы из очереди по алгоритму FIFO. А так как при наличии нескольких потоков элементы из глобальной очереди могут удаляться одновременно. все рабочие потоки конкурируют за право на блокировку в рамках синхронизации потоков, которое гарантирует, что никакие два или более потока не смогут одновременно обрабатывать один и тот же элемент.

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

Рабочий поток, готовый к обработке элементов, сначала проверяет наличие заданий в локальной очереди. Обнаружив такой объект, он изымает его и обрабатывает. Изъятие производится по алгоритму LIFO. Так как доступ к началу локальной очереди имеет только рабочий поток, блокировка в рамках синхронизации потоков больше не требуется, а добавление заданий в очередь и их изъятие происходит очень быстро. Побочным эффектом такого поведения является то, что задания выполняются в порядке, обратном порядку их постановки в очередь.

Пул потоков не гарантирует определённого порядка обработки элементов.

Обнаружив пустую локальную очередь, рабочий поток пытается взять задание из локальной очереди другого рабочего потока. Задания берутся с конца очереди, а значит, требуется блокировка, что несколько снижает производительность. Если пустыми оказываются все локальные очереди, рабочий поток извлекает (прибегая к блокировке) элемент из глобальной очереди по алгоритму FIFO. В случае пустой глобальной очереди поток переходит в режим ожидания. Если этот режим длится долго, поток просыпается и самоуничтожается, освобождая занятые ресурсы (ядро, стеки и TEB).

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