Гибридные конструкции синхронизации потоков
При отсутствии конкуренции потоков гибридные конструкции дают даже большую производительность, чем простейшие конструкции пользовательского режима.
Простая гибридная блокировка
В книге приводится пример простой гибридной блокировки на основе Interlocked-конструкции и AutoResetEvent.
Зацикливание, владение потоком и рекурсия
Так как переходы в ядро сильно снижают производительность, а потоки остаются запертыми короткое время, общую производительность можно повысить, заставив поток перед переходом в режим ядра на некоторое время зациклиться в пользовательском режиме. Если в это время блокирование, которого ждёт поток, станет возможным, переход в режим ядра не понадобится.
Некоторые варианты блокирования налагают ограничение, при котором получить право на блокировку может только поток, снимающий блокировку. В книге приводится пример гибридного блокирования, предполагающее одновременно зацикливание, владение потоком и рекурсию.
Гибридные конструкции в FCL
В FCL существует множество гибридных конструкций, которые призваны удержать потоки в пользовательском режиме, что повышает производительность.
Классы ManualResetEventSlim и SemaphoreSlim
Классы ManualResetEventSlim и SemaphoreSlim функционируют точно так же, как их аналоги режима ядра, отличаясь только зацикливанием в пользовательском режиме. Они не создают конструкций режима ядра до возникновения конкуренции.
Класс Monitor и блоки синхронизации
Самой популярной гибридной конструкцией является класс Monitor, обеспечивающий взаимоисключающее блокирование, владение потоком и рекурсией. Данная конструкция используется чаще других, так как является одной из самых старых. Для её поддержки в C# даже есть специальное ключевое слово, с ней по умолчанию умеет работать JIT-компилятор, а CLR пользуется ей от имени приложения. Однако работать с ней не просто, а получить некорректный код очень легко.
С каждым объектом в куче связан блок синхронизации (sync block). Этот блок содержит поля для объекта ядра, идентификатора потока-владельца, счётчика рекурсии и счётчика ожидающих потоков. Класс монитор является статическим и его методы принимают ссылки на любой объект из кучи. Управление полями эти методы осуществляют в блоке синхронизации заданного объекта.
Привязка блока синхронизации к каждому объекту в куче является очень расточительной, так как большинство объектов никогда не пользуются этим блоком. Для снижения потребления памяти, разработчики CLR применении более эффективный вариант реализации. Во время инициализации CLR выделяется массив блоков синхронизации. При создании объекта в куче инициализируется индекс блока синхронизации (sync block index), то есть индекс в массиве блоков синхронизации.
В момент конструирования объекта этому индексу присваивается -1. Затем при вызове метода Monitor.Enter() CLR обнаруживает свободный блок синхронизации и присваивает ссылку на него объекту. Метод Exit() проверяет наличие потоков, ожидающих блока синхронизации. Если таких потоков не обнаруживается, метод возвращает индексу значение -1, освобождая блоки синхронизации. Массив блоков синхронизации может быть увеличен, если в какой-то момент их станет недостаточно.
В книге приводится пример корректного использования Monitor.
Так как разработчики привыкли устанавливать и снимать блокировку в одном и том же методе, в C# появился упрощённый синтаксис для этого:
private void SomeMethod()
{
lock (this)
{
// Этот код имеет эксклюзивный доступ к данным...
}
}
Что эквивалентно:
private void SomeMethod()
{
Boolean lockTaken = false;
try
{
// Исключение (например, ThreadAbortException) может здесь появиться
Monitor.Enter(this, ref lockTaken);
// Этот код имеет монопольный доступ к данным...
}
finally
{
if (lockTaken)
Monitor.Exit(this);
}
}
Это приводит к тому, что снятая в блоке finally блокировка (в ситуации, когда в блоке try повреждаются данные), позволит работать с повреждёнными данными другому потоку.
Класс ReaderWriterLockSlim
Если данные, которые читаются потоками, защищены взаимоисключающей блокировкой, то при попытке одновременного доступа нескольких потоков работу продолжит только один, а остальные блокируются, что ухудшает масштабируемость и снижает производительность. Хотя в случае одновременного чтение необходимости в блокировке нет, а вот при попытке записи требуется монопольный доступ. Конструкция ReaderWriteLockSlim призвана решить проблему, управляя потоками следующим образом:
- Если один поток осуществляет запись, все остальные потоки блокируются.
- Если один поток читает, все остальные потоки продолжают работать; блокируются только те, которые ждут доступа на запись.
- После завершения работы записывающего потока разблокируется либо один поток на запись, либо все читающий поток. При отсутствии заблокированных потоков блокировку получит следующий поток, которому это потребуется.
- После завершения всех читающих потоков, разблокируется записывающий поток. При отсутствии заблокированных потоков блокировку получит следующий поток, которому это потребуется.
Класс OneManyLock
Рихтер создал собственную конструкцию, которая работает быстрее, чем ReaderWriteLockSlim. Эта конструкция называется OneManyLock, так как она предоставляет доступ либо одному пишущему, либо многим читающим потокам. Подробнее в книге.
Класс CountdownEvent
System.Threading.CountdownEvent построен на основе ManualResetEventSlim и блокирует поток до достижения внутренним счётчиком 0. Поведение этой конструкции диаметрально противоположно семафору.
Класс Barrier
System.Threading.Barrier была создана для решения крайне редко возникающей проблемы, так что ею вряд ли придётся пользоваться. Она управляет группами параллельно выполняющихся потоков, обеспечивая одновременное прохождение ими всех фаз алгоритма.
Выводы по гибридным конструкциям
Стоит по возможности избегать кода, блокирующего потоки. При асинхронных вычислениях или операциях ввода-вывода стоит передавать данные от одного потока другому так, чтобы исключить попытку одновременного доступа. Если это невозможно, стоит использовать Volatile или Interlocked. Однако они подходят только для работы с простыми типами.
Две причины для блокирования потоков:
- Упрощение модели программирования. Блокируя поток и жертвуя ресурсами, разработчик получает возможность писать код последовательно, без методов обратного вызова. Асинхронные функции C# предоставляют упрощённую модель программирования без необходимости блокировать потоки.
- Поток имеет определённое назначение.
Чтобы избежать блокировки потоков, не стоит мысленно связывать их с конкретными операциями. Потоки являются слишком ценным ресурсом, чтобы ограничивать их назначение. Стоит использовать пул потоков для возможности потокам решать разные задачи.
При блокировке для синхронизации потоков из разных доменов стоит использовать конструкции режима ядра. Стоит стараться избегать рекурсивных блокировок, так как они снижают производительность. Кроме того, стоит стараться не снимать блокировку в блоке finally, так как можно получить повреждённые данные.
В конечном счёте, для вычислительных операций или операций ввода-вывода стоит использовать асинхронные операции, так как они используют преимущества пула потоков.
Блокировка с двойной проверкой
К блокировке с двойной проверкой (double-check locking) прибегают, если нужно отложить создание одиночки (singletone) до тех пор, пока он не потребуется приложению - это называют отложенной инициализацией (lazy initialization). Это экономит время и память. Проблемы могут возникнуть если объект понадобится сразу нескольким потокам. Чтобы в результате появился только один объект, необходимо применить синхронизацию потоков. Пример реализации данной техники:
internal sealed class Singleton
{
// Объект s_lock требуется для обеспечения безопасности в многопоточной среде.
// Наличие этого объекта предполагает, что для создания одноэлементного объекта требуется больше ресурсов,
// чем для объекта System.Object и что эта процедура может вовсе не понадобиться.
// В противном случае проще и эффективнее получить одноэлементный объект в конструкторе класса
private static readonly Object s_lock = new Object();
// Это поле ссылается на один объект Singleton
private static Singleton s_value = null;
// Закрытый конструктор не дает внешнему коду создавать экземпляры
private Singleton()
{
// Код инициализации объекта Singleton
}
// Открытый статический метод, возвращающий объект Singleton (создавая его при необходимости)
public static Singleton GetSingleton()
{
// Если объект Singleton уже создан, возвращаем его
if (s_value != null) return s_value;
Monitor.Enter(s_lock); // Если не создан, позволяем одному потоку сделать это
if (s_value == null)
{
// Если объекта все еще нет, создаем его
Singleton temp = new Singleton();
// Сохраняем ссылку в переменной s_value (см. обсуждение далее)
Volatile.Write(ref s_value, temp);
}
Monitor.Exit(s_lock);
// Возвращаем ссылку на объект Singleton
return s_value;
}
}
Для многопоточной инициализации одиночки в FCL существует два типа System.Lazy и System.Threading.LazyInitializer.
Паттерн условной переменной
Если некий поток выполняет код при соблюдении сложного условия, то можно было бы просто организовать зацикливание этого потока с периодической проверкой условия, хотя этого не стоит делать по нескольким причинам:
- Пустая трата процессорного времени.
- Невозможность атомарно проверить несколько переменных и условия.
Решить эту проблему можно с использованием паттерна условной переменной (condition variable pattern):
internal sealed class ConditionVariablePattern
{
private readonly Object m_lock = new Object();
private Boolean m_condition = false;
public void Thread1()
{
Monitor.Enter(m_lock); // Взаимоисключающая блокировка
// "Атомарная" проверка сложного условия блокирования
while (!m_condition)
{
// Если условие не соблюдается, ждем, что его поменяет другой поток
Monitor.Wait(m_lock); // На время снимаем блокировку, чтобы другой поток мог ее получить
}
// Условие соблюдено, обрабатываем данные...
Monitor.Exit(m_lock); // Снятие блокировки
}
public void Thread2()
{
Monitor.Enter(m_lock); // Взаимоисключающая блокировка
// Обрабатываем данные и изменяем условие...
m_condition = true;
// Monitor.Pulse(m_lock); // Будим одного ожидающего ПОСЛЕ отмены блокировки
Monitor.PulseAll(m_lock); // Будим всех ожидающих ПОСЛЕ отмены блокировки
Monitor.Exit(m_lock); // Снятие блокировки
}
}
Асинхронная синхронизация
Конструкции синхронизации потоков, использующие примитивы в режиме ядра являются не лучшей идеей. Они нужны для блокирования потоков, в то время как создание потока обходится достаточно дорого, чтобы он потом бездействовал.
Многие из проблем, решаемых гибридными конструкциями, можно успешно решить с помощью Task. Такой подход имеет ряд преимуществ:
- Задания требуют меньше памяти, быстрее создаются и уничтожаются.
- Пул потоков автоматически распределяет задания между доступными процессорами.
- По мере завершения заданием своего этапа, выполнявший его поток возвращается в пул, где может заняться другой работой при наличии.
- Пул потоков видит все задания и может лучше их планировать, сокращая количество потоков и переключений контекста.
Несколько примеров асинхронной синхронизации:
System.Threading.SemaphoreSlimс использованием методаWaitAsync()решает проблему асинхронного ожидания получения потоком блокировки.System.Threading.Tasks.ConcurrentExclusiveSchedulerPairпозволяет реализовать семантику записи/чтения при планировании заданий.- В .NET нет асинхронных средств с семантикой чтения/записи, но Рихтер создал
AsyncOneManyLock.
Классы коллекций для параллельного доступа
В FCL существуют четыре потокобезопасных классов коллекций, принадлежащий пространству имён System.Collections.Concurrent: ConcurrentQueue, ConcurrentStack, ConcurrentDictionary и ConcurrentBag. Эти классы являются неблокирующими: при попытке извлечь несуществующий элемент поток немедленно возвращает управление, а не блокируется, ожидая появления элемента. Именно поэтому для получения используются методы TryXXX(), которые возвращают булево значение.
Хоть классы и являются неблокирующими, они могут использовать синхронизацию, пусть и на короткое время, необходимо для работы с элементами коллекции.
Все рассматриваемые классы обладают методом GetEnumerator(). Для всех классов кроме ConcurrentDictionary метод создаёт снимок (snapshot) и возвращает зафиксированные элементы, при этом коллекции могут поменяться.
Все классы кроме ConcurrentDictionary реализуют интерфейс IProducerConsumerCollection. Из-за этого классы могут стать блокирующими коллекциями: если коллекция заполнена, то блокируется пишущий поток, а если пуста - то читающий.
