□ Если RR > 0, возвращаем управление.

□ Если1л11л1 > 0, присваиваем состоянию значение ResenvedFonWniten (зарезервировано для потока записи), выполняем WW- освобождаем один заблокированный поток записи, возвращаем управление.

□ Если RW = 0иЫЫ = 0, присваиваем состоянию значение Free (свободно), возвращаем управление.

Вот что происходит при входе потока в код блокировки с монопольным доступом:

□ Если блокирование возможно, присваиваем состоянию значение OwnedByWniten (занято потоком записи), возвращаем управление.

□ Если состояние блокировки равно ReservedForWriter (зарезервировано для потока записи), присваиваем состоянию значение OwnedByWriter (занято потоком записи), возвращаем управление.

□ Если состояние блокировки равно OwnedByWriter (занято потоком записи), выполняем WW++, блокируем поток записи. Когда поток проснется, проходим цикл и делаем вторую попытку.

□ В противном случае присваиваем состоянию значение OwnedByReaders- AndWriterPending (ожидание потоков чтения и записи), выполняем WW++, блокируем поток записи. Когда поток проснется, проходим цикл и делаем вторую попытку.

Вот что происходит при выходе из блокировки потока с монопольным доступом:

□ ЕслиММ = 0hRW = 0, присваиваем состоянию значение Free (свободно), возвращаем управление.

□ EchhWW > 0, присваиваем состоянию значение ReservedForWriter (зарезервировано для потока записи), выполняем WW- -, освобождаем один заблокированный поток записи, возвращаем управление.

□ Если WW = 0 и RW > 0, присваиваем состоянию значение F гее (свободно), выполняем RW = 0, пробуждаем все заблокированные потоки чтения, возвращаем управление.

Предположим, что у нас один запертый поток осуществляет чтение, а другой ждет освобождения блокировки, чтобы осуществить запись. Записывающий поток сначала проверит, свободна ли блокировка, и так как результат будет отрицательным, начнет готовиться к следующей проверке. 11о в этот момент читающий поток может покинуть код блокировки, и обнаружив, что значения RR и WW равны 0, поток присвоит состоянию блокировки значение Free. Однако проблема в том, что поток записи уже закончил проверку состояния и продолжил выполнение. Фактически поток чтения «за спиной» потока записи меняет то состояние, к которому последний пытается получить доступ. Для корректного блокирования нужно разобраться с данной проблемой.

Для этого все манипуляции с битами выполняются с применением приемов, описанных в разделе «Универсальный Interlocked-паттерн» главы 29. Если помните, данный паттерн превращает любую операцию в безопасную в отношении потоков и атомарную. Именно это обеспечивает быстрое блокирование. Сравнив производительность моего класса OneManyLock и классов ReaderWriterLockSlim и ReadenWnitenLock из FCL, я получил следующий результат:

Приращение х в OneManyLock: 330 — максимально быстро.

Приращение х в ReaderWriterLockSlim: 554 — примерно в 1,7 раза медленней.

Приращение х в ReaderWriterLock: 984 — примерно в 3 раза медленней.

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

В завершение этого раздела я в очередной раз упомяну мою библиотеку Power Threading library (ее можно загрузить бесплатно по адресу http://Wintellect.com/ PowerThreading.aspx). Она содержит немного другую версию этой блокировки, которая называется OneManyResourceLock. Этот и другие варианты блокирования из моей библиотеки предлагают множество дополнительных возможностей, например распознавание взаимной блокировки (deadlocks), включение режимов владения блокированием и рекурсии (за что придется платить снижением производительности), унифицированная программная модель всех блокировок и средства наблюдения за поведением блокировок в процессе выполнения. При наблюдении за поведением можно узнать максимальное время ожидания получения блокировки потоком, а также максимальное и минимальное время удержания блокировки.

Класс Countdown Event

Следующая конструкция называется System. Threading. CountdownEvent. Она построена на основе o6beKTaManualResetEventSlim и блокирует поток до достижения внутренним счетчиком значения 0. Поведение этой конструкции диаметрально противоположно поведению семафора (блокирующего потоки, пока значение счетчика равно 0). Вот как выглядит данный класс (некоторые перегруженные версии методов не показаны):

public class CountdownEvent : IDisposable { public CountdownEvent(Int32 initialCount); public void DisposeQ;

public void Reset(Int32 count); // Присваиваем CurrentCount

// значение count

public void AddCount(Int32 signalCount); // Увеличение CurrentCount

//на signalCount

public Boolean TryAddCount(Int32 signalCount); // Увеличение CurrentCount

//на signalCount

public Boolean Signal(Int32 signalCount); // Уменьшение CurrentCount

// на slgnameCount

public Boolean Wait(

Int32 millisecondsTimeout, CancellationToken cancellatlonToken);

public Int32 CurrentCount { get; } public Boolean IsSet { get; }

public WaitHandle WaitHandle { get; }

}

Достигнутое параметром CurrentCount класса CountdownEvent нулевое значение уже не может быть изменено. Если параметр CurrentCount равен 0, метод AddCount генерирует исключение InvalidOperationException, а метод TryAddCount просто

возвращает значение false.

Класс Barrier

Конструкция System.Threading.Barrier была создана для решения достаточно редко возникающей проблемы, так что вряд ли вам когда-нибудь придется ею пользоваться. Она управляет группами параллельно выполняющихся потоков, обеспечивая одновременное прохождение ими всех фаз алгоритма. К примеру, когда CLR задействует серверную версию уборщика мусора, его алгоритм создает один поток исполнения для каждого ядра. Эти потоки проходят через различные стеки приложения, одновременно помечая объекты в куче. Завершив свою порцию работы, поток должен остановиться и подождать завершения работы остальных. Когда все потоки пометят объекты, они смогут одновременно приступить к сжатию различных частей кучи. Поток, закончивший сжимать кучу, следует заблокировать, чтобы он дождался завершения остальных потоков. Потом все потоки одновременно пройдут через стек потоков приложения, присваивая корням ссылки на новые местоположения сжатых объектов. И только после завершения всех потоков деятельность сборщика мусора считается оконченной и появляется возможность восстановить поток приложения.

Данный сценарий легко реализуется при помощи класса Barrier, который выглядит следующим образом (некоторые перегруженные версии методов не показаны):

public class Barrier : IDisposable {

public Barrier(Int32 participantCount, Action<Barrier> postPhaseAction); public void DisposeQ;

public Int64 AddParticipants(Int32 participantCount); // Добавление

// участников

public void RemoveParticipants(Int32 participantCount); // Удаление

// участников

public Boolean SignalAndWait(

Int32 millisecondsTimeout, CancellatlonToken CancellationToken);

public Int64 CurrentPhaseNumber { get; } // Показывает фазы процесса

// (начиная с 0)

public Int32 ParticipantCount { get; } 11 Количество участников

public Int32 PantlcipantsRemainlng { get; } // Число потоков, необходимых

// для вызова SignalAndWait

}

При конструировании класса Barrier указывается количество потоков, которые будут принимать участие в работе. Можно также передать конструктору делегата Action<Barrier>, ссылающегося на код, который будет вызван после завершения всеми участниками очередной фазы. Динамически добавлять потоки к классу Barrier и удалять их оттуда можно при помощи методов AddParticipant и RemoveParticipant, но на практике это делается крайне редко. Завершивший свою фазу работы поток должен вызвать метод SignalAndWait, который заставит метод Barrier заблокировать данный поток (с помощью конструкции ManualResetEventSlim). После вызова метода SignalAndWait всеми участниками метод Barrier вызывает делегата (с помощью последнего обращавшегося к методу SignalAndWait потока) и снимает блокировку со всех потоков, давая им возможность перейти к следующей фазе.

Выводы по гибридным конструкциям

Я рекомендую по возможности избегать кода, блокирующего потоки. Выполняя асинхронные вычисления или операции ввода-вывода, передавайте данные от одного потока к другому так, чтобы исключить одновременную попытку доступа к данным со стороны нескольких потоков. Если это невозможно, используйте методы классов Volatile и Interlocked, так как они работают быстро и не блокируют потоки. К сожалению, они подходят только для работы с простыми типами. Впрочем, даже в этом случае вы можете выполнять достаточно сложные операции, описанные в предыдущей главе.

Две основные причины, по которым приходится блокировать потоки:

□ Упрощение модели программирования. Блокируя поток, вы жертвуете ресурсами и производительностью, но получаете возможность писать код последовательно, не прибегая к методам обратного вызова. Асинхронные функции C# предоставляют упрощенную модель программирования без необходимости блокирования потоков.

□ Поток имеет определенное назначение. Некоторые потоки используются для решения конкретных задач. Лучший пример такого рода — основной поток приложения. Без блокировки он рано или поздно вернет управление, и процесс завершится. Другим примером является поток или потоки графического интерфейса приложения. Windows требует, чтобы манипуляции окнами и элементами управления осуществлял только породивший их поток. Поэтому периодически приходится писать код, блокирующий GUI-поток до завершения каких-то других операций. И только после этого данный поток обновляет окна и элементы

управления. Разумеется, блокировка GUI-потока подвешивает приложение

и мешает работе пользователя.

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

Если же вы, несмотря ни на что, решите блокировать потоки, для синхронизации потоков из разных доменов или процессов используйте конструкции режима ядра. Для атомарного управления состоянием через набор операций вам потребуется класс Monitor с закрытым полем1. В качестве альтернативы можно прибегнуть к блокированию на чтение-запись. В общем случае; это блокирование работает медленнее класса Monitor, но оно позволяет одновременно исполняться нескольким потокам чтения, что повышает общую производительность и минимизирует возможность блокировки потоков.

Также старайтесь избегать рекурсивных блокировок (особенно блокировок чтения-записи), так как они серьезно снижают производительность. Впрочем, класс Monitor, несмотря на свою рекурсивность, показывает высокую производительность[72] [73]. Кроме того, старайтесь не снимать блокировку в блоке finally, так как вход в блоки обработки исключений и выход из них негативно сказывается на производительности. Кроме того, выдача исключения при изменении состояния приводит к ситуации, когда другим потокам приходится работать с поврежденными данными, из-за чего результат работы приложения становится непредсказуемым и возникают бреши в системе безопасности.

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

Ну и, наконец, для вычислительных операций можно использовать задания (о которых мы говорили в главе 27), чтобы обойтись без многочисленных конструкций синхронизации. В частности, мне нравится, что с каждым заданием можно связать

одно или несколько других заданий, которые начинают выполняться средствами пула потоков при завершении некой операции. Это намного лучше, чем блокировать поток, ожидая завершения операции. Для операций ввода-вывода следует вызывать различные методы XxxAsync, которые заставляют ваш код продолжать выполнение после завершений операций ввода-вывода (аналог заданий, выполняемых по завершению операции).

Блокировка с двойной проверкой

Существует известный прием, называемый блокировкой с двойной проверкой (double-check locking). К нему прибегают, если нужно отложить создание одноэлементного объекта до тех пор, пока он не потребуется приложению — иногда это называют отложенной инициализацией (lazy initialization). Без запроса объект никогда не создается, что экономит время и память. Проблемы могут возникнуть при одновременном запросе объекта несколькими потоками. Чтобы в результате у вас появился всего один объект, потребуется применение некоторого способа синхронизации потоков.

Этот прием известен вовсе не благодаря своей выдающейся интересности или полезности, просто о нем очень много писали. Раньше он часто применялся при программировании на Java, но позже обнаружилось, что Java не гарантирует стопроцентной работоспособности результата. Документ с описанием этой проблемы находится по адресу www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html.

Думаю, вы будете рады узнать, что благодаря модели памяти и доступу к волатильным полям (см. главу 29) CLR прекрасно поддерживает блокирование с двойной проверкой. Вот код, демонстрирующий реализацию данной техники на языке С#:

internal sealed class Singleton {

// Объект s_lock требуется для обеспечения безопасности

// в многопоточной среде. Наличие этого объекта предполагает,

// что для создания одноэлементного объекта требуется больше // ресурсов, чем для объекта System.Object и что эта процедура // может вовсе не понадобиться. В противном случае проще и эффективнее // получить одноэлементный объект в конструкторе класса private static readonly Object s_lock = new ObjectQ;

// Это поле ссылается на один объект Singleton private static Singleton s_value = null;

// Закрытый конструктор не дает внешнему коду создавать экземпляры private SingletonQ {

// Код инициализации объекта Singleton

}

// Открытый статический метод, возвращающий объект Singleton // (создавая его при необходимости) public static Singleton GetSingleton() {

// Если объект Singleton уже создан, возвращаем его if (svalue != 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;

}

}

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

Теперь я объясню, почему этот паттерн не работает в Java. В начале метода GetSingleton виртуальная машина Java считывает значение поля s_value в регистр процессора и при выполнении второй инструкции if ограничивается запросом к этому регистру. В итоге результатом данной проверки всегда является значение true, а это означает, что в создании объекта Singleton принимают участие все потоки. Разумеется, это возможно только при условии, что все потоки вызвали метод GetSingleton одновременно, чего в большинстве случаев не происходит. Именно поэтому ошибка столько времени оставалась нераспознанной.

В CLR вызов любого метода блокирования означает установку непреодолимого барьера на доступ к памяти: вся запись в переменные должна завершиться до этого барьера, а любое чтение переменных может начаться только после барьера. Для метода GetSingleton это означает, что повторное чтение поля s_value должно быть произведено после вызова метода Monitor. Enter; в процессе вызова метода значение поля нельзя сохранить в регистре.

Внутри метода GetSingleton вызывается метод Volatile .Write. Предположим, что вторая инструкция if содержит следующую строку кода: s value = new SingletonQ; // В идеале хотелось бы использовать эту команду

Можно ожидать, что компилятор создаст код, выделяющий память под объект Singleton, вызовет конструктор для инициализации полей данного объекта и присвоит ссылку на него полю s_value, чтобы это значение увидели другие потоки — это называется публикацией (publishing). Однако компилятор может выделить память под объект Singleton, назначить ссылку переменной s_value (выполнив публикацию) и только после этого вызвать конструктор. Если в процедуре участвует всего один поток, подобное изменение очередности операций не имеет значения. Но что произойдет, если после публикации ссылки в поле s_value, но до вызова конструктора другой поток вызовет метод GetSingleton? Этот поток обнаружит, что значение поля s_value отлично от null и начнет пользоваться объектом Singleton, хотя его конструктор еще не закончил работу! Подобную ошибку крайне сложно отследить, особенно из-за того, что время ее появления случайно.

Эту проблему решает вызов метода Interlocked. Exchange. Он гарантирует, что ссылка из переменной temp будет опубликована в поле s_value только после того, как конструктор завершит свою работу. Альтернативным способом решения проблемы является пометка поля s_value ключевым словом volatile. Запись в такое волатильное (неустойчивое) поле s_value возможна только после завершения конструктора. К сожалению, то же самое относится ко всем процедурам чтения волатильного поля, а так как никакой необходимости в этом нет, вряд ли стоит идти на снижение производительности без полной уверенности в полезности этого.

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

internal sealed class Singleton {

private static Singleton svalue = new SingletonQ;

// Закрытый конструктор не дает коду вне данного класса // создавать экземпляры private Singleton() {

// Код инициализации объекта Singleton

}

// Открытый статический метод, возвращающий объект Singleton // (и создающий его, если это нужно)

public static Singleton GetSingleton() { return svalue; }

}

Так как CLR автоматически вызывает конструктор класса при первой попытке получить доступ к члену этого класса, при первом запросе потока к методу GetSingleton класса Singleton автоматически создается экземпляр объекта. Более того, среда CLR гарантирует безопасность в отношении потоков при вызове конструктора класса. Все это уже объяснялось в главе 8. Недостатком такого подхода является вызов конструктора типа при первом доступе к любому члену класса. То есть если в типе Singleton определить другие статические члены, первая же

попытка доступа к любому из них приведет к появлению объекта Singleton. Некоторые разработчики обходят данную проблему при помощи вложенных классов. Рассмотрим третий способ создания одиночного объекта Singleton:

internal sealed class Singleton {

private static Singleton s_value = null;

// Закрытый конструктор не дает коду вне данного // класса создавать экземпляры private SingletonQ {

// Код инициализации объекта Singleton

}

// Открытый статический метод, возвращающий объект Singleton // (и создающий его, если это нужно) public static Singleton GetSingletonQ { if (s_value != null) return s_value;

// Создание нового объекта Singleton и превращение его в корень,

// если этого еще не сделал другой поток Singleton temp = new SingletonQ;

Interlocked.CompareExchange(ref s_value, temp, null);

// При потере этого потока второй объект Singleton

// утилизируется сборщиком мусора

return svalue; // Возвращение ссылки на объект

}

При одновременном вызове метода GetSingleton различными потоками в этой версии кода может появиться два (и более) объекта Singleton. Однако метод Interlocked.CompareExchange гарантирует публикацию в поле s_value только одной ссылки. Любой объект, не превращенный этим полем в корневой, будет утилизирован при первой же сборке мусора. Впрочем, в большинстве приложений практически никогда не возникает ситуация одновременного вызова метода GetSingleton разными потоками, поэтому там вряд ли когда-нибудь появится более одного объекта Singleton.

Возможно, вас беспокоит возможность создания множественных объектов Singleton, но данный код имеет массу достоинств. Во-первых, он очень быстро работает. Во-вторых, в нем никогда не блокируются потоки. Ведь когда поток из пула блокируется на объекте Monitor или на любой другой конструкции синхронизации потоков режима ядра, пул порождает еще один поток, чтобы загрузить процессор. Выделяется и инициализируется дополнительная память, а все библиотеки получают уведомление о присоединении нового потока. С методом CompareExchange такого никогда не происходит. Разумеется, данную технику можно использовать только при отсутствии побочных эффектов у конструктора.

В FCL существует два типа, реализующие описанные в данном разделе шаблоны программирования. Вот как выглядит обобщенный класс System .Lazy (некоторые методы не показаны):

public class Lazy<T> {

public Lazy(Func<T> valueFactory, LazyThreadSafetyMode mode); public Boolean IsValueCreated { get; } public T Value { get; }

>

А вот как он работает:

public static void Main() {

// Создание оболочки отложенной инициализации для получения DateTime Lazy<String> s = new Lazy<String>(

() => DateTime.Now.ToLongTimeString(),

LazyThreadSafetyMode.PublicationOnly);

Console.WriteLine(s.IsValueCreated);

Console.WriteLine(s.Value); Console.WriteLine(s.IsValueCreated);

Thread.Sleep(10000);

Console.WriteLine(s. Value);

}

После запуска данного кода я получил:

False

2:40:42 РМ True

2:40:42 PM I? Обратите внимание, 10 секунд прошло, а время осталось прежним

Код сконструировал экземпляр класса Lazy и передал ему один из флагов LazyThreadSafetyMode. Вот как выглядят и что означают данные флаги:

public enum LazyThreadSafetyMode {

None, // Безопасность в отношении потоков не

// поддерживается (хорошо для GUI-приложений) ExecutionAndPublication, // Используется блокировка с двойной проверкой PublicationOnly, // Используется метод Interlocked.CompareExchange

}

В некоторых ситуациях с ограничениями по памяти отсутствует необходимость в создании экземпляра класса Lazy. Вместо этого можно воспользоваться статическими методами класса System.Threading. Lazylnitializer. Вот какой выглядит:

public static class Lazylnitializer {

// Эти два метода используют Interlocked.CompareExchange

public static T EnsureInitialized<T>(ref T target) where T: class;

public static T EnsureInitialized<T>(

ref T target, Func<T> valueFactory) where T: class;

// Эти два метода передают syncLock в методы Enter и Exit класса Monitor public static T EnsureInitialized<T>(

ref Т target, ref Boolean initialized, ref Object syncLock); public static T EnsureInitialized<T>(ref T target,

ref Boolean initialized, ref Object syncLock, Func<T> valueFactory);

}

Возможность явно указать объект синхронизации в параметре sync Lock метода Ensunelnitialized позволяет одной блокировкой защитить сразу несколько полей и функций инициализации.

Пример использования метода из данного класса:

public static void Main() {

String name = null;

// Так как имя равно null, запускается делегат и инициализирует поле имени Lazylnitializer.Ensurelnitialized(ref name, () => "Jeffrey");

Console.WriteLine(name); // Выводится "Jeffrey"

// Так как имя отлично от null, делегат не запускается и имя не меняется Lazylnitializer.Ensurelnitialized(ref name, () => "Richter");

Console.WriteLine(name); // Снова выводится "Jeffrey"

}

Паттерн условной переменной

Предположим, что некий поток выполняет код при соблюдении сложного условия. Можно просто организовать зацикливание этого потока с периодической проверкой условия. Однако, во-первых, это пустая трата процессорного времени, во-вторых, невозможно атомарно проверить несколько переменных, входящих в условие. К счастью, существует шаблон программирования, позволяющий потокам эффективно синхронизировать свои операции на основе сложного условия. Он называется паттерном условной переменной (condition variable pattern), а для его применения можно воспользоваться следующими методами класса Monitor:

public static class Monitor {

public static Boolean Wait(0bject obj);

public static Boolean Wait(0bject obj, Int32 millisecondsTimeout);

public static void Pulse(0bject obj); public static void PulseAll(ObJect obj);

}

Вот как выглядит данный паттерн:

internal sealed class ConditionVariablePattern { private readonly Object m_lock = new ObJectQ; private Boolean m_condition = false;

public void ThreadlQ {

Monitor.Enter(m_lock); // Взаимоисключающая блокировка

// "Атомарная" проверка сложного условия блокирования while (Imcondition) {

// Если условие не соблюдается, ждем, что его поменяет другой поток Monitor.Wait(mlock); // На время снимаем блокировку,

// чтобы другой поток мог ее получить

 


// Условие соблюдено, обрабатываем данные... Monitor.Exit(mlock); // Снятие блокировки

> public void Thread2() {

Monitor.Enter(mlock); // Взаимоисключающая блокировка

// Обрабатываем данные и изменяем условие... mcondition = true;

// Monitor.Pulse(m_lock); // Будим одного ожидающего ПОСЛЕ отмены блокировки

Monitor.PulseAll(m_lock); // Будим всех ожидающих ПОСЛЕ отмены блокировки

Monitor.Exit(mlock); // Снятие блокировки

}

}

В этом коде поток, выполняющий метод Threadl, входит в код взаимоисключающей блокировки и осуществляет проверку условия. В данном случае я всего лишь проверяю значение поля Boolean, но условие может быть сколь угодно сложным. К примеру, можно взять текущую дату и удостовериться, что сейчас вторник и март, а заодно проверить, что коллекция состоит из 10 элементов. Если условие не соблюдается, поток не зацикливается на проверке, так как это было бы напрасной тратой процессорного времени, а вызывает метод Wait. Данный метод снимает блокировку, чтобы ее мог получить другой поток, и приостанавливает вызывающий поток.

Метод Thread2 содержит код, выполняемый вторым потоком. Он вызывает метод Enter для блокировки, обрабатывает какие-то данные, меняя при этом состояние условия, после чего вызывает метод Pulse (АН), разблокирующий поток после вызова метода Wait. Метод Pulse разблокирует поток, ожидающий дольше всех (если такие имеются), в то время как метод PulseAll разблокирует все ожидающие потоки (если такие есть). Однако ни один из этих потоков пока не просыпается. Поток, выполняющий метод Thread2, должен вызвать метод Monitor. Exit, давая шанс другому потоку выполнить блокировку. Кроме того, в результате выполнения метода PulseAll потоки разблокируются не одновременно. После освобождения потока, вызвавшего метод Wait, он становится владельцем блокировки, а так как это взаимоисключающее блокирование, в каждый момент времени им может владеть только один поток. Другие потоки имеют шанс получить право на блокировку только после того, как текущий владелец вызовет метод Wait или Exit.

Проснувшись, поток, выполняющий метод Threadl, снова проверяет условие в цикле. Если оно все еще не соблюдено, он опять вызывает метод Wait. В противном случае он обрабатывает данные и, в конце концов, вызывает метод Exit, снимая блокировку и давая доступ другим потокам возможность получить ее. Таким образом данный паттерн позволяет проверить несколько формирующих сложное условие переменных при помощи простой логики синхронизации (всего одной блокировки), а несколько ожидающих потоков могут разблокироваться без нарушения какой- либо логики, хотя при этом возможна напрасная трата процессорного времени.

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

internal sealed class SynchronizedQueue<T> { private readonly Object m_lock = new ObJectQ; private readonly Queue<T> m_queue = new Queue<T>();

public void Enqueue(T item) {

Monitor.Enter(m_lock);

II После постановки элемента в очередь пробуждаем II один/все ожидающие потоки mqueue.Enqueue(item);

Monitor.PulseAll(m_lock);

Monitor.Exit(m_lock);

}

public T DequeueQ {

Monitor.Enter(m_lock);

II Выполняем цикл, пока очередь не опустеет (условие) while (m_queue.Count == 0)

Monitor.Wait(m_queue);

// Удаляем элемент из очереди и возвращаем его на обработку Т item = m_queue.Dequeue();

Monitor.Exit(m_lock); return item;

}

}

Асинхронная синхронизация

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

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

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

Многие из проблем, решаемые при помощи описанных в этой главе конструкций, намного успешнее решаются средствами класса Task, рассмотренного в главе 27. К примеру, возьмем класс Barrier: для работы на каждом этапе можно было бы создать группу заданий (объектов Task), а после их завершения ничто не мешает нам продолжить работу с дополнительными объектами Task. Такой подход имеет целый ряд преимуществ в сравнении с конструкциями, описанными в этой главе:

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