• СТАТЬЯ  Критические секции
  • Программирование на 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_SECTION

    typedef 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, должен быть создан заранее. Если системных ресурсов для этого недостаточно, система сгенерирует исключение, и программа может "урезать" свою функциональнось. Или совсем завершить работу.

    API для работы с критическими секциями

    BOOL InitializeCriticalSection(LPCRITICAL_SECTION lpCriticalSection);

    BOOL InitializeCriticalSectionAndSpinCount(LPCRITICAL_SECTION lpCriticalSection, DWORD dwSpinCount);

    Заполняют поля структуры, адресуемой lpCriticalSection.

    После вызова любой из этих функций критическая секция готова к работе.

    Листинг 1. Псевдокод RtlInitializeCriticalSection из ntdll.dll

    VOID RtlInitializeCriticalSection(LPRTL_CRITICAL_SECTION pcs) {

     RtlInitializeCriticalSectionAndSpinCount(pcs, 0);

    }


    VOID RtlInitializeCriticalSectionAndSpinCount(LPRTL_CRITICAL_SECTION pcs, DWORD dwSpinCount) {

     pcs->DebugInfo = NULL;

     pcs->LockCount = -1;

     pcs->RecursionCount = 0;

     pcs->OwningThread = 0;

     pcs->LockSemaphore = NULL;

     pcs->SpinCount = dwSpinCount;

     if (0x80000000 & dwSpinCount) _CriticalSectionGetEvent(pcs);

    }


    DWORD SetCriticalSectionSpinCount(LPCRITICAL_SECTION lpCriticalSection, DWORD dwSpinCount);

    Устанавливает значение поля SpinCount и возвращает его предыдущее значение. Напоминаю, что старший бит отвечает за "привязку" события, используемого для ожидания доступа к данной критической секции.

    Листинг 2. Псевдокод RtlSetCriticalSectionSpinCount из ntdll.dll

    DWORD RtlSetCriticalSectionSpinCount(LPRTL_CRITICAL_SECTION pcs, DWORD dwSpinCount) {

     DWORD dwRet = pcs->SpinCount;

     pcs->SpinCount = dwSpinCount;

     return dwRet;

    }


    VOID DeleteCriticalSection(LPCRITICAL_SECTION lpCriticalSection);

    Освобождает ресурсы, занимаемые критической секцией.

    Листинг 3. Псевдокод RtlDeleteCriticalSection из ntdll.dll

    VOID RtlDeleteCriticalSection(LPRTL_CRITICAL_SECTION pcs) {

     pcs->DebugInfo = NULL;

     pcs->LockCount = -1;

     pcs->RecursionCount = 0;

     pcs->OwningThread = 0;

     if (pcs->LockSemaphore) {

      ::CloseHandle(pcs->LockSemaphore);

      pcs->LockSemaphore = NULL;

     }

    }


    VOID EnterCriticalSection(LPCRITICAL_SECTION lpCriticalSection);

    BOOL TryEnterCriticalSection(LPCRITICAL_SECTION lpCriticalSection);

    Осуществляют "захват" критической секции. Если критическая секция занята другой нитью, то ::EnterCriticalSection() будет ждать, пока та освободится, а ::TryEnterCriticalSection() вернет FALSE.

    Листинг 4. Псевдокод RtlEnterCriticalSection из ntdll.dll

    VOID RtlEnterCriticalSection(LPRTL_CRITICAL_SECTION pcs) {

     if (::InterlockedIncrement(&pcs->LockCount)) {

      if (pcs->OwningThread == (HANDLE)::GetCurrentThreadId()) {

       pcs->RecursionCount++;

       return;

      }

      RtlpWaitForCriticalSection(pcs);

     }

     pcs->OwningThread = (HANDLE)::GetCurrentThreadId();

     pcs->RecursionCount = 1;

    }


    BOOL RtlTryEnterCriticalSection(LPRTL_CRITICAL_SECTION pcs) {

     if (-1L == ::InterlockedCompareExchange(&pcs->LockCount, 0, -1)) {

      pcs->OwningThread = (HANDLE)::GetCurrentThreadId();

      pcs->RecursionCount = 1;

     } else if (pcs->OwningThread == (HANDLE)::GetCurrentThreadId()) {

      ::InterlockedIncrement(&pcs->LockCount);

      pcs->RecursionCount++;

     } else return FALSE;

     return TRUE;

    }


    VOID LeaveCriticalSection(LPCRITICAL_SECTION lpCriticalSection);

    Освобождает критическую секцию

    Листинг 5. Псевдокод RtlLeaveCriticalSection из ntdll.dll

    VOID RtlLeaveCriticalSectionDbg(LPRTL_CRITICAL_SECTION pcs) {

     if (--pcs->RecursionCount) ::InterlockedDecrement(&pcs->LockCount);

     else if (::InterlockedDecrement(&pcs->LockCount) >= 0) RtlpUnWaitCriticalSection(pcs);

    }

    Классы-обертки для критических секций

    Листинг 6. Код классов CLock, CAutoLock и CScopeLock

    class CLock {

     friend class CScopeLock;

     CRITICAL_SECTION m_CS;

    public:

     void Init() { ::InitializeCriticalSection(&m_CS); }

     void Term() { ::DeleteCriticalSection(&m_CS); }

     void Lock() { ::EnterCriticalSection(&m_CS); }

     BOOL TryLock() { return ::TryEnterCriticalSection(&m_CS); }

     void Unlock() { ::LeaveCriticalSection(&m_CS); }

    };


    class CAutoLock : public CLock {

    public:

     CAutoLock() { Init(); }

     ~CAutoLock() { Term(); }

    };


    class CScopeLock {

     LPCRITICAL_SECTION m_pCS;

    public:

     CScopeLock(LPCRITICAL_SECTION pCS) : m_pCS(pCS) { Lock(); }

     CScopeLock(CLock& lock) : m_pCS(&lock.m_CS) { Lock(); }

     ~CScopeLock() { Unlock(); }

     void Lock() { ::EnterCriticalSection(m_pCS); }

     void Unlock() { ::LeaveCriticalSection(m_pCS); }

    };

    Классы CLock и CAutoLock удобно использовать для синхронизации доступа к переменным класса, а CScopeLock предназначен, в основном, для использования в процедурах. Удобно, что компилятор сам позаботится о вызове ::LeaveCriticalSection() через наш деструктор.

    Листинг 7. Пример использования CScopeLock

    CAutoLock m_lockObject;

    CObject *m_pObject;


    void Proc1() {

     CScopeLock lock(m_lockObject); // Вызов lock.Lock();

     if (!m_pObject) return; // Вызов lock.Unlock();

     m_pObject->SomeMethod();

     // Вызов lock.Unlock();

    }

    Отладка критических секций

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

    Ошибки, связанные с реализацией

    Это довольно легко обнаруживаемые ошибки, как правило, связанные с непарностью вызовов ::EnterCriticalSection() и ::LeaveCriticalSection().

    Листинг 8. Пропущен вызов ::EnterCriticalSection()

    // Процедура предполагает, что m_lockObject.Lock(); уже был вызван

    void Pool() {

     for (int i = 0; i < m_vectSinks.size(); i++) {

      m_lockObject.Unlock();

      m_vectSinks[i]->DoSomething();

      m_lockObject.Lock();

     }

    }

    ::LeaveCriticalSection() без ::EnterCriticalSection() приведет к тому, что первый же вызов ::EnterCriticalSection() остановит выполнение нити навсегда.

    Листинг 9. Пропущен вызов ::LeaveCriticalSection()

    void Proc() {

     m_lockObject.Lock();

     if (!m_pObject) return;

     // ...   

     m_lockObject.Unlock();

    }

    В этом примере, конечно, имеет смысл воспользоваться классом типа CScopeLock.

    Кроме того, случается, что ::EnterCriticalSection() вызывается без инициализации критической секции с помощью ::InitializeCriticalSection(). Особенно часто такое случается с проектами, написанными с помощью ATL. Причем в debug-версии все работает замечательно, а release-версия рушится. Это происходит из-за так называемой "минимальной" CRT (_ATL_MIN_CRT), которая не вызывает конструкторы статических объектов (Q166480, Q165076). В ATL версии 7.0 эту проблему решили.

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

    CScopeLock l(m_lock);

    и как-то раз просто пропустил имя у переменной. Получилось

    CScopeLock (m_lock);

    а что это означает? Компилятор честно сделал вызов конструктора CScopeLock, и тут же уничтожил этот безымянный объект, как и положено по стандарту. Т.е. сразу же после вызова метода Lock() последовал вызов Unlock(), и синхронизация перестала иметь место. Вообще, давать переменным, даже локальным, имена из одной буквы – путь быстрого наступления на всяческие грабли.

    СОВЕТ

    Если у Вас в процедуре больше одного цикла, то вместо int i, j, k стоит все-таки использовать что-то вроде int nObject, nSection, nRow.

    Архитектурные ошибки

    Самая известная из них это блокировка (deadlock) когда две нити пытаются захватить две или более критических секций, причем делают это в разном порядке.

    Листинг 10. Взаимоблокировка двух ниток

    void Proc1() // Нить #1

    {

     ::EnterCriticalSection(&m_lock1);

     // ...

     ::EnterCriticalSection(&m_lock2);

     // ...

     ::LeaveCriticalSection(&m_lock2);

     // ...

     ::LeaveCriticalSection(&m_lock1);

    }


    // Нить #2

    void Proc2() {

     ::EnterCriticalSection(&m_lock2);

     // ...   

     ::EnterCriticalSection(&m_lock1);

     // ...   

     ::LeaveCriticalSection(&m_lock1);

     // ...   

     ::LeaveCriticalSection(&m_lock2);

    }

    Еще могут возникнуть проблемы при… копировании критических секций. Понятно, что вот такой код вряд ли сможет написать программист в здравом уме и памяти:

    CRITICAL_SECTION sec1;

    CRITICAL_SECTION sec2;

    // …

    sec1 = sec2;

    Из такого присвоения трудно извлечь какую-либо пользу. А вот такой код иногда пишут:

    struct SData {

     CLock _lock;

     DWORD m_dwSmth;

    } m_data;


    void Proc1(SData& data) {

     m_data = data;

    }

    и все бы хорошо, если бы у структуры SData был конструктор копирования, например такой:

    SData(const SData data) {

     CScopeLock lock(data.m_lock);

     m_dwSmth = data.m_dwSmth;

    }

    но нет, программист посчитал, что хватит за глаза простого копирования полей и, в результате, переменная m_lock была просто скопирована, хотя именно в этот момент из другой нити она была "захвачена" и значение поля LockCount у нее в этот момент больше либо равен нулю. После вызова ::LeaveCriticalSection() в той нити, у исходной переменной m_lock значение поля LockCount уменьшилось на единицу. А у скопированно переменной – осталось прежним. И любой вызов ::EnterCriticalSection() в этой нити никогда не вернется. Он будет вечно ждать неизвестно чего.

    Это только цветочки. С ягодками Вы очень быстро столкнетесь, если попытаетесь написать что-нибудь действительно сложное. Например, ActiveX-объект в многопоточном подразделении (MTA), создаваемый из скрипта, запущенного из-под контейнера, размещенного в однопоточном подразделении (STA). Ни слова не понятно? Не беда. Сейчас я попытаюсь выразить проблему более понятным языком. Итак. Имеется объект, вызывающий методы другого объекта, причем живут они в разных нитях. Вызовы производятся синхронно. Т.е. объект #1 переключает выполнение на нить объекта #2, вызывает метод и переключается обратно на свою нить. При этом выполнение нити #1 приостановлено до тех пор, пока не отработает нить объекта #2. Теперь положим, объект #2 вызывает метод объекта #1 из своей нити. Получается, что управление вернулось в объект #1, но из нити объекта #2. Если объект #1 вызывал метод объекта #2, захватив какую-либо критическую секцию, то при вызове метода объекта #1 тот заблокирует сам себя при повторном входе в ту же критическую секцию.

    Листинг 11. Самоблокировка средствами одного объекта

    // Нить #1

    void IObject1::Proc1() {

     // Входим в критическую секцию объекта #1

     m_lockObject.Lock();

     // Вызываем метод объекта #2, происходит переключение на нить объекта #2

     m_pObject2->SomeMethod();

     // Сюда мы попадем только по возвращении из

     m_pObject2->SomeMethod();

     m_lockObject.Unlock();

    }


    // Нить #2

    void IObject2::SomeMethod() {

     // Вызываем метод объекта #1 из нити объекта #2

     m_pObject1->Proc2();

    }


    // Нить #2

    void IObject1::Proc2() {

     // Пытаемся войти в критическую секцию объекта #1

     m_lockObject.Lock();

     // Сюда мы не попадем никогда

     m_lockObject.Unlock();

    }

    Если бы в примере не было переключения нитей, все вызовы произошли бы в нити объекта #1, и никаких проблем не возникло. Сильно надуманный пример? Ничуть. Именно переключение ниток лежит в основе подразделений COM (apartments). А из этого следует одно очень, очень неприятное правило.

    СОВЕТ

    Избегайте вызовов каких бы то ни было объектов при захваченных критических секциях.

    Помните пример из начала статьи? Так вот, он абсолютно неприемлем в подобных случаях. Его придется переделать на что-то вроде

    Листинг 12. Простой пример, не подверженный самоблокировке

    // Нить #1

    void Proc1() {

     m_lockObject.Lock();

     CComPtr<IObject> pObject(m_pObject); // вызов pObject->AddRef();

     m_lockObject.Unlock();

     if (pObject) pObject->SomeMethod();

    }


    // Нить #2

    void Proc2(IObject *pNewObject) {

     m_lockObject.Lock();

     m_pObject = pNewobject;

     m_lockObject.Unlock();

    }

    Доступ к объекту остался по-прежнему синхронизован, но вызов SomeMethod(); происходит вне критической секции. Победа? Почти. осталась одна маленькая деталь. Давайте посмотрим, что происходит в Proc2():

    void Proc2(IObject *pNewObject) {

     m_lockObject.Lock();

     if (m_pObject.p) m_pObject.p->Release();

     m_pObject.p = pNewobject;

     if (m_pObject.p) m_pObject.p->AddRef();

     m_lockObject.Unlock();

    }

    Очевидно, что вызовы m_pObject.p->AddRef(); и m_pObject.p->Release(); происходят внутри критической секции. И если вызов метода AddRef(), как правило, безвреден, то вызов метода Release() может оказаться последним вызовом Release(), и объект самоуничтожится. В методе FinalRelease() объекта #2 может быть все что угодно, например, освобождение объектов, живущих в других подразделениях. А это опять приведет к переключению ниток и может вызвать самоблокировку объекта #1 по уже известному сценарию. Придется воспользоваться той же техникой, что и в методе Proc1().

    // Нить #2 void Proc2(IObject *pNewObject) {

     CComPtr<IObject> pPrevObject;

     m_lockObject.Lock();

     pPrevObject.Attach(m_pObject.Detach());

     m_pObject = pNewobject;

     m_lockObject.Unlock(); // pPrevObject.Release();

    }

    Теперь потенциально последний вызов IObject2::Release() будет осуществлен после выхода из критической секции. А присвоение нового значения по-прежнему синхронизовано с вызовом IObject2::SomeMethod() из нити #1.

    Способы обнаружения ошибок

    Сначала стоит обратить внимание на "официальный" способ обнаружения блокировок. Если бы кроме ::EnterCriticalSection() и ::TryEnterCtiticalSection() существовал бы еще и ::EnterCriticalSectionWithTimeout(), то достаточно было бы просто указать какое-нибудь резонное значение для интервала ожидания, например, 30 секунд. Если критическая секция не освободилась в течение указанного времени, то с очень большой вероятностью она не освободится никогда. Имеет смысл подключить отладчик и посмотреть, что же творится в соседних нитьх. Но увы. Никаких ::EnterCriticalSectionWithTimeout() в Win32 не предусмотрено. Вместо этого есть поле CriticalSectionDefaultTimeout в структуре IMAGE_LOAD_CONFIG_DIRECTORY32, которое всегда равно нулю и, судя по всему, не используется. Зато используется ключ в реестре "HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\CriticalSectionTimeout", который по умолчанию равен 30 суткам, и по истечению этого времени в системный лог попадает строка "RTL: Enter Critical Section Timeout (2 minutes)\nRTL: Pid.Tid XXXX.YYYY, owner tid ZZZZ\nRTL: Re-Waiting\n". К тому же это верно только для систем WindowsNT/2k/XP и только с CheckedBuild. У вас установлен CheckedBuild? Нет? А зря. Вы теряете исключительную возможность увидеть эту замечательную строку.

    Ну, а какие у нас альтернативы? Да, пожалуй, только одна. Не использовать API для работы с критическими секциями. Вместо них написать свои собственные. Пусть даже не такие обточенные напильником, как в WindowsNT. Не страшно. Нам это понадобится только в debug-конфигурациях. В release'ах мы будем продолжать использовать оригинальный API от Майкрософт. Для этого напишем несколько функций полностью совместимых по типам и количеству аргументов с "настоящим" API и добавим #define как у MFC для переопределения оператора new в debug-конфигурациях.

    Листинг 14. Собственная реализация критических секций

    #if defined(_DEBUG) && !defined(_NO_DEADLOCK_TRACE)

    #define DEADLOCK_TIMEOUT 30000

    #define CS_DEBUG 1


    // Создаем на лету событие для операций ожидания,

    // но никогда его не освобождаем. Так удобней для отладки

    static inline HANDLE _CriticalSectionGetEvent(LPCRITICAL_SECTION pcs) {

     HANDLE ret = pcs->LockSemaphore;

     if (!ret) {

      HANDLE sem = ::CreateEvent(NULL, false, false, NULL);

      ATLASSERT(sem);

      if (!(ret = (HANDLE)::InterlockedCompareExchangePointer(

       &pcs->LockSemaphore, sem, NULL))) ret = sem;

      else ::CloseHandle(sem); // Кто-то успел раньше

     }

     return ret;

    }


    // Ждем, пока критическая секция не освободится либо время ожидания

    // будет превышено

    static inline VOID _WaitForCriticalSectionDbg(LPCRITICAL_SECTION pcs) {

     HANDLE sem = _CriticalSectionGetEvent(pcs);

     DWORD dwWait;

     do {

      dwWait = ::WaitForSingleObject(sem, DEADLOCK_TIMEOUT);

      if (WAIT_TIMEOUT == dwWait) {

       ATLTRACE("Critical section timeout (%u msec):"

        " tid 0x%04X owner tid 0x%04X\n", DEADLOCK_TIMEOUT,

        ::GetCurrentThreadId(), pcs->OwningThread);

      }

    } while(WAIT_TIMEOUT == dwWait);

     ATLASSERT(WAIT_OBJECT_0 == dwWait);

    }


    // Выставляем событие в активное состояние

    static inline VOID _UnWaitCriticalSectionDbg(LPCRITICAL_SECTION pcs) {

     HANDLE sem = _CriticalSectionGetEvent(pcs);

     BOOL b = ::SetEvent(sem);

     ATLASSERT(b);

    }


    // Заполучем критическую секцию в свое пользование

    inline VOID EnterCriticalSectionDbg(LPCRITICAL_SECTION pcs) {

     if (::InterlockedIncrement(&pcs->LockCount)) {

      // LockCount стал больше нуля.

      // Проверяем идентификатор нити

      if (pcs->OwningThread == (HANDLE)::GetCurrentThreadId()) {

       // Нить та же самая. Критическая секция наша.

       pcs->RecursionCount++;

       return;

      }

      // Критическая секция занята другой нитью.

      // Придется подождать

      _WaitForCriticalSectionDbg(pcs);

     }

     // Либо критическая секция была "свободна",

     // либо мы дождались. Сохраняем идентификатор текущей нити.

     pcs->OwningThread = (HANDLE)::GetCurrentThreadId();

     pcs->RecursionCount = 1;

    }


    // Заполучаем критическую секцию если она никем не занята

    inline BOOL TryEnterCriticalSectionDbg(LPCRITICAL_SECTION pcs) {

     if (-1L == ::InterlockedCompareExchange(&pcs->LockCount, 0, -1)) {

      // Это первое обращение к критической секции

      pcs->OwningThread = (HANDLE)::GetCurrentThreadId();

      pcs->RecursionCount = 1;

     } else if (pcs->OwningThread == (HANDLE)::GetCurrentThreadId()) {

      // Это не первое обращение, но из той же нити

      ::InterlockedIncrement(&pcs->LockCount);

      pcs->RecursionCount++;

     } else return FALSE; // Критическая секция занята другой нитью

     return TRUE;

    }


    // Освобождаем критическую секцию

    inline VOID LeaveCriticalSectionDbg(LPCRITICAL_SECTION pcs) {

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

     // с идентификатор нити-влядельца.

     // Если это не так, скорее всего мы имеем дело с ошибкой

     ATLASSERT(pcs->OwningThread == (HANDLE)::GetCurrentThreadId());

     if (--pcs->RecursionCount) {

      // Не последний вызов из этой нити.

      // Уменьшаем значение поля LockCount

      ::InterlockedDecrement(&pcs->LockCount);

     } else {

      // Последний вызов. Нужно "разбудить" какую-либо

      // из ожидающих ниток, если таковые имеются

      ATLASSERT(NULL != pcs->OwningThread);

      pcs->OwningThread = NULL;

      if (::InterlockedDecrement(&pcs->LockCount) >= 0) {

       // Имеется, как минимум, одна ожидающая нить

       _UnWaitCriticalSectionDbg(pcs);

      }

     }

    }


    // Удостоверяемся, что ::EnterCriticalSection() была вызвана

    // до вызова этого метода

    inline BOOL CheckCriticalSection(LPCRITICAL_SECTION pcs) {

     return pcs->LockCount >= 0

      && pcs->OwningThread == (HANDLE)::GetCurrentThreadId();

    }


    // Переопределяем все функции для работы с критическими секциями.

    // Определение класса CLock должно быть после этих строк

    #define EnterCriticalSection EnterCriticalSectionDbg

    #define TryEnterCriticalSection TryEnterCriticalSectionDbg

    #define LeaveCriticalSection LeaveCriticalSectionDbg

    #endif

    Ну и заодно добавим еще один метод в наш класс CLock

    Листинг 15. Класс CLock с новым методом

    class CLock {

     friend class CScopeLock;

     CRITICAL_SECTION m_CS;

    public:

     void Init() { ::InitializeCriticalSection(&m_CS); }

     void Term() { ::DeleteCriticalSection(&m_CS); }

     void Lock() { ::EnterCriticalSection(&m_CS); }

     BOOL TryLock() { return ::TryEnterCriticalSection(&m_CS); }

     void Unlock() { ::LeaveCriticalSection(&m_CS); }

     BOOL Check() { return CheckCriticalSection(&m_CS); }

    };

    Использовать метод Check() в release-конфигурациях не стоит, возможно, что в будущем, в какой-нибудь Windows64, структура RTL_CRITICAL_SECTION изменится и результат такой проверки не определен. Так что ему самое место "жить" внутри всяческих ASSERT'ов.

    Итак, что мы имеем? Мы имеем проверку на лишний вызов ::LeaveCriticalSection() и ту же трассировку для блокировок. Не так уж много. Особенно, если трассировка о блокировке имеет место, а вот нить, забывшая освободить критическую секцию, давно завершилась. Как быть? Вернее, что бы еще придумать, чтобы ошибку проще было выявить? Как минимум, прикрутить сюда __LINE__ и __FILE__, константы, соответствующие текущей строке и имени файла на момент компиляции этого метода.

    VOID EnterCriticalSectionDbg(LPCRITICAL_SECTION pcs, int nLine = __LINE__, azFile = __FILE__);

    Компилируем, запускаем… Результат удивительный. Хотя правильный. Компилятор честно подставил номер строки и имя файла, соответствующие началу нашей EnterCriticalSectionDbg(). Так что придется попотеть немного больше. __LINE__ и __FILE__ нужно вставить в #define'ы, тогда мы получим действительные номер строки и имя исходного файла. Теперь вопрос, куда же сохранить эти параметры для дальнейшего использования? Причем хочется оставить за собой возможность вызова стандартных функций API наряду с нашими собственными? На помощь приходит C++: просто создадим свою структуру, унаследовав ее от RTL_CRITICAL_SECTION. Итак:

    Листинг 16. Реализация критических секций с сохранением строки и имени файла

    #if defined(_DEBUG) && !defined(_NO_DEADLOCK_TRACE)


    #define DEADLOCK_TIMEOUT 30000

    #define CS_DEBUG 2


    // Наша структура взамен CRITICAL_SECTION

    struct CRITICAL_SECTION_DBG : public CRITICAL_SECTION {

     // Добавочные поля

     int m_nLine;

     LPCSTR m_azFile;

    };

    typedef struct CRITICAL_SECTION_DBG *LPCRITICAL_SECTION_DBG;


    // Создаем на лету событие для операций ожидания,

    // но никогда его не освобождаем. Так удобней для отладки

    static inline HANDLE _CriticalSectionGetEvent(LPCRITICAL_SECTION pcs) {

     HANDLE ret = pcs->LockSemaphore;

     if (!ret) {

      HANDLE sem = ::CreateEvent(NULL, false, false, NULL);

      ATLASSERT(sem);

      if (!(ret = (HANDLE)::InterlockedCompareExchangePointer(

       &pcs->LockSemaphore, sem, NULL))) ret = sem;

      else ::CloseHandle(sem); // Кто-то успел раньше

     }

     return ret;

    }


    // Ждем, пока критическая секция не освободится либо время ожидания

    // будет превышено

    static inline VOID _WaitForCriticalSectionDbg(LPCRITICAL_SECTION_DBG pcs, int nLine, LPCSTR azFile) {

     HANDLE sem = _CriticalSectionGetEvent(pcs);

     DWORD dwWait;

     do {

      dwWait = ::WaitForSingleObject(sem, DEADLOCK_TIMEOUT);

      if (WAIT_TIMEOUT == dwWait) {

       ATLTRACE("Critical section timeout (%u msec):"

        " tid 0x%04X owner tid 0x%04X\n"

        "Owner lock from %hs line %u, waiter %hs line %u\n",

        DEADLOCK_TIMEOUT, ::GetCurrentThreadId(), pcs->OwningThread,

        pcs->m_azFile, pcs->m_nLine, azFile, nLine);

      }

     } while(WAIT_TIMEOUT == dwWait);

     ATLASSERT(WAIT_OBJECT_0 == dwWait);

    }


    // Выставляем событие в активное состояние

    static inline VOID _UnWaitCriticalSectionDbg(LPCRITICAL_SECTION pcs) {

     HANDLE sem = _CriticalSectionGetEvent(pcs);

     BOOL b = ::SetEvent(sem);

     ATLASSERT(b);

    }


    // Инициализируем критическую секцию.

    inline VOID InitializeCriticalSectionDbg(LPCRITICAL_SECTION_DBG pcs) {

     // Пусть система заполнит свои поля

     InitializeCriticalSection(pcs);

     // Заполняем наши поля

     pcs->m_nLine = 0;

     pcs->m_azFile = NULL;

    }


    // Освобождаем ресурсы, занимаемые критической секцией

    inline VOID DeleteCriticalSectionDbg(LPCRITICAL_SECTION_DBG pcs) {

     // Проверяем, чтобы не было удалений "захваченных" критических секций

     ATLASSERT(0 == pcs->m_nLine && NULL == pcs->m_azFile);

     // Остальное доделает система

     DeleteCriticalSection(pcs);

    }


    // Заполучем критическую секцию в свое пользование

    inline VOID EnterCriticalSectionDbg(LPCRITICAL_SECTION_DBG pcs, int nLine, LPSTR azFile) {

     if (::InterlockedIncrement(&pcs->LockCount)) {

      // LockCount стал больше нуля.

      // Проверяем идентификатор нити

      if (pcs->OwningThread == (HANDLE)::GetCurrentThreadId()) {

       // Нить та же самая. Критическая секция наша.

       // Никаких дополнительных действий не производим.

       // Это не совсем верно, так как возможно, что непарный

       // вызов ::LeaveCriticalSection() был на n-ном заходе,

       // и это прийдется отлавливать вручную, но реализация

       // стека для __LINE__ и __FILE__ сделает нашу систему

       // более громоздкой. Если это действительно необходимо,

       // Вы всегда можете сделать это самостоятельно

       pcs->RecursionCount++;

       return;

      }

      // Критическая секция занята другой нитью.

      // Придется подождать

      _WaitForCriticalSectionDbg(pcs, nLine, azFile);

     }

     // Либо критическая секция была "свободна",

     // либо мы дождались. Сохраняем идентификатор текущей нити.

     pcs->OwningThread = (HANDLE)::GetCurrentThreadId();

     pcs->RecursionCount = 1;

     pcs->m_nLine = nLine;

     pcs->m_azFile = azFile;

    }


    // Заполучаем критическую секцию если она никем не занята

    inline BOOL TryEnterCriticalSectionDbg(LPCRITICAL_SECTION_DBG pcs, int nLine, LPSTR azFile) {

     if (-1L == ::InterlockedCompareExchange(&pcs->LockCount, 0, -1)) {

      // Это первое обращение к критической секции

      pcs->OwningThread = (HANDLE)::GetCurrentThreadId();

      pcs->RecursionCount = 1;

      pcs->m_nLine = nLine;

      pcs->m_azFile = azFile;

     } else if (pcs->OwningThread == (HANDLE)::GetCurrentThreadId()) {

      // Это не первое обращение, но из той же нити

      ::InterlockedIncrement(&pcs->LockCount);

      pcs->RecursionCount++;

     } else return FALSE; // Критическая секция занята другой нитью

     return TRUE;

    }


    // Освобождаем критическую секцию

    inline VOID LeaveCriticalSectionDbg(LPCRITICAL_SECTION_DBG pcs) {

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

     // с идентификатором нити-влядельца.

     // Если это не так, скорее всего мы имеем дело с ошибкой

     ATLASSERT(pcs->OwningThread == (HANDLE)::GetCurrentThreadId());

     if (--pcs->RecursionCount) {

      // Не последний вызов из этой нити.

      // Уменьшаем значение поля LockCount

      ::InterlockedDecrement(&pcs->LockCount);

     } else {

      // Последний вызов. Нужно "разбудить" какую-либо

      // из ожидающих ниток, если таковые имеются

      ATLASSERT(NULL != pcs->OwningThread);

      pcs->OwningThread = NULL;

      pcs->m_nLine = 0;

      pcs->m_azFile = NULL;

      if (::InterlockedDecrement(&pcs->LockCount) >= 0) {

       // Имеется, как минимум, одна ожидающая нить

       _UnWaitCriticalSectionDbg(pcs);

      }

     }

    }


    // Удостоверяемся, что ::EnterCriticalSection() была вызвана

    // до вызова этого метода

    inline BOOL CheckCriticalSection(LPCRITICAL_SECTION pcs) {

     return pcs->LockCount >= 0

      && pcs->OwningThread == (HANDLE)::GetCurrentThreadId();

    }


    // Переопределяем все функции для работы с критическими секциями.

    // Определение класса CLock должно быть после этих строк

    #define InitializeCriticalSection InitializeCriticalSectionDbg

    #define InitializeCriticalSectionAndSpinCount(pcs, c) \

     InitializeCriticalSectionDbg(pcs)

    #define DeleteCriticalSection DeleteCriticalSectionDbg

    #define EnterCriticalSection(pcs) EnterCriticalSectionDbg(pcs, __LINE__, __FILE__)

    #define TryEnterCriticalSection(pcs) TryEnterCriticalSectionDbg(pcs, __LINE__, __FILE__)

    #define LeaveCriticalSection LeaveCriticalSectionDbg

    #define CRITICAL_SECTION CRITICAL_SECTION_DBG

    #define LPCRITICAL_SECTION LPCRITICAL_SECTION_DBG

    #define PCRITICAL_SECTION PCRITICAL_SECTION_DBG

    #endif

    Приводим наши классы в соответствие

    Листинг 17. Классы CLock и CScopeLock, вариант для отладки

    class CLock {

     friend class CScopeLock;

     CRITICAL_SECTION m_CS;

    public:

     void Init() { ::InitializeCriticalSection(&m_CS); }

     void Term() { ::DeleteCriticalSection(&m_CS); }


    #if defined(CS_DEBUG)

     BOOL Check() { return CheckCriticalSection(&m_CS); }

    #endif

    #if CS_DEBUG > 1

     void Lock(int nLine, LPSTR azFile) {

      EnterCriticalSectionDbg(&m_CS, nLine, azFile);

     }

     BOOL TryLock(int nLine, LPSTR azFile) {

      return TryEnterCriticalSectionDbg(&m_CS, nLine, azFile);

     }

    #else

     void Lock() {

      ::EnterCriticalSection(&m_CS);

     }

     BOOL TryLock() {

      return ::TryEnterCriticalSection(&m_CS);

     }

    #endif

     void Unlock() {

      ::LeaveCriticalSection(&m_CS);

     }

    };


    class CScopeLock {

     LPCRITICAL_SECTION m_pCS;

    public:

    #if CS_DEBUG > 1

     CScopeLock(LPCRITICAL_SECTION pCS, int nLine, LPSTR azFile) : m_pCS(pCS) {

      Lock(nLine, azFile);

     }

     CScopeLock(CLock& lock, int nLine, LPSTR azFile) : m_pCS(&lock.m_CS) {

      Lock(nLine, azFile);

     }

     void Lock(int nLine, LPSTR azFile) {

      EnterCriticalSectionDbg(m_pCS, nLine, azFile);

     }

    #else

     CScopeLock(LPCRITICAL_SECTION pCS) : m_pCS(pCS) { Lock(); }

     CScopeLock(CLock& lock) : m_pCS(&lock.m_CS) { Lock(); }

     void Lock() { ::EnterCriticalSection(m_pCS); }

    #endif

     ~CScopeLock() { Unlock(); }

     void Unlock() { ::LeaveCriticalSection(m_pCS); }

    };


    #if CS_DEBUG > 1

    #define Lock() Lock(__LINE__, __FILE__)

    #define TryLock() TryLock(__LINE__, __FILE__)

    #define lock(cs) lock(cs, __LINE__, __FILE__)

    #endif

    К сожалению, пришлось даже переопределить CScopeLock lock(cs), причем мы жестко привязались к имени переменной. Не говоря уж о том, что у нас наверняка получился конфликт имен, все-таки Lock довольно популярное название для метода. Такой код не будет собираться, например, с популярнейшей библиотекой ATL. Тут есть два способа. Переименовать наши методы Lock() и TryLock() во что-нибудь более уникальное либо переименовать Lock() в ATL:

    // StdAfx.h

    // …

    #define Lock ATLLock

    #include <AtlBase.h>

    // …

    Сменим тему

    А что это мы все про Win32 API да про C++? Давайте посмотрим, как обстоят дела с критическими секциями в более современных языках программирования.

    C#

    Тут мы стараниями Майкрософт имеем полный набор старого доброго API под новыми именами.

    Критические секции представлены классом System.Threading.Monitor, вместо ::EnterCriticalSection() есть Monitor.Enter(object), а вместо ::LeaveCriticalSection() Monitor.Exit(object), где object – это любой объект C#. Т.е. каждый объект где-то в потрохах CLR (Common Language Runtime) имеет свою собственную критическую секцию. Либо заводит ее по необходимости. Типичное использование этой секции выглядит так:

    Monitor.Enter(this);

    m_dwSmth = dwSmth;

    Monitor.Exit(this);

    Если нужно организовать отдельную критическую секцию для какой-либо переменной самым логичным способом будет поместить ее в отдельный объект и использовать этот объект как аргумент при вызове Monitor.Enter/Exit(). Кроме того, в C# существует ключевое слово lock, это полный аналог нашего класса CScopeLock.

    lock(this) {

     m_dwSmth = dwSmth;

    }

    А вот Monitor.TryEnter() в C# (о, чюдо!) принимает в качестве параметра максимальный период ожидания.

    Замечу, что CLR это не только C#, все это применимо и к другим языкам, использующим CLR.

    Java

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

    synchronized(this) {

      m_dwSmth = dwSmth;

    }

    MC++ (управляемый C++)

    Тут тоже появился атрибут [synchronized] ведущий себя точно также, как и одноименное ключевое слово из Java. Странно, что архитекторы из Майкрософт решили позаимствовать синтаксис из продукта от Sun Microsystems вместо своего собственного.

    [synchronized] DWORD m_dwSmth;

    //...

    m_dwSmth = dwSmth; // неявный вызов Lock(this)

    Delphi

    Практически все, что верно для C++, верно и для Delphi. Критические секции представлены объектом TCriticalSection. Собственно, это такая же обертка как и наш класс CLock.

    Кроме того, в Delphi присутствует специальный объект TMultiReadExclusiveWriteSynchronizer с названием, говорящим само за себя.

    Подведем итоги

    Итак, что нужно знать о критических секциях:

    • Критические секции работают быстро и не требуют большого количества системных ресурсов.

    • Для синхронизации доступа к нескольким (независимым) переменным лучше использовать несколько критических секций, а не одну для всех.

    • Код, ограниченный критическими секциями, лучше всего свести к минимуму.

    • Находясь в критической секции, не стоит вызовать методы "чужих" объектов.


    Это все на сегодня. Пока! 

    (Алекс Jenter jenter@rsdn.ru) (Duisburg, 2001. Публикуемые в рассылке материалы принадлежат сайту RSDN.)







    Главная | В избранное | Наш E-MAIL | Прислать материал | Нашёл ошибку | Наверх