Программирование на Visual C++. Архив рассылки - Алекс Jenter
Шрифт:
Интервал:
Закладка:
Программирование на Visual C++
Выпуск №66 от 3 марта 2002 г.
Здравствуйте, дорогие подписчики!
Помнится, когда-то я уже публиковал статью, посвященную вопросу многозадачности и синхронизации потоков (нитей). Сегодня я предлагаю вам вернуться к этой теме, но уже на более подробном уровне. Вместе с Павлом Блудовым мы подробно рассмотрим один из объектов синхронизации – критические секции, и причем не просто их применение, но и их внутреннее устройство.
СТАТЬЯ
Критические секции
Автор: Paul Bludov
Демонстрационный проект CSTest (7.8kb)
Файл csdbg.h (1.8kb)
Файл csdbg2.h (2.5kb)
Классы-обертки для критических секций cswrap.h (0.5kb)
ВведениеКритические секции – это объекты, используемые для блокироки доступа к некоторорым важным данным всем нитям (threads) приложения, кроме одной, в один момент времени. Например, имеется переменная m_pObject и несколько нитей, вызывающих методы объекта, на который ссылается m_pObject. Причем эта переменная может изменять свое значение время от времени. Иногда там даже оказывается нуль. Предположим, имеется вот такой код:
// Нить #1
void Proc1() {
if (m_pObject) m_pObject->SomeMethod();
}
// Нить #2
void Proc2(IObject *pNewObject) {
if (m_pObject) delete m_pObject;
m_pObject = pNewobject;
}
Тут мы имеем потенциальную опасность вызова m_pObject->SomeMethod() после того, как объект был уничтожен при помощи delete m_pObject. Дело в том, что в системах с вытесняющей многозадачностью выполнение любой нити процесса может прерваться в самый неподходящий для нее момент времени и начнет выполняться совершенно другая нить. В данном примере неподходящим моментом будет тот, в котором нить #1 уже проверила m_pObject, но еще не успела вызвать SomeMethod(). Выполнение нити #1 прервалось, и начала исполняться нить #2. Причем нить #2 успела вызвать деструктор объекта. Что же произойдет, когда нить #1 получит немного процессорного времени и вызовет-таки SomeMethod() у уже несуществующего объекта? Наверняка что-то ужасное.
Именно тут приходят на помощь критические секции. Перепишем наш пример.
// Нить #1
void Proc1() {
::EnterCriticalSection(&m_lockObject);
if (m_pObject) m_pObject->SomeMethod();
::LeaveCriticalSection(&m_lockObject);
}
// Нить #2
void Proc2(IObject *pNewObject) {
::EnterCriticalSection(&m_lockObject);
if (m_pObject) delete m_pObject;
m_pObject = pNewobject;
::LeaveCriticalSection(&m_lockObject);
}
Код, помещенный между ::EnterCriticalSection() и ::LeaveCriticalSection() с одной и той же критической секцией в качестве параметра, никогда не будет выполняться параллельно. Это означает, что если нить #1 успела "захватить" критическую секцию m_lockObject, то при попытке нити #2 заполучить эту же критическую секцию в свое единоличное пользование, ее выполнение будет приостановлено до тех пор, пока нить #1 не "отпустит" m_lockObject при помощи вызова ::LeaveCriticalSection(). И наоборот, если нить #2 успела раньше нити #1, то та "подождет", прежде чем начнет работу с m_pObject.
Работа с критическими секциямиЧто же происходит внутри критических секций и как они устроены? Прежде всего, следует отметить, что критические секции это не объекты ядра операционной системы. Практически вся работа с критическими секциями происходит в создавшем их процессе. Их этого следует, что критические секции могут быть использованы только для синхронизации в пределах одного процесса. Теперь рассмотрим критические секции поближе.
Структура RTL_CRITICAL_SECTIONtypedef struct _RTL_CRITICAL_SECTION {
PRTL_CRITICAL_SECTION_DEBUG DebugInfo; // Используется операционной системой
LONG LockCount; // Счетчик использования этой критической секции
LONG RecursionCount; // Счетцик повторного захвата из нити-владельца
HANDLE OwningThread; // Уникальный ID нити-владельца
HANDLE LockSemaphore; // Объект ядра используемый для ожидания
ULONG_PTR SpinCount; // Количество холостых циклов перед вызовом ядра
} RTL_CRITICAL_SECTION, *PRTL_CRITICAL_SECTION;
Поле LockCount увеличивается на единицу при каждом вызове ::EnterCriticalSection() и уменьшается при каждом вызове ::LeaveCriticalSection(). Это первая (а часто и единственная проверка) на пути к "захвату" критической секции. Если после увеличения в этом поле находится ноль, это означает, что до этого момента непарных вызовов ::EnterCriticalSection() из других ниток не было. В этом случае можно забрать данные, охраняемые этой критической секцией в монопольное пользование. Таким образом, если критическая секция интенсивно используется не более чем одной нитью, ::EnterCriticalSection() практически вырождается в ++LockCount, а ::LeaveCriticalSection() в --LockCount. Это очень важно. Это означает, что использование многих тысяч критических секций в одном процессе не повлечет значительного расхода ни системных ресурсов, ни процессорного времени.
СОВЕТ
Не стоит экономить на критических секциях. Много наэкономить все равно не получится.
В поле RecursionCount хранится количество повторных вызовов ::EnterCriticalSection() из одной и той же нити. Действительно, если вызвать ::EnterCriticalSection() из одной и той же нити несколько раз, все вызовы будут успешны. Т.е. вот такой код не останосится навечно во втором вызове ::EnterCriticalSection(), а отработает до конца.
// Нить #1
void Proc1() {
::EnterCriticalSection(&m_lock);
// ...
Proc2()
// ...
::LeaveCriticalSection(&m_lock);
}
// Все еще нить #1
void Proc2() {
::EnterCriticalSection(&m_lock);
// ...
::LeaveCriticalSection(&m_lock);
}
Действительно, критические секции предназначены для защиты данных от доступа из нескольких ниток. Многократное использование одной и той же критической секции из одной нити не приведет к ошибке. Это вполне нормальное явление. Следите, чтобы количество вызовов ::EnterCriticalSection() и ::LeaveCriticalSection() совпадало, и все будет хорошо.
Поле OwningThread содержит 0 для никем не занятых критических секций или уникальный идентификатор нити-владельца. Это поле проверяется, если при вызове ::EnterCriticalSection() поле LockCount, после увеличения на единицу, оказалось больше нуля. Если OwningThread совпадает с уникальным идентификатором текущей нити, то RecursionCount просто увеличивается на единицу и ::EnterCriticalSection() возвращается немедленно. Иначе ::EnterCriticalSection() будет дожидаться, пока нить, владеющая критической секцией, не вызовет ::LeaveCriticalSection() необходимое количество раз.
Поле LockSemaphore используется, если нужно подождать, пока критическая секция освободится. Если LockCount больше нуля и OwningThread не совпадает с уникальным идентификатором текущей нити, то ждущая нить создает объект ядра (событие) и вызывает ::WaitForSingleObject(LockSemaphore). Нить-владелец, после уменьшения RecursionCount, проверяет его, и если значение этого поля равно нулю, а LockCount больше нуля, то это значит, что есть как минимум одна нить, ожидающая, пока LockSemaphore не окажется в состоянии "случилось!". Для этого нить-владелец вызывает ::SetEvent() и какая-то одна (только одна) из ожидающих ниток пробуждается и получает доступ к критическим данным.
WindowsNT/2k генерирует исключение, если попытка создать событие не увенчалась успехом. Это верно как для функций ::Enter/LeaveCriticalSection() так и для ::InitializeCriticalSectionAndSpinCount() с установленным старшим битом параметра SpinCount. Но только не WindowsXP. Разработчики ядра этой операционной системы поступили по-другому. Вместо генерации исключения, функции ::Enter/LeaveCriticalSection(), если не могут создать собственное событие, начинают использовать заранее созданный глобальный объект. Один на всех. Таким образом, в случае катастрофической нехватки системных ресурсов, программа под управлением WindowsXP ковыляет какое-то время дальше. Действительно, писать программы, способные продолжать работать после того, как ::EnterCriticalSection() сгенерировала исключение, черезвычайно сложно. Как правило, если программистом и предусмотрен такой поворот событий, то дальше вывода сообщения об ошибке и аварийного завершеня программы дело не идет. Как следствие, WindowsXP игнорирует старший бит поля LockCount.
И, наконец, поле SpinCount. Это поле используется только многопроцессорными системами. В однопроцессорных системах, если критическая секция занята другой нитью, можно только переключить управление на нее и подождать наступления нашего события. В многопроцессорных системах есть альтернатива: прогнать некоторое количество раз холостой цикл, проверяя каждый раз, не освободилась ли наша критическая секция. Если за SpinCount раз это не получилось, переходим к ожиданию. Это гораздо эффективнее, чем переключение на планировщик ядра и обратно. Кроме того, в WindowsNT/2k старший бит этого поля служит для индикации того, что объект ядра, хендл которого находится в поле LockSemaphore, должен быть создан заранее. Если системных ресурсов для этого недостаточно, система сгенерирует исключение, и программа может "урезать" свою функциональнось. Или совсем завершить работу.