ГЛАВА 10 Полезные средства для синхронизации потоков

Зa годы своей практики я часто сталкивался с проблемами синхронизации потоков и поэтому написал ряд С++-классов и компонентов, которыми я поделюсь с Вами в этой главе. Надеюсь, этот код Вам пригодится и сэкономит массу времени при разра ботке приложений — или по крайней мере чему-нибудь научит.

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

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

Наконец, я продемонстрирую свою функцию WaitForMultipleExpressions. Работая по аналогии с WaitForMultipleObjects, заставляющей ждать освобождения одного или всех объектов, она позволяет указывать более сложные условия пробуждения потока.

Реализация критической секции: объект-оптекс

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

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

Значит, в критических секциях есть какой-то объект ядра, умеющий переводить поток в эффективный ждущий режим. Критическая секция обладает высоким быст родействием, потому что этот объект ядра используется только при конкуренции потоков за вход в критическую секцию. И он не задействован, пока потоку удастся немедленно захватывать защищаемый ресурс, работать с ним и освобождать его без конкуренции со стороны других потоков, так как выходить из пользовательского режима потоку в этом случае не требуется. В большинстве приложений конкуренция двух (или более) потоков за одновременный вход в критическую секцию наблюдает ся нечасто.

Мой вариант критической секции содержится в файлах Optex.h и Optex.cpp (см, листинг на рис. 10-1). Я назвал cc оптимизированным мъютексом оптексом и реализовал в виде С++-класса. Разобравшись в этом коде, Вы поймете, почему крити ческие секции работают быстрее объектов ядра «мьютекс».

Поскольку я создавал собственную критическую секцию, у меня была возможность расширить ее функциональность. Например, мой класс COpcex позволяет синхрони зировать потоки из разных процессов. Это фантастически полезная особенность моей реализации: Вы получаете высокоэффективный механизм взаимодействия между по токами из разных процессов

Чтобы использовать мой оптекс, Вы просто объявляете объект класса COptex. Для этого объекта предусмотрено три конструктора;

COptex::(DWORD dwSpinCount = 4000);
COptex::(PCSTR pszNane, DWORD dwSpinCount = 4000);
COptex::(PCWSTR pszName, DWORD dwSpinCount = 4000);

Первый создает объект COptex, применимый для синхронизации потоков лишь одного процесса. Оптекс этого типа работает быстрее, чем межпроцессный. Осталь ные два конструктора создают оптекс, которым могут пользоваться потоки из разных процессов. В параметре pszName Вы должны передавать ANSI- или Unicode-строку, уникально идентифицирующую каждый разделяемый оптекс. Чтобы процессы разде ляли один оптекс, они должны создать по экземпляру объекта COptex с одинаковым именем.

Поток входит в объект COptex и покидает его, вызывая методы Enter и Leave:

void COptex::Enter();
void COptex::Leave();

Я даже включил методы, эквивалентные функциям TryEnterCriticalSection и SetCriti calSectionSpinCount критических секций:

BOOL COptex::TryEnter();
void COptex::SetSpinCount(DWORD dwSpinCount);

Тип оптекса (одно- или межпроцессный) позволяет выяснить последний метод класса COptex, показанный ниже. (Необходимость в его вызове возникает очень ред ко, но внутренние функции класса время от времени к нему обращаются.)

BOOL COptex::IsSingleProcessOptex() const;

Вот и все (открытые) функции, о которых Вам нужно знать, чтобы пользоваться оптексом. Теперь я объясню, как работает оптекс. Он — как, в сущности, и критичес кая секция — содержит несколько псрсменных-членов. Значения этих переменных отражают состояние оптекса. Просмотрев файл Optex.h, Вы увидите, что в основном они являются элементами структуры SHAREDINFO, а остальные — членами самого класса. Назначение каждой переменной описывается в следующей таблице.

Переменная

Описание

m_lLockCount

Сообщает, сколько раз потоки пытались занять оптекс Ее значение равно 0, если оптекс не занят ни одним потоком.

т dwThreadId

Сообщает уникальный идентификатор потока — владельца оптекса Ее значение равно 0, если оптекс не занят ни одним потоком

m_lRecurseCount

Указывает, сколько раз отеке был занят потоком- владельцем. Ее зна чение равно 0, если оптекс не занят ни одним потоком.

m_hevt

Содержит описатель объекта ядра «событие", используемого, только если поток пытается войти в оптекс в то время, как им владеет другой поток. Описатели объектов ядра специфичны для конкретных процес сов, и имении поэтому данная переменная не включена в структуру SHAREDINFO.

m_dwSpinCount

Определяет, сколько попыток входа в оптекс должен предпринять по ток до перехода в состояние ожидания на объекте ядра «событие». На однопроцессорной машине значение этой переменной всегда равно 0.

m_hfm

Содержит описатель объекта ядра «проекция файла», используемого при разделении оптекса несколькими процессами Описатели объек тов ядра специфичны для конкретных процессов, и именно поэтому данная переменная не включена в структуру SHAREDINFO. В однопро цессном оптексе значение этой переменной всегда равно NULL

m_psi

Содержит указатель на элементы данных оптекса, которые могут ис пользоваться несколькими процессами. Адреса памяти специфичны для конкретных процессов, и именно поэтому данная переменная не включена в структуру SHAREDINFO. В однопроцессном оптексе эта пе ременная указывает ни блок памяти, выделенный из кучи, а в межпро цессном — на файл, спроецированный в память.

Комментариев в исходном коде вполне достаточно, и у Вас не должно возникнуть трудностей в понимании того, как работает оптекс. Важно лишь отметить, что высо кое быстродействие оптекса достигается за счет интенсивного использования lnterlo ced-функций. Благодаря им код выполняется в пользовательском режиме и перехо дит в режим ядра только в том случае, когда это действительно необходимо.

Программа-пример Optex

Эта программа, «10 Optex.exe» (см.листинг на рис, 10-1), предназначенадля провер ки того, что класс COptex работает корректно. Файлы исходного кода и ресурсов этой программы находятся в каталоге 10-Optex на компакт-диске, прилагаемом к книге. Я всегда запускаю такие приложения под управлением отладчика, чтобы наблюдать за всеми функциями и переменными — членами классов,

При запуске программа сначала определяет, является ли она первым экземпляром. Для этого я создаю именованный объект ядра «событие». Реально я им не пользуюсь, а просто смотрю, вернет ли GetLastError значение ERROR_ALREADY_EXISTS. Если да, значит, это второй экземпляр программы. Зачем мнс два экземпляра этой програм мы, я объясню позже.

Если же это первый экземпляр, я создаю однопроцессный объект COptex и вызы ваю свою функцию FirstFunc. Она выполняет серию операций с объектом-оптексом и создает второй поток, который манипулирует тем же оптексом. На этом этапе с оптексом работают два потока из одного процесса. Что именно они делают, Вы узна ете, просмотрев исходный код. Я пытался охватить все мыслимые сценарии, чтобы дать шанс на выполнение каждому блоку кода в классе COptex

После тестирования однопроцессного оптекса я начинаю проверку межпроцесс ного оптекса. В функции _tWinMain по завершении первого вызова FirstFunc я создаю

другой объект-оптекс COptex. Но на этот раз я присваиваю ему имя — CrossOptexTest. Простое присвоение оптексу имени в момент создания превращает этот объект в межпроцессный. Далее я снова вызываю FirstFunc, передавая сй адрес межпроцессно го оптекса При этом FirstFunc выполняет в основном тот же код, что и раньше. Но теперь она порождает не второй поток, а дочерний процесс.

Этот дочерний процесс представляет собой всего лишь второй экземпляр той же программы. Однако, создав при запуске объектядра «событие", она обнаруживает, что такой объект уже существует. Тем самым она узнает, что является вторым экземпля ром, и выполняет другой код (отличный от того, который выполняется первым эк земпляром). Первое, что делает второй экземпляр, — вызывает DebugBreak:

VOID DebugBreak();

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

Наконец, второй экземпляр программы вызывает функцию SecondFunc, передавая сй адрес межпроцессного оптекса, и с этого момента выполняется тот же набор тес тов. Единственное, что в них меняется, — два потока, манипулирующие оптексом, принадлежат разным процессам.

Optex

 

Создание инверсных семафоров и типов данных, безопасных в многопоточной среде

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

Я мог бы придумать много применений такому объекту. Например, потокдолжсн пробудиться после того, как определенная операция будет выполнена 100 раз. Чтобы осуществить это, нужен объект ядра, счетчик которого можно было бы инициализи ровать этим значением. Пока он больше 0, объект остается в занятом состоянии. По окончании каждой операции Вы уменьшаете счетчик в объекте ядра на 1. Как только счетчик обнуляется, объект переходит в свободное состояние, сообщая другому по току, что тот может пробудиться и чем-то заняться. Это типичная задача, и я не пони маю, почему в Windows нет подходящего синхронизирующего объекта

В сущности, Microsoft могла бы легко решить эту задачу, предусмотрев в семафо ре возможность присвоения отрицательных значений его счетчику текущего числа ресурсов. Тогда Вы инициализировали бы счетчик семафора значением -99 и по окон чании каждой операции вызывали бы ReleaseSemaphore, Как только его счетчик дос тиг бы значения 1, объект перешел бы в свободное состояние. После этого мог бы пробудиться другой Ваш поток и выполнить свою работу. Увы, Microsoft запрещает

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

В этом разделе я познакомлю Вас со своим набором С++-классов, которые дей ствуют как инверсный семафор и делают уйму всяких других вещей. Исходный код этих классов находится в файле Interlocked.h (см листинг на рис. 10-2)

Когда я впервые взялся за решение этой проблемы, я понимал, что главное в нем — обеспечить безопасность манипуляций над переменной в многопоточной среде. Я хотел найти элегантное решение, которое позволило бы легко писать код, ссылаю щийся на эту переменную. Очевидно, что самый простой способ обезопасить какой то ресурс от повреждения в многопоточной среде, — защитить его с помощью кри тической секции. В С++ это можно сделать бсз особого труда. Достаточно создать C++ класс, который содержит защищаемую переменную и структуру CRITICAL_SECTION. В конструкторе Вы вызываете lnitializeCriticalSection, а в деструкторе — DeleteCritical Section. Затем для любой переменной-члена Вы вызываете EnterCriticalSection, что-то делаете с этой переменной и вызываете LeaveCriticalSection. Если Вы именно так реа лизуете С++-класс, то писать безопасный код, обращающийся к какой-либо структу ре данных, будет несложно. Этот принцип положен мной в основу всех С++-классов, о которых я буду рассказывать в данном разделе. (Конечно, вместо критических сек ций я мог бы использовать оптекс, рассмотренный в предыдущем разделе.)

Первый класс, CResGuard, охраняет доступ к ресурсу. Он содержит два элемента данных: CRITICAL_SECTION и LONG. Последний используется для слежения за тем, сколько раз поток, владеющий ресурсом, входил в критическую секцию. Эта инфор мация полезна при отладке. Конструктор и деструктор объекта CResGuard вызывают сответственно InitializeCriticalSeclion и DeleteCriticalSection. Поскольку создать объект может лишь единственный поток, конструктор и деструктор какого-либо С++-объек та не обязательно должен быть реентерабельным. Функция-член IsGuarded просто сообщает, была ли хоть раз вызвана EnterCriticalSection для данного объекта. Как я уже говорил, все это предназначено для отладки. Включение CRITICAL_SECTION в C++ объект гарантирует корректность инициализации и удаления критической секции.

Класс CResGuard также включает открытый вложенный С++-класс CGuard. Объект CGuard содержит ссылку на объект CResGuard и предусматривает лишь конструктор и деструктор. Конструктор обращается к функции-члену Guard класса CResGuard, вызывающей EnterCriticalSection, а деструктор — к функции-члену Unguard того же класса, вызывающей LeaveCriticalSection. Такая схема упрощает манипуляции с CRITI CAL_SECTION. Вот небольшой фрагмент кода, иллюстрирующий применение этих классов:

struct SomeDataStruct
{
...
} g_SomeSharedData;

// Создаем объект CResGuard, защищающий g_SomeSharedData.
// Примечание: Конструктор инициализирует критическую секцию, а деструктор удаляет ее.

CResGuard g_rgSomeSharedData;

void AFunction()
{

// эта функция работает с разделяемой структурой данных

// защищаем ресурс от одновременного доступа со стороны нескольких потоков
CResGuard::CGuard gDummy(g_rgSomeSharedData);
// входим в критическую секцию

// работаем c ресурсом g_SomeSharedData

...

}
// Примечание: LeaveCriticalSection вызывается, когда gDummy
// выходит за пределы области видимости

Следующий С++-класс, CInterlockedType, содержит все, что нужно для создания объекта данных, безопасного в многопоточной среде. Я сделал CInterlockediype клас сом шаблона, чтобы его можно было применять для любых типов данных. Поэтому Вы можете использовать его, например, с целочисленной переменной, строкой или произвольной структурой данных.

Каждый экземпляр объекта CInterlockedType содержит два элемента данных Пер вый - это экземпляр шаблонного типа данных, который Вы хотите сделать безопас ным в многопоточной среде Он является закрытым, и им можно манипулировать только через функции-члены класса CInterlockedType. Второй элемент данных пред ставляет собой экземпляр объекта CResGuard, так чго класс, производный от CInter lockedType, может легко защитить свои данные.

Предполагается, что Вы всегда будете создавать свой класс, используя класс CInter lockedType как базовый. Ранее я ужс говорил, что класс CInterlockedType предостав ляет все необходимое для создания объекта, безопасного в многопоточной среде, но производный класс должен сам позаботиться о корректном использовании элемен тов CInterlockedType.

Класс CInterlockedType содержит всего четыре открытые функции- конструктор, инициализирующий объект данных, и конструктор, не инициализирующий этот объ ект, а также виртуальный деструктор, который ничего не делает, и оператор приведе ния типа (cast operator). Последний просто гарантирует безопасный доступ к данным, охраняя ресурс и возвращая текущее значение объекта. (Ресурс автоматически раз блокируется при выходе локальной переменной x за пределы ее области видимости.) Этот оператор упрощает безопасную проверку значения объекта данных, содержаще гося в классе.

В классе CInterlockedType также присутствуют три невиртуальные защищенные функции, которые будут вызываться производным классом. Две функции GetVal воз вращают текущее значение объекта данных. В отладочных версиях файла обе эти функции сначала проверяют, охраняется ли объект данных. Если бы он не охранял ся, GetVal могла бы вернуть значение объекта, а затем позволить другому потоку из менить его до того, как первый поток успеет что-то сделать с этим значением. Я пред полагаю, что вызывающий поток получает значение объекта для того, чтобы как-то изменить его Поэтому функции GetVal требуют от вызывающего потока охраны дос тупа к данным. Определив, что данные охраняются, функции GetVal возвращают теку щее значение.

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

Третья невиртуальная защищенная функция-член — SetVal, Желая модифицировать данные, любая функция-члсн производного класса должна защитить доступ к этим данным, а потом вызвать функцию SetVal. Как и GetVal, функция SetVal сначала прово дит отладочную проверку, чтобы убедиться, не пабыл ли код производного класса за щитить доступ к данным. Затем SetVal проверяет, действительно ли данные изменя ются. Если да, SetVal сохраняет старое значение, присваивает объекту новое значение и вызывает виртуальную защищенную функцию-члеи OnValChanged, передавая ей оба значения. В классе CInterlockedType последняя функция реализована так, что она

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

До сих пор речь шла в основном об абстрактных классах и концепциях. Теперь посмотрим, как пользоваться этой архитектурой на благо всего человечества. Я пред ставлю Вам CInterlockedScalar — класс шаблона, производный от CInterlockedType. С его помощью Вы сможете создавать безопасные в многопоточной среде скалярные (простые) типы данных — байт, символ, l6-, 32- или 64-битное целое, вещественное значение (с плавающей точкой) и т. д. Поскольку CInterlockedScalar является произ водным от класса CInrerlockedType, у него нет собственных элементов данных. Кон структор CInterlockedScalar просто обращается к конструктору CInterlockedType, пе редавая ему начальное значение объекта скалярных данных Класс CInterlockedScalar работает только с числовыми значениями, и в качестве начального значения я выб рал нуль, чтобы наш объект всегда создавался в известном состоянии Ну а деструк тор класса CInterlockedScalar вообще ничего не делает.

Остальные функции-члены класса CInterlockedScalar отвечают за изменение ска лярного значения. Для каждой операции над ним предусмотрена отдельная функция член. Чтобы класс CInterlockedScalar мог безопасно манипулировать своим объектом данных, все функции-члены псрсд выполнением какой-либо операции блокируют доступ к этому объекту. Функции очень просты, и я не стану подробно объяснять их; просмотрев исходный код, Вы сами поймете, что они делают. Однако я покажу, как пользоваться этими классами. В следующем фрагменте кода объявляется безопасная в многопоточной среде переменная типа BYTE и над ней выполняется серия операций:

CInterlockedScalar<BYTE> b = 5, // безопасная переменнан типа BYTE

BYTE b2 = 10; // небезопасная переменная типа BYTF

b2 = b++; // b2=5, b=6

b *= 4; // b=24

b2 = b, // b2=24, b=24

b += b; // b=48

b %= 2; // b=0

Работа с безопасной скалярной переменной также проста, как и с небезопасной. Благодаря замещению (перегрузке) операторов в С++ даже код в таких случаях фак тически одинаков! С помощью С++-классов, о которых я уже рассказал, любую небе зопасную переменную можно легко превратить в безопасную, внеся лишь минималь ные изменения в исходный код своей программы.

Проектируя все эти классы, я хотел создать объект, чье поведение было бы про тивоположно поведению семафора. Эту функциональность предоставляет мой C++ класс CWhenZero, производный от CInrerlockedScaIar Когда скалярное значение рав но 0, объект CWhenZero пребывает в свободном состоянии, а когда оно не равно 0 — в занятом

Как Вам известно, С++-объекты не поддерживают такие состояния — в них могут находиться только объекты ядра. Значит, в CWhenZero нужны дополнительные эле менты данных с описателями объектов ядра "событие». Я включил в объект CWhenZero два элемента данных: m_hevtZero (описатель объекта ядра «событие», переходящего в свободное состояние, когда объект данных содержит нулевое значение) и m_hevt NotZero (описатель объекта ядра «событие», переходящего в свободное состояние, когда объект данных содержит ненулевое значение).

Конструктор CWhenZero принимает начальное значение для объекта данных, а также позволяет указать, какими должны быть объекты ядра "событие" — со сбросом

вручную (по умолчанию) или с автосбросом Далее конструктор, вызывая CreateEvent, создает два объекта ядра «событие» и переводит их в свободное или занятое состоя ние в зависимости от того, равно ли нулю начальное значение. Деструктор CWhenZero просто закрывает описатели этих двух объектов ядра Поскольку CWhenZero откры то наследует от класса CInterlockedScalar, все функции-члены перегруженного опера тора доступны и пользователям объекта CWhenZero.

Помните защищенную функцию-член OnValChanged, объявленную внутри класса CInterLockedType Так вот, класс CWhenZero замещает эту виртуальную функцию. Она отвечает за перевод объектов ядра «событие» в свободное или занятое состояние в соответствии со значением объекта данных. OnValChanged вызывается при каждом изменении этого значения Ее реализация в CWhenZero проверяет, равно ли нулю новое значение Если да, функция устанавливает событие m_hevtZero и сбрасывает событие m_hevtNotZero. Нет — все делается наоборот

Теперь, если Вы хотите, чтобы поток ждал нулевого значения объекта данных, от Вас требуется лишь следующее:

CWhenZero<BYTE> b = 0; // безопасная переменная типа BYTE

// немедленно возвращает управление, так как b равна 0
WaitForSingleObject(b, INFINITE);

b = 5;

// возвращает управление, только если другой поток присваивает D нулевое значение
WaitForSingleObject(b, INFINITE);

Вы можете вызывать WaitForSingleObject именно таким образом, потому что класс CWhenZero включает и функцию-член оператора приведения, которая приводит объ ект CWhenZero к типу HANDLE объекта ядра. Иначе говоря, передача С++-объекта CWhenZero любой Windows-функции, ожидающей HANDLE, приводит к автоматичес кому вызову функции-члена оператора приведения, возвращаемое значение которой и передастся Windows-функции В данном случае эта функция-член возвращает опи сатель объекта ядра «событие» m_hevtZero.

Описатель события m_hevtNotZero внутри класса CWhenZero позволяет писать код, ждущий ненулевого значения объекта данных. К сожалению, в класс нельзя включить второй оператор приведения HANDLE — для получения описателя m_hevtNotZero Поэтому мне пришлось добавить функцию-члсн GetNotZeroHandle, которая исполь зуется так.

CWhenZero<BYTE> b = 5; // безопасная переменная типа BYTE

// немедленно возвращает управление, так как b не равна 0
WaitForSingleObject(b GetNotZeroHandle(), INFINITE);

b = 0,

// возвращает управление, только если другой поток присваивает b ненулевое значение WaitForSingleObject(b.GetNotZeroHandle(), INFINITE);

Программа-пример lnterlockedType

Эта программа, «10 IntertockedType ехе» (см. листинг на рис. 10-2), предназначена для тестирования только что описанных классов. Файлы исходного кода и ресурсов этой

программы находятся в каталоге l0-InterlockedType на компакт-диске, прилагаемом к книге. Как я уже говорил, такие приложения я всегда запускаю под управлением отладчика, чтобы наблюдать за всеми функциями и переменными — членами классов.

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

На примере этого кода хорошо видно, насколько тривиальным становится реше ние этой распространенной задачи программирования при использовании С++. Класс CWhenZero дает нам гораздо больше возможностей — не один лишь инверсный се мафор. Мы получаем теперь безопасный в многопоточной среде объект данных, ко торый переходит в свободное состояние, когда его значение обнуляется! Вы можете не только увеличивать и уменьшать счетчик семафора на 1, но и выполнять над ним любые математические и логические операции, в том числе сложение, вычитание, умножение, деление, вычисления по модулю! Так что объект CWhenZero намного функциональнее, чем объект ядра «семафор».

С этими классами шаблонов С++ можно много чего придумать. Например, создать класс CInterlockedString, производный от CInterlockedType, и с его помощыо безопас но манипулировать символьными строками. А потом создать класс CWhenCertain String, производный от CInterlockedString, чтобы освобождать объект ядра "событие», когда строка принимает определенное значение (или значения). В общем, возмож ности безграничны.

lntLockTest

 

Синхронизация в сценарии "один писатель/группа читателей"

Во многих приложениях возникает одна и та же проблемя синхронизации, о кото рой часто говорят как о сценарии «один писатель/группа читателей» (single-wrirer/ multiple-readers). В чем ее суть? Представьте: произвольное число потоков пытается

получить доступ к некоему разделяемому ресурсу. Каким-то потокам («писателям») нужно модифицировать данные, а каким-то («читателям») — лишь прочесть эти дан ные. Синхронизация такого процесса необходима хотя бы потому, что Вы должны соблюдать следующие правила:

  1. Когда один поток что-то пишет в область общих данных, другие этого делать не могут.
  2. Когда один поток что-то пишет в область общих данных, другие не могут ни чего считывать оттуда.
  3. Когда один поток считывает что-то из области общих данных, другие не мо гут туда ничего записывать
  4. Когда один поток считывает что-тo из области общих данных, другие тоже могут это делать.

Посмотрим на проблему в контексте базы данных. Допустим, с ней работают пять конечных пользователей: двое вводят в нее записи, трое — считывают.

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

Правило 2 запрещает доступ к записи, обновляемой в данный момент другим пользователем Будь то иначе, один пользователь считывал бы запись, когда другой пользователь изменял бы ее содержимое Что увидел бы на мониторе своего компью тера первый пользователь, предсказать не берусь. Правило 3 служит тем же целям, что и правило 2. И действительно, какая разница, кто первый получит доступ к данным: тот, кто записывает, или тот, кто считывает, — все равно одновременно этого делать нельзя

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

О'кэй, суть проблемы Вы ухватили. А теперь вопрос: как ее решить?

NOTE:
Я представлю здесь совершенно новый код Решения этой проблемы, которые я публиковал в прежних изданиях, часто критиковались по двум причинам. Во первых, предыдущие реализации работали слишком медленно, так как я пи сал их в расчете на самые разные сценарии Например, я шире использовал объекты ядра, стремясь синхронизировать доступ к базе данных потоков из разных процессов. Конечно, эти реализации работали и в сценарии для одно го процесса, но интенсивное использование объектов ядра приводило в этом случае к существенным издержкам. Похоже, сценарий для одного процесса более распространен, чем я думал

Во-вторых, в моей реализации был потенциальный риск блокировки no токов-«писателей» Из правил, о которых я рассказал в начале этого раздела, вытекает, что потоки-«писатели» — при обращении к базе данных очень боль шого количества потоков-«читателей» — могут вообще не получить доступ к этому ресурсу

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

Плоды своих трудов я инкапсулировал в С++-класс CSWMRG (я произношу его название как swimerge); это аббревиатура от «single writer/multiple reader guard». Он содержится в фцйлах SWMRG.h и SWMRG.cpp (см. листинг на рис. 10-3).

Использовать CSWMRG проще простого. Вы создаете объект С++-класса CSWMRG и вызываете нужные в Вашей программе функции-члены. В этом классе всего три метода (не считая конструктора и деструктора);

VOID CSWMRG:;WaitToRead(); // доступ к разделяемому ресурсу для чтения

VOID CSWMRG::WaitToWrite(); // монопольный доступ к разделяемому ресурсу для записи

VOID CSWMRG::Done(); // вызывается по окончании работы с ресурсом

Первый метод (WaitToRead) вызывается перед выполнением кода, что-либо считы вающего из разделяемого ресурса, а второй (WaitToWrite) — перед выполнением кода, который считывает и записывает данные в разделяемом ресурсе. К последнему мето ду (Done) программа обращается, закончив работу с этим ресурсом. Куда уж проще, а?

Объект CSWMRG содержит набор переменных-членов (см. таблицу ниже), отра жающих то, как потоки работают с разделяемым ресурсом на данный момент. Осталь ные подробности Вы узнаете из исходного кода.

Переменная

Описание

m_cs

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

т_nActive

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

m_nWaitingReaders

Сообщает количество потоков «читателей», которым нужен доступ к ресурсу. Значение этой переменной инициализируется 0 и увели чивается на 1 всякий раз, когда поток вызывает WaitToRead в то вре мя, как т nActive равна — 1.

т_nWaitingWriters

Сообщает количество потоков-«писателей», которым нужен доступ к ресурсу. Значение этой переменной инициализируется 0 и увели чивается на 1 всякий раз, когда поток вызывает WaitToWrite в то вре мя, как т nActive больше 0,

т_hsemWriters

Когда потоки - " писатели"> вызывают WaitToWrtie, но получают отказ в доступе, так как m_nActive больше 0, они переходят в состояние ожидания этого семафора. Пока ждет хотя бы один поток-«писа тель», новые потоки-«читатели» получают отказ в доступе к ресурсу. Тем самым я не даю потокам- «читателям" монополизировать доступ к этому ресурсу Когда последний поток-«читатсль», работавший с ресурсом, вызывает Done, семафор освобождается со счетчиком, равным 1, и система пробуждает один ждущий поток-описатель».

m_hsemReaders

Когда потоки-«читатели" вызывают' WaitToRead, но получают отказ в доступе, так как т nActive равна - 1, они переходят в состояние ожидания этого семафора. Когда последний из ждущих потоков «писателей» вызывает Done, семафор освобождается со счетчиком, равным т nWaitingReaders, и система пробуждает все ждущие пото ки -«читатели».

Программа-пример SWMRG

Эта программа, «10 SWMRG.exe» (см. листинг на рис. 10-3), предназначена для тести рования С++-класса CbWMRG. Файлы исходного кода и ресурсов этой программы

находятся в каталоге l0-SWMRG на компакт-диске, прилагаемом к книге, Я запускаю это приложение под управлением отладчика, чтобы наблюдать за всеми функциями и переменными — членами классов,

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

Каждый вторичный поток выводит на экран такое сообщение:

h10-1.jpg

Чтобы данный поток имитировал чтение ресурса, щелкните кнопку Yes, a чтобы оптимитировал запись в ресурс — кнопку No. Эти действия просто заставляют его вызвать либо функцию WaitToRead, либо функцию WaitToWrite объекта CSWMRG.

После вызова одной из этих функций поток выводит соответствующее сообщение.

h10-2.jpg

Пока окно с сообщением открыто, программа приостанавливает поток и делает вид, будто он сейчас работает с ресурсом

Конечно, если какой-то поток читает данные из ресурса и Вы командуете другому потоку записать данные в ресурс, окно с сообщением от последнего на экране не появится, так как поток-«писатель» ждет освобождения ресурса, вызвав WaitToWrite. Аналогичным образом, если Вы скомандуете потоку считать данные из ресурса в то время, как показывается окно с сообщением от потока-«писателя», первый поток бу дет ждать в вызове WaitToRead, и его окно не появится до тех пор, пока все потоки «писатели» не закончат имитировать свою работу с ресурсом

Закрыв окно с сообщением (щелчком кнопки OK), Вы заставите поток, получив ший доступ к ресурсу, вызвать Done, и объект CSWMRG переключится на другие жду щие потоки.

SWMRG

 

Реализация функции WaitForMultipleExpressions

Некогорое время назад я разрабатывал одно приложение и столкнулся с весьма не простым случаем синхронизации потоков Функции WaitForMultipleObjects., заставля ющей поток ждать освобождения одного или всех объектов, оказалось недостаточно Мне понадобилась функция, которая позволяла бы задавать более сложные критерии ожидания У меня было три объекта ядра процесс, семафор и событие Мой поток должен был ждать до тех пор, пока не освободтся либо процесс и семафор, либо процесс и событие

Слегка поразмыслив и творчески использовав имеющиеся функции Windows, я создал именно то, что мне требовалось, — функцию WaitWorMulttpleExpressions Ее прототип выглядит так

DWORD WINAPI WaitForMultipleExpressions( DWORD nExpObjectS, CONST HANDLE* phExpObiects, DWORD dwMilliseconds);

Перед ее вызовом Вы должны создать массив описателей (HANDLE) и инициали зировать всс cro элементы. Параметр nExpObjects сообщает число элементов в масси ве, на который указывает параметр phExpObjects. Этот массив содержит несколько наборов описателей объектов ядра; при этом каждый набор отделяется элементом, равным NULL. Функция WaitForMultipleExpressions считает все объекты в одном набо ре объединяемыми логической операцией AND, а сами наборы — объединяемыми логической операцией OR. Поэтому WaitForMultipleExpressions приостанавливает вызы вающий поток до тех пор, пока нс освободятся сразу все объекты в одном из наборов.

Вот пример. Допустим, мы работаем с четырьмя объектами ядра (см. таблицу ниже).

Обьект ядра

Значение описателя

Поток

0x1111

Семафор

0x2222

Событие

0x3333

Процесс

0x4444

Инициализировав массив описателей, как показано в следующей таблице, мы со общаем функции WaitForMultipleExpressions приостановить вызывающий поток до тех пор, пока не освободятся поток AND семафор OR семафор AND событие AND про цесс OR поток AND процесс,

Индекс

Значение описателя

Набор

0

0x1111 (поток)

0

1

0x2222 (семафор)

2

0x0000 (OR)

3

0x2222 (семафор)

1

4

0x3333 (событие)

5

0x4444 (процесс)

6

0x0000 (OR)

7

0x1 1 1 1 (поток)

2

8

0x4444 (процесс)

Вы, наверное, помните, что функции WaitForMultipleObjects нельзя передать массив описателей, число элементов в котором превышает 64 (MAXIMUM_WAIT_OBJECTS). Так вот, при использовании WaitForMultipleExpressions массив описателей может быть го раздо больше. Однако у Вас не должно быть более 64 выражений, а в каждом — более 63 описателей. Кроме того, WaitForMulttpleExpresstons будет работать некорректно, если Вы передадите ей хотя бы один описатель мыотекса. (Почему — объясню позже.)

Возвращаемые значения функции WaitForMultipleExpressions показаны в следующей таблице. Если заданное выражение становится истинным, WaitForMultipleExpressions

возвращает индекс этого выражения относительно WAIT_OBJECT_0. Если взять тот же пример, то при освобождении объектов «поток» и «процесс» WaitforMultipleExpressions вернет индекс в виде WAIT_OBJECT_0 + 2.

Возвращаемое значение

Описание

От WAIT_OBJECT_0 до (WAIT_OBJECT_0 + число выражений - 1)

Указывает, какое выражение стало истинным

WAIT_TIMEOUT

Ни одно выражение не стало истинным в течение заданного времени.

WAIT_FAILED

Произошла ошибка. Чтобы получить более подробную инфор мацию, вызовите GetLastError. Код ERROR_TOO_MANY_SECRETS означает, что Вы указали более 61 выражений, a ERROR_SEC RET_ТОО_LONG — что по крайней мере в одном выражении указано более 63 объектов. Могут возвращаться коды и других ошибок

Программа-пример WaitForMultExp

Эта программа, «10 WaitForMultExp.exe» (см. листинг на рис. 10-4), предназначена для тестирования функции WaitForMultipleExpressions Файлы исходного кода и ресурсов этой программы находятся в каталоге l0-WaitForMultExp на компакт-диске, прилага емом к кпиге. После запуска WaitForMultExp открывается диалоговое окно, показан ное ниже.

h10-3.jpg

Если Вы нс станете изменять предлагаемые параметры, а просто щелкнете кноп ку Wait For Multiple Expressions, диалоговое окно будет выглядеть так, как показано на следующей иллюстрации.

Программа создаст четыре объекта ядра «событие» в занятом состоянии и поме щает в многоколоночный список (с возможностью выбора сразу нескольких элемен тов) по одной записи для каждого объекта ядра. Далее программа анализирует содер жимое поля Expression и формирует массив описателей. По умолчанию я предлагаю объекты ядра и выражение, как в предыдущем примере.

Поскольку я задал время ожидания равным 30000 мс, у Вас есть 30 секунд на вне сение изменений. Выбор элемента в нижнем списке приводит к вызову SetEvent, ко

торая освобождает объект, а отказ от его выбора — к вызову ResetEvent и соответствен но к переводу объекта в занятое состояние. После выбора достаточного числа эле ментов (удовлетворяющего одному из выражений) WaitForMultipleExpressions возвра щает управление, и в нижней части диалогового окна показывается, какому выраже нию удовлетворяет Ваш выбор. Если Вы не уложитесь в 30 секунд, появится слово "Timeout"

h10-4.jpg

Теперь обсудим мою функцию WaitForMultipleExpressions. Реализовать ее было не просто, и ее применение, конечно, приводит к некоторым издержкам. Как Вы знаете, в Windows есть функция WaitForMultipleOtyects, которая позволяет потоку ждать по единственному AND-выражснию.

DWORD WaitForMultipleObjects( DWORD dwObjects, CONST HANDLE* pliObjects, BOOL fWaitAll, DWORD dwMimseconds);

Чтобы расширить ее функциональность для поддержки выражений, объединяемых OR, я должен создать несколько потоков — по одному на каждое такое выражение Каждый из этих потоков ждет в вызове WaitForMultipleObjectsEx по единственному AND-выражснию (Почему я использую эту функцию вместо более распространенной WaitForMultipleObjects - станет ясно позже) Когда какое-то выражение становится истинным, один из созданных потоков пробуждается и завершается

Поток, который вызвал WaitForMultipleExpressions (и который породил все OR-пo токи), должен ждать, пока одно из OR-выражений пе станет истинным. Для этого он вызывает функцию WaitForMultipleQbjeclsEx. В параметре dwObjects передается коли чество порожденных потоков (OR-выражений), а параметр phObjects указывает на массив описателей этих потоков. В паряметр fWaitAll записывается FALSE, чтобы ос новной поток пробудился сразу после того, как оанет истинным любое из выраже ний. И, наконец, в параметре dwMilliseconds передается значение, идентичное тому, которое было указано в аналогичном параметре при вызове WaitForMultipleExpressions

Если в течение заданного времени ни одно из выражений не становится истин ным, WaitForMultipleObjectsEx возвращает WAIT_TIMHOUT, и это же значение возвpa щается функцией WaitForMiltipleExpressions А если какое-нибудь выражение становит

ся истинным, WaitForMultipleObjectsEx возвращает индекс, указывающий, какой поток завершился Так как каждый поток представляет отдельное выражение, этот индекс сообщает и то, какое выражение стало истинным; этот же индекс возвращается и функцией WaitForMultipleExpressions.

На этом мы, пожалуй, закончим рассмотрение того, как работает функция WaiWor MultipleExpressions. Но нужно обсудить еще три вещи. Во-первых, нельзя допустить, чтобы несколько OR-потоков одновременно пробудились в своих вызовах WaitFor MultipleObjectsEx, так как успешное ожидание некоторых объектов ядра приводит к изменению их состояния (например, у семафора счетчик уменьшается на 1) WaitFor MultipleExpressions ждет лишь до тех пор, пока одно из выражений не станет истин ным, а значит, я должен предотвратить более чем однократное изменение состояния объекта

Решить эту проблему на самом деле довольно легко. Прежде чем порождать OR потоки, я создаю собственный объект-семафор с начальным значением счетчика, равным 1 Далее каждый OR-поток вызывает WaitForMultipleObjectsEx и передает ей не только описатели объектов, связанных с выражением, но и описатель этого семафо ра. Теперь Вы понимаете, почему в каждом наборе не может быть более 63 описате лей? Чтобы OR-поток пробудился, должны освободиться все объекты, которые он ждет, — в том числе мой специальный семафор. Поскольку начальное значение его счетчика равно 1, более одного OR-потока никогда не пробудится, и, следовательно, случайного изменения состояния каких-либо других объектов нс произойдет.

Второе, на что нужно обратить внимание, - как заставить ждущий поток прекра тить ожидание для корректной очистки. Добавление семафора гарантирует, что про будится не более чем один поток, но, раз мне уже известно, какое выражение стало истинным, я должен пробудить и остальные потоки, чтобы они корректно заверши лись. Вызова TerminateThread следует избегать, поэтому нужен какой-тодругой меха низм. Поразмыслив, я вспомнил, что потоки, ждущие в «тревожном" состоянии, при нудительно пробуждаются, когда в АРС-очереди появляется какой-нибудь элемент.

Моя реализация WaitForMultipleExpressions для принудительного пробуждения по токов использует QueueUserAPC. После того как WaitForMultipleObjects, вызванная ос новным потоком, возвращает управление, я ставлю АРС-вызов в соответствующие очереди каждого из все еще ждущих OR-потоков:

// выводим все еще ждущие потоки из состояния сна,
// чтобы они могли корректно завершиться

for (dwExpNum = 0; dwExpNum < dwNumExps; dwExpNum++)
{

if ((WAIT_TIMEOUT == dwWaitRet) || (dwExpNum != (dwWaitRet - WAIT_OBJECT_0)))
{
QueueUserAPC(WFME_ExpressionAPC, ahThreads[dwExpNum], 0);
}

}

Функция обратного вызова, WFMEExpressionAPC, выглядит столь странно пото му, что на самом деле от нее не требуется ничего, кроме одного: прервать ожидание потока.

// это АРС-функция обратного вызова

VOID WINAPI WFHE_ExpressionAPC(DWORD dwData}
{

// в тело функции преднамеренно не включено никаких операторов

}

Третье (и последнее) — правильная обработка интервалов ожидания. Если ника кие выражения так и не стали истинными в течение заданного времени, функция

WaitForMultipleObjects, вызванная основным потоком, возвращает WAIT_TIMEOUT В этом случае я должен позаботиться о том, чтобы ни одно выражение больше не ста ло бы истинным и тем самым не изменило бы состояние объектов. За это отвечает следующий код

// ждем, когда выражение станет TRUE или когда истечет срок ожидания
dwWaitRet = WaitForMultiplcObjects(dwExpNum, ahThreads, FALSE, dwMilliseconds);

if (WAIT_TIMEOUT == dwWaitRet)
{

// срок ожидания истек, выясняем, не стало ли какое-нибудь выражение
// истинным, проверяя состояние семафора hsemOnlyOne
dwWaitRet = WaitForSingleObject(hsemOnlyOne, 0);

if (WAIT_TIMEOUT == dwWaitRet}
{
// если семафор не был переведен в свободное состояние,
// какое-то выражение дало TRUE, надо выяснить - какое
dwWaitRet = WaitForMultipleObjects(dwExpNum, ahThreads, FALSE. INFINITE);
}
else
{
// ни одно выражение не стало TRUE,
// и WaitForSingleObject просто отдала нам семафор
dwWaitRet = WAIT_TIMbOUT;
}
}

Я не даю другим выражениям стать истинными за счет ожидания на семафоре. Это приводит к уменьшению счетчика семафора до 0, и никакой OR-поток не может про будиться. Но где-то после вызова функции WaitForMultipleObjects из основного пото ка и обращения той к WaitForSingleObject одно из выражений может стать истинным Вот почемуя проверяю значение, возвращаемое WaitForSingleQbject. Если она возвра щает WAIT_OBJECT_0, значит, семафор захвачен основным потоком и ни одно из выражений не стало истинным. Но если она возвращает WAIT_TIMEOUT, какое-то выражение все же стало истинным, прежде чем основной поюк успел захватить се мафор. Чтобы выяснить, какое именно выражение дало TRUE, основной поток снова вызывает WaitForMultipleObjects, но уже с временем ожидания, равным INFINITE; здесь все в порядке, так как я знаю, что семафор захвачен OR-потоком и этот поток вот-вот завершится Теперь я должен пробудить остальные OR-потоки, чтобы корректно за вершить их Это делается в цикле, из которого вызывается QueueUserAPC (о ней я уже рассказывал).

Поскольку реализация WintForMultipleExpressions основана на использовании груп пы потоков, каждый из которых ждет на своем наборе объектов, объединяемых по AND, мьютексы в ней неприменимы. В отличие от остальных объектов ядра мьютек сы могут передаваться потоку во владение. Значит, если какой-нибудь из моих AND потоков заполучит мьютекс, то по его завершении произойдет отказ от мьютекса. Вот когда Microsoft добавит в Windows API функцию, позволяющую одному потоку пере давать права на владение мьютексом другому потоку, тогда моя функция WaitFor MultipleExpressions и сможет поддерживать мьютексы. А пока надежного и корректного способа ввести в WattForMultipleExpressions такую поддержку я не вижу.

WaitForMultExp