Параллельное программирование в .NET Framework 4.0

Денис Речкунов В 4-ой версии фреймворка от Microsoft произведено множество улучшений в области параллельного программирования: были добавлены принципиально новые для .NET вещи и улучшены уже существующие механизмы, используемые разработчиками ранее. С версии 4.0 во фреймворке дополнительно выделена отдельная библиотека для решения параллельных задач, так называемая TPL (Task Parallel Library), содержащая в себе высокоуровневые механизмы для выполнения, планирования и синхронизации параллельных действий. Рассмотрим общий вид программы, использующей параллельные алгоритмы в .NET Framework 4.0:
netgif1 Figure 1 Parallel Programming in the .NET Framework (взято из MSDN)
Из схемы видно, что любая программа написанная .NET разработчиком и использующая параллельные алгоритмы в конечном итоге использует 3 модуля .NET Framework:
  • Data Structures for Coordination (набор потокобезопасных структур данных, примитивов синхронизации, реализаций паттернов параллельного программирования и управляемых оберток поверх потоков операционной системы)
  • Task Parallel Library (высокоуровневая библиотека параллельных задач, построенная поверх предыдущего модуля)
  • PLINQ Execution Engine (механизмы для высокоуровневой параллельной обработки данных, которые в конечном итоге реализованы поверх TPL)
Причем два последних модуля появились именно в .NET Framework 4.0, а первый был значительно улучшен с этой версии. Далее будет приведен краткий обзор перечисленных модулей.

Data Structures for Coordination

Все элементы этого модуля содержаться в пространстве имен System.Threading и их можно разделить на несколько групп:
  • Примитивы синхронизации (Semaphore, Mutex, Monitor, Barrier, ReaderWriterLock, LazyInitializer, SpinLock, SpinWait)
  • Средства для работы с потоками операционной системы (Thread, ThreadPool, ThreadLocal)
  • Атомарные операции (Interlocked)
  • Планирование действий (CountdownEvent, Timer)
  • Структуры данных для конкурентного использования (BlockingCollection, ConcurrentBag, ConcurrentDictionary, ConcurrentQueue, ConcurrentStack)

Semaphore

Класс реализует классический примитив синхронизации, который позволяет синхронизировать в себе заданное число потоков. Существует два основных действия – взять семафор (WaitOne) и освободить (Release). При создании семафора можно указать количество потоков, которые могут вызвать WaitOne без ожидания Release от другого потока, то есть, другими словами, сколько потоков могут брать семафор, до того как понадобится синхронизация. Помимо количества потоков можно указать глобальный идентификатор семафора в системе, то есть сделать семафор именованным, чтобы использовать его не в рамках одного процесса, а уже для межпроцессовой синхронизации. Для того чтобы получить именованный семафор (даже если он создан в другом процессе), достаточно использовать метод OpenExisting. При использовании класса Semaphore необходимо учесть несколько особенностей:
  • У семафора нет идентификации потоков, то есть любой поток может освободить семафор, даже если он был взят другим потоком (по сути это просто увеличение/уменьшение счетчика внутри семафора).
  • Если, к примеру, два потока вызвали WaitOne, а затем один из них вызвал дважды Release, то когда второй поток попытается освободить семафор, получит исключение SemaphoreFullException.

Mutex

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

Monitor

Еще один классический примитив синхронизации, по использованию аналогичен Mutex, однако, сам по себе Monitor не является объектом синхронизации и класс статический. Monitor’у необходимо указать объект, который будет являться признаком синхронизации. Для упрощенного использования мониторов в C# есть инструкция компилятора lock, которая организует блок кода, в котором гарантируется монопольный синхронизированный доступ к объекту, а также освобождение монитора после выхода из блока кода, даже в случае исключения. Стоит помнить несколько особенностей Monitor:
  • Примитив работает исключительно со ссылочными типами
  • Не рекомендуется использовать публичные объекты для синхронизации, так как это может привести к взаимоблокировке
  • Рекомендуется везде, где это возможно использовать инструкцию lock вместо явного вызова методов класса

Barrier

Представьте, что вы менеджер проектов и у вас в распоряжении несколько сотрудников, которые занимаются решением каких-то задач. У вас появляется новая необъятная задача, которую можно начать только когда все ваши сотрудники закончат их текущие задачи. В таком случае вам нужен примитив синхронизации Barrier. Основная задача примитива зарегистрировать участников (AddParticipant), которые заняты своими задачами и по их завершению сообщают об этом и зависают (SignalAndWait) до тех пор, пока все участники не закончат свои задачи. Затем все потоки размораживаются и продолжают следующий «тур» своих задач по той же схеме, причем Barrier ведет счетчик этих самых «туров» или фаз.

LazyInitializer

Как очевидно из названия, класс выполняет задачу потокобезопасной ленивой инициализации объекта (EnsureInitialized(ref target)). Однако не стоит путать этот примитив с паттерном Singleton, так как класс не гарантирует, что объект будет создан всего раз, при конкурентном доступе к примитиву синхронизации может быть создано несколько объектов, но только один будет сохранен в target во всех потоках.

ReaderWriterLock

Реализация паттерна параллельного программирования, в котором ведется контроль доступа к объекту по схеме «Много читателей ИЛИ один писатель». Потоки, производящие чтение с объекта должны брать блокировку чтения (AcquireReaderLock), а при завершении процесса чтения снимать блокировку (ReleaseReaderLock), аналогично должны поступать и потоки-писатели (AcquireWriterLock, ReleaseWriterLock). ReaderWriterLock идентифицирует потоки, поэтому один поток не может взять два вида блокировки одновременно. При использовании примитива необходимо учесть некоторые особенности:
  • Запись будет производиться, когда нет блокировок чтения, поэтому происходить это будет редко
  • Если запрос на блокировку не может быть удовлетворен, то поток получает ApplicationException.

SpinLock и SpinWait

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

Thread, ThreadLocal, ThreadPool

Thread – это .NET оболочка для потоков операционной системы, которая позволяет выполнять действие асинхронно и управлять его выполнением. Поддерживается приоритет выполнения, приостановка выполнения, прерывание, а также межпоточная синхронизация (вызов метода Join блокирует вызвавший поток пока поток, у которого метод вызван, не завершится). ThreadLocal – предоставляет локальное для каждого потока хранилище данных. То есть если поток проинициализировал это хранилище какими-то данными, то они будут доступны только из этого потока, а в другом потоке объект будет не проинициализирован. ThreadPool – идея пула потоков схожа с семафором: есть ограниченное число потоков которые могут существовать в пуле, каждому потоку предоставляется задача на исполнение, однако, когда поступает очередная задача (QueueUserWorkItem), а свободных потоков в пуле нет, задача ставится в очередь, пока какой-нибудь из потоков не освободится. При использовании ThreadPool необходимо учесть несколько особенностей:
  • Все потоки в пуле являются фоновыми (Background = true)
  • Таймеры и зарегистрированные операции ожидания в .NET также используют ThreadPool
  • Для каждого процесса создается один пул потоков, который по умолчанию содержит 250 рабочих потоков на каждый процессор и 1000 потоков на завершение ввода/вывода
  • Когда все свободные потоки получают задачу, то для следующей задачи поток создается с задержкой 0,5 сек

Concurrent Collections

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

BlockingCollection

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

ConcurrentBag

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

Task Parallel Library

Основным пространством имен для библиотеки является System.Threading.Tasks. Библиотека содержит высокоуровневые средства выполнения параллельных задач, в которых разработчику нет нужды задумываться о синхронизации, блокировках, создании и управлении потоками. Условно библиотеку можно разделить на две части:
  • Parallel – класс реализующий параллельный оператор цикла с его вариациями и метод Invoke для быстрого асинхронного вызова действия через Task.
  • Task, TaskFactory, TaskScheduler – классы связанные с планировкой и постановкой параллельных задач

Task

Класс Task является высокоуровневым аналогом класса Thread, однако на самом деле исполняется как делегат в ThreadPool. Помимо расширенных средств комбинирования и планирования задач Microsoft утверждает, что TPL эффективнее распределяет и контролирует ресурсы, использует в себе ThreadPool, усовершенствованный с помощью алгоритмов, которые оптимально подстраивают количество потоков. А также очевидным преимуществом является более гибкая обработка результатов и исключений в задачах. При использовании Task необходимо учесть несколько особенностей:
  • Если задача возвращает результат, он помещается в свойство Result и доступ к нему синхронизирован с завершением выполнения задачи
  • Если во время выполнения задачи произошло исключение, оно будет вложено в AggregateException и помещается в свойство Exception, которое также синхронизировано, а также выставляется флаг IsFaulted.
  • Если есть потоки ожидающие поле Result, в них автоматические будет вызвано это исключение.
  • У одной параллельной задачи может быть множество подзадач, возможно построение древесной структуры задач.
При постановке задачи разработчик имеет возможность указать параметры ее исполнения TaskCreationOptions:
  • None – поведение по умолчанию
  • PreferFairness – Рекомендация для TaskScheduler планировать задачи максимально сохраняя приоритет по порядку постановки задач (раньше старт – раньше завершение).
  • LongRunning – Уведомляет планировщик, что задача будет исполняться длительное время, такие задачи не помещаются в ThreadPool.
  • AttachedToParent – Уведомляет планировщик, что задача присоединена к родительской задаче и помещается в локальную очередь родительской задачи а не в ThreadPool.

CancellationToken

Эта структура не что иное, как реализация паттерна параллельного программирования «Conditional Variable». В TPL принято использовать именно эту структуру для прерывания параллельных задач, ее поддерживают класс Task и механизмы PLINQ. Доступ к структуре синхронизирован, вы можете передать ее в задачу и затем вызвать метод Cancel(), выставив состояние, что задачу необходимо отменить. При этом внутри задачи разработчик сам решает, когда проверить, что задача отменена и прервать ее. При этом нет нештатного завершения процесса обработки каких-либо данных, разработчик имеет возможность обработать прерывание задачи и подготовить штатное состояние (например, завершить итерацию цикла).
netfig2 Figure 2 Пример использования механизма прерывания задач в TPL

PLINQ

Помимо TPL в новой версии .NET Framework Microsoft решила развить LINQ и представила его параллельную реализацию – это PLINQ (Parallel Language Integrated Query). Знакомый .NET разработчикам LINQ обзавелся реализацией, позволяющей обрабатывать данные параллельно, не задумываясь о потокобезопасности, синхронизации, сборе результирующего множества из разных потоков, оптимальном размере подмножеств на которые разбиваются данные для параллельной обработки. PLINQ предоставляет высокоуровневые подходы к параллельной обработке данных, однако, при необходимости, предоставляет разработчику свободу самостоятельно конфигурировать основные параметры процесса, что делает PLINQ также достаточно гибким. PLINQ может быть использован со всеми коллекциями .NET которые реализуют System.Collections.IEnumerable, так как в .NET Framework 4.0 появился класс методов-расширений ParallelEnumerable а в нем расширение AsParallel для IEnumerable, которое возвращает обертку над коллекцией типа ParallelQuery и позволяет с почти тем же интерфейсом что и LINQ работать с коллекцией параллельно. При этом данные разбиваются на сегменты, причем учитывается тип коллекции для оптимального разбиения, а когда нет возможности обработать данные параллельно автоматически используется последовательный режим. Как уже упоминалось, можно использовать CancellationToken для прерывания обработки коллекции. Перед тем как все-таки использовать PLINQ, необходимо задуматься о накладных расходах механизма на сегментирование, сбор и упорядочивание данных, какова степень параллелизма вашей системы (сколько ядер/процессоров), достаточно большой ли у вас объем данных, чтобы обрабатывать его параллельно или все же дешевле будет обработать его последовательно. Важным фактором также является хорошее знание документации PLINQ, в которой описаны случаи, когда механизм начинает использовать последовательный режим. Если вы не доверяете сегментации, которая работает по умолчанию в PLINQ, вы можете использовать свой сегментатор, создав его методом Partitioner.Create.

Заключение

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