Не так давно вышло новое издание замечательной книги Джозефа Албахари (Josepth Albahari) “C# 4.0 in Nutshell: The Definitive Reference”, которая, как подсказывает название, обновилась с учетом нововведений, появившихся в последней версии языка программирования C# и платформы .Net. Еще до перевода этой книги на русский язык, одна из самых интересных и полезных частей книги – о многопоточном программировании на языке C# – была переведена Алексеем Кирюшкиным (a.k.a. Odi$$ey) и опубликована на сайте rsdn.ru (Часть 1 и Часть 2).
В связи с тем, что в новой версии .Net Framework появилось много новых полезных конструкций, призванных упростить работу с многопоточностью, эта часть книги была существенно расширена и переработана. Так, во-первых, добавилась новая часть “Part 5. Parallel Programming”, а во-вторых, части 3 и 4 подверглись существенным переработкам.
С любезного разрешения автора я решил продолжить работу, начатую Алексеем и перевести на русский язык 5-ю часть, а также те изменения, которые появились в частях предыдущих. Я не хочу здесь заново публиковать то, что уже опубликовано на русский язык (хотя, я думаю, что в скором времени полную версию предыдущих частей можно будет почитать на rsdn.ru), и оставлю здесь только те новшества, которые появились в новой версии .Net Framework. Так что, если вы уже знакомы с конструкциями многопоточности в предыдущих версиях платформы, это позволит вам не тратить лишнее время на то, что вы и так уже знаете, ну, а если нет – то лучше начать с чтения предыдущего издания этих частей, опубликованных на rsdn.ru.
Часть 3. Работа с потоками
Безопасная отмена операций
Как мы видели в предыдущем разделе, вызов метода Abort в большинстве случаев является опасным. В таком случае, альтернативой является реализация кооперативного паттерна(cooperative pattern), когда рабочий поток периодически проверяет флаг, указывающий на необходимость прекращения его работы (как в классе BackgroundWorker). Для отмены операции, ее инициатор просто устанавливает этот флаг, и затем ожидает завершения выполнения рабочего потока. Вспомогательный класс BackgroundWorker реализует этот паттерн отмены на основе флага (flag-based cancelation pattern), и вы легко можете реализовать его самостоятельно.
Очевидным недостатком этого метода является то, что рабочий метод должен быть написан специально с поддержкой отмены выполнения. Но, несмотря на это, это один из немногих безопасных паттернов отмены. Для демонстрации этого паттерна, мы сначала напишем класс, инкапсулирующий флаг отмены.
class RulyCanceler
{
object _cancelLocker = new object();
bool _cancelRequest;
public bool IsCancellationRequested
{
get { lock (_cancelLocker) return _cancelRequest; }
}
public void Cancel() { lock (_cancelLocker) _cancelRequest = true; }
public void ThrowIfCancellationRequested()
{
if (IsCancellationRequested) throw new OperationCanceledException();
}
}
Класс OperationCanceledException – это класс в составе .NET Framework предназначенный именно для этих целей. Хотя для этих целей подойдет и любой другой класс исключения.
Мы можем использовать его следующим образом:
class Test
{
static void Main()
{
var canceler = new RulyCanceler();
new Thread(() =>
{
try { Work(canceler); }
catch (OperationCanceledException)
{
Console.WriteLine("Canceled!");
}
}).Start();
Thread.Sleep(1000);
canceler.Cancel(); // безопасная отмена.
}
static void Work(RulyCanceler c)
{
while (true)
{
c.ThrowIfCancellationRequested();
// ...
try { OtherMethod(c); }
finally { /* необходимая очистка ресурсов */ }
}
}
static void OtherMethod(RulyCanceler c)
{
// Выполняем некоторые действия ...
c.ThrowIfCancellationRequested();
}
}
Мы можем упростить наш пример, убрав класс RulyCanceler и добавив вместо него в классе Test статическое булево поле _cancelRequest. Однако, в случае исполнения метода Work несколькими потоками одновременно, установка флага _cancelRequest в true прервет выполнение всех рабочих потоков. Так что класс RulyCanceler является полезной абстракцией. Недостатком этого подхода является то, что глядя на сигнатуру метода Work не сразу понятно его предназначение:
static void Work (RulyCanceler c)
Маркеры отмены
В .Net Framework 4.0 существует два типа, формализующие этот только что продемонстрированный кооперативный паттерн отмены: CancellationTokenSource и CancellationToken. Эти два типа работают в тандеме:
-
В классе CancellationTokenSource определен метод Cancel
-
В классе CancellationToken определено свойство IsCancellationRequested и метод ThrowIfCancellationRequested
Вместе, эти классы являются более сложной версией класса RulyCanceler, представленного в предыдущем примере. Но поскольку типы разделены, вы можете разделить возможность отмены операции от возможности проверки флага отмены.
Для использования этих типов, создайте вначале экземпляр класса CancellationTokenSource:
var cancelSource = new CancellationTokenSource();
Затем, передайте свойство Token этого объекта в метод, поддерживающий отмену:
new Thread (() => Work (cancelSource.Token)).Start();
Вот как будет определен метод Work:
void Work (CancellationToken cancelToken)
{
cancelToken.ThrowIfCancellationRequested();
...
}
Когда вы хотите отменить выполнение метода, просто вызовите метод Cancel объекта cancelSource.
CancellationToken на самом деле является структурой, хотя ведет себя как класс. При копировании, копия ведет себя точно также и указывает на исходный объект CancellationTokenSource.
В структуре CancellationToken определены два дополнительных полезных метода. Первый – это WaitHandle, возвращающий дескриптор ожидания (wait handle), который переходит в сигнальное состояние при отмене. Второй – Register, который позволяет зарегистрировать метод обратного вызова и который будет вызван при отмене.
Маркеры отмены используются в самом .NET Framework, в частности, в следующих классах:
-
BlockingCollection
Большинство этих классов используют маркеры отмены в методах Wait. Например, если вы вызовете метод Wait объекта класса ManualResetEventSlim и укажете маркер отмены, другой класс сможет вызвать метод Cancel и отменить ожидание. Этот метод чище и безопаснее, нежели вызывать метод Interrupt заблокированного потока.
Отложенная инициализация
Типичной проблемой в многопоточной среде является отложенная инициализация разделяемого (shared) поля потокобезопасным способом. Такая потребность возникает, когда создание экземпляра некоторого типа является дорогостоящим:
class Foo
{
public readonly Expensive Expensive = new Expensive();
...
}
class Expensive { /* Предположим, что создание этого объекта является дорогим */ }
Проблема с этим кодом в том, что создание экземпляра класса Foo приводит к затратам на создания экземпляра класса Expensive не зависимо от того, используется это поле или нет. Очевидным решением этой задачи является создание экземпляра по требованию:
class Foo
{
Expensive _expensive;
public Expensive Expensive // Создать экземпляр класса Expensive отложенно
{
get
{
if (_expensive == null) _expensive = new Expensive();
return _expensive;
}
}
...
}
В таком случае возникает вопрос: является ли этот код потокобезопасным? Даже не смотря на тот факт, что доступ к полю _expensive вне блока lock без использования барьеров памяти, давайте подумаем о том, что будет, если два потока обратятся к этому свойству одновременно. При выполнении каждого потока условие if будет выполнено и каждый поток получит разные экземпляры класса Expensive. Это может привести к тонким ошибкам, так что, в общем случае мы можем сказать, что этот код не является потокобезопасный.
Решение проблемы состоит в использовании блокировки вокруг проверки и инициализации объекта:
Expensive _expensive;
readonly object _expenseLock = new object();
public Expensive Expensive
{
get
{
lock (_expenseLock)
{
if (_expensive == null) _expensive = new Expensive();
return _expensive;
}
}
}
Lazy<T>
В .Net Framework 4.0 существует новый класс, под названием Lazy<T> для упрощения отложенной инициализации. Если объект этого класса будет создан с аргументом, равным true, он будет реализовывать только что описанный паттерн инициализации.
На самом деле класс Lazy<T> реализует более эффективную версию этого паттерна, под названием блокировка с двойной проверкой (double-checked locking). При блокировке с двойной проверкой выполняется дополнительная операция volatile чтение для избегания захвата блокировки в случае, если объект уже проинициализирован.
Для использования класса Lazy<T>, создайте класс с делегатом[S7] -фабрикой, который будет определять способ создания нового значения, и в качестве второго параметра передайте true. Затем, вы можете использовать это значение с помощью свойства Value:
Lazy<Expensive> _expensive = new Lazy<Expensive>
(() => new Expensive(), true);
public Expensive Expensive { get { return _expensive.Value; } }
Если вы передадите false в конструктор класса Lasy<T>, тогда объект этого класса будет реализовывать потоконебезопасный паттерн отложенной инициализации, рассмотренный в начале этого раздела. Это имеет смысл, если вы будете применять Lazy<T> в однопоточноном контексте.
LazyInitializer
Класс LazyInitializer – это статический класс, который работает в точности как Lazy<T> за исключением следующего:
-
Его функциональность реализована в виде статического метода, который работает непосредственно с полем внутри вашего типа. Это устраняет дополнительный уровень косвенности и улучшает производительность в случае необходимости чрезвычайной оптимизации.
-
Он предоставляет еще один способ инициализации, при котором во время инициализации возможны гонки нескольких потоков.
Expensive _expensive;
public Expensive Expensive
{
get // Реализует блокировку с двойной проверкой
{
LazyInitializer.EnsureInitialized (ref _expensive,
() => new Expensive());
return _expensive;
}
}
Вы также можете передать другой аргумент для того, чтобы указать на возможность гонок нескольких потоков, борющихся между собой за инициализацию ресурса. Этот способ кажется аналогичным нашему исходному потоконебезопасному примеру, за исключением того, что в данном случае первый поток «всегда выиграет», так что в конечном итоге всегда будет получен только один экземпляр. Преимущество этого подхода состоит в том, что он даже быстрее (на многоядерных компьютерах) чем блокировка с двойной проверкой, поскольку он может быть полностью реализован без блокировок. Такая чрезвычайная оптимизация требуется достаточно редко и она не обходится бесплатно:
-
Этот способ медленнее, когда количество потоков, борющихся за инициализацию, превосходит количество ядер.
-
Он потенциально расходует ресурсы процессора понапрасну, выполняя избыточную инициализацию.
-
Логика инициализации должна быть потокобезопасной (например, будет потоконебезопасно, если конструктор класса Expensive будет записывать что-либо в статические поля).
-
Если инициализатор создает объект, требующий очистки ресурсов (disposable object), ресурсы «лишнего» объекта не будут освобождены без дополнительной логики.
Для справки, вот как реализовывается блокировка с двойной проверкой:
volatile Expensive _expensive;
public Expensive Expensive
{
get
{
if (_expensive == null)
{
var expensive = new Expensive();
lock (_expenseLock) if (_expensive == null) _expensive = expensive;
}
return _expensive;
}
}
А вот как реализован способ с возможностью гонок при инициализации:
volatile Expensive _expensive;
public Expensive Expensive
{
get
{
if (_expensive == null)
{
var instance = new Expensive();
Interlocked.CompareExchange (ref _expensive, instance, null);
}
return _expensive;
}
}
Локальное хранилище потока
В этой статье делается серьезный акцент на конструкции синхронизации и на проблемы, который возникают при одновременном доступе различных потоков к одним и тем же данным. Однако иногда вы хотите иметь изолированные данные, гарантируя, что у каждого потока будет их отдельная копия. Локальные переменные предназначены именно для этого, но они применимы только для временных данных (transient data).
Решением является использование локального хранилища потока (thread-local storage). Вы можете увидеть противоречие в этом требовании: данные, которые вы хотите изолировать для каждого отдельного потока, являются временными по своей природе. Главная цель этих хранилищ – хранение «нетиповых» (“out-of-band”) данных, которые являются частью инфраструктуры приложения, такие как сообщения, транзакции и маркеры безопасности (security tokens). Передача таких данных через параметры методов является чрезвычайно грубым решением и усложняет все, кроме этих методов; хранение же такой информации в обыкновенных статических полях означает разделение этих данных всеми потоками.
Кроме того, локальное хранилище потока может быть полезным при оптимизации параллельного кода. Это позволяет каждому потоку единолично обращаться к своей собственной версии потоконебезопасного объекта без использования блокировок и без необходимости повторного создания объекта между вызовами методов.
Существует три способа реализации локального хранилища потока.
[ThreadStatic]
Самым простым способом использования локального хранилища потока является пометка статического поля с помощью атрибута ThreadStatic:
[ThreadStatic] static int _x;
В этом случае каждый поток будет видеть независимую копию переменной _x.
К сожалению, атрибут [ThreadStatic] не работает с экземплярными полями (он просто ничего не делает в этом случае); а также нормально не работает с инициализаторами статических полей (field initializer), поскольку они выполняются только один раз при выполнении статического конструктора. Если вам нужно работать с экземплярными полями или значениями статических полей, отличных от значений по умолчанию, более подходящим выбором будет ThreadLocal<T>.
ThreadLocal<T>
Класс ThreadLocal<T> появился в .Net Framework 4.0 и он позволяет использовать локальное хранилище потока, как для статических, так и для экземплярных полей, а также позволяет задавать значение по умолчанию.
Вот как можно создать экземпляр ThreadLocal<int> со значением по умолчанию для каждого потока, равным 3:
static ThreadLocal<int> _x = new ThreadLocal<int> (() => 3);
Затем вы можете использовать свойство Value переменной _x для получения или установки значения, локального для каждого потока. Бонусом использования ThreadLocal является отложенная инициализация значений: фабричная функция выполняется при первом вызове (для каждого потока).
ThreadLocal<T> и экземплярные поля
Класс TrheadLocal<T> также полезен при работе с экземплярными полями и захваченными локальными переменными. Например, давайте рассмотрим задачу генерации случайных чисел в многопоточном окружении. Класс Random не потокобезопасен, поэтому нам либо нужно, либо воспользоваться блокировкой вокруг использования объекта класса Random (ограничивая одновременный доступ) либо генерировать объект класса Random для каждого потока. Класс ThreadLocal<T> легко решает вторую задачу:
var localRandom = new ThreadLocal<Random>(() => new Random());
Console.WriteLine (localRandom.Value.Next());
Наша фабричная функция для создания объекта Random немного упрощенная, поскольку конструктор без параметров класса Random основывается на системном времени для начальных значений. И это значение может быть одинаковым для двух разных объектов Random созданных с интервалом порядка 10 мс. Вот как это можно исправить:
var localRandom = new ThreadLocal<Random>
( () => new Random (Guid.NewGuid().GetHashCode()) );
Мы воспользуемся этим способом в 5-й части (см. пример параллельной проверки правописания в разделе “PLINQ”).
GetData and SetData
Третий подход основывается на использовании двух методов класса Thread: GetData и SetData. Эти методы сохраняют данные в специальных «слотах» потока. Метод Thread.GetData читает из этого хранилища потока, а метод Thread.SetData – записывает туда. Для идентификации слота оба метода требуют объект LocalDataStoreSlot. Одна и та же ячейка может быть использована всеми потоками, но при этом каждый из них будет работать со своими собственными значениями. Вот пример:
class Test
{
// Один и тот же объект LocalDataStoreSlot может использоваться всеми потоками.
LocalDataStoreSlot _secSlot = Thread.GetNamedDataSlot ("securityLevel");
// Это свойство содержит разное значение для каждого потока
int SecurityLevel
{
get
{
object data = Thread.GetData (_secSlot);
return data == null ? 0 : (int) data; // null == поле неинициализировано
}
set { Thread.SetData (_secSlot, value); }
}
...
В этом примере, мы вызываем Thread.GetNamedDataSlot, который создает новый именованный слот , что позволяет совместно использовать этот слот всему приложению. Создавая объект LocalDataStoreSlot явно и не задавая его имени, вы можете управлять областью видимостью слота самостоятельно:
class Test
{
LocalDataStoreSlot _secSlot = new LocalDataStoreSlot();
...
Вызов метода Thread.FreeNamedDataSlot освободит все именованные слоты для всех потоков, но только после того, как будут освобождены все ссылки на LocalDataStoreSlot и он будет освобожден сборщиком мусора. Это гарантирует то, что если потокам все еще нужен этот слот и они содержат ссылку на соответствующий объект LocalDataStoreSlot, то они никогда внезапно не потеряют эти данные
На самом-то деле эту книгу перевели на русский, не про С# 4.0, а про C# 3.0/3.5 и назвается он русском варианте как "C# 3.0. Справочник. Джозеф Албахари, Бен Албахари. Пер. с англ.". Подробнее смотри по этой сылке: http://www.ozon.ru/context/detail/id/4625027/
ОтветитьУдалитьНо за работу по переводу спасибо ;)
@alexlab: Упс! Как-то я пропустил, что она выходила на русском! Спасибо, исправил эту фразу.
ОтветитьУдалитьКонечно статья старая, но хочу задать вопрос: Какая разница между двумя реализациями метода Expensive на предмет гонки. Ведь, и в релизации с double-check, и в реализации с CompareExchange, в равной мере, возможность гонки присутствует.
ОтветитьУдалитьВ данных примерах разницы действительно немного: вариант с lock займет больше времени да потенциально будет блокировать потоки. Вообще в классическом double-check locking варианте создание ресурса идет под локом, т.о. гарантируется, что ресурс будет создан только единожды, в отличии от варианта с Interlocked (этому соответствуют варианты инициализации Lazy: ExecutionAndPublication и PublicationOnly).
Удалить@Hamlet: Реализация double-check lock создает лишь один экземпляр и именно он будет сохранет в переменной _instance, в случае с CompareExchange есть возможность, что конструктор будет вызван дважды, но лишь один экземпляр будет сохранен в поле _instance. Я не вижу, как в случае с double ckecked lock-ом может быть гонка.
Удалить@Andrey: Разница между double-checked lock реализацией и реализацией на основе CompareExchange достаточно много: в первом случае конструктор гарантированно вызовется один раз, а во-втором случае - он может быть вызван более одного раза.
Когда класс называется Expensive, то эта разница ИМХО очень и очень существенна!