Критичні секції

Автор: Павло Блудов The RSDN Group Джерело: RSDN Magazine #6-2004

секції

Критичні секції - це об'єкти, що використовуються для блокування доступу всіх ниток (threads) програми, крім однієї, до деяких важливих даних одночасно. Наприклад, є змінна m_pObject і кілька ниток, які викликають методи об'єкта, який посилається m_pObject, причому ця змінна може змінювати своє значення іноді. Іноді там навіть виявляється нуль. Припустимо, є такий код:

Тут ми маємо потенційну небезпеку викликуm_pObject->SomeMethod()після того, як об'єкт був знищений за допомогоюdelete m_pObject. Справа в тому, що в системах з багатозадачністю, що витісняє, виконання будь-якої нитки процесу може перерватися в самий невідповідний для неї момент часу, і почне виконуватися зовсім інша нитка. У цьому прикладі невідповідним моментом буде той, у якому нитка №1 вже перевірилаm_pObject, але ще не встигла викликатиSomeMethod(). Виконання нитки №1 перервалося, і почала виконуватися нитка №2. Причому нитка №2 встигла викликати деструктор об'єкта. Що ж станеться, коли нитка №1 отримає трохи процесорного часу і викличе-такиSomeMethod()у вже неіснуючого об'єкта? Напевно, щось жахливе.

Саме тут приходять на допомогу критичні секції. Перепишемо наш приклад.

Код, поміщений між ::EnterCriticalSection() та ::LeaveCriticalSection() з однією і тією ж критичною секцією як параметр, ніколи не буде виконуватися паралельно. Це означає, що якщо нитка №1 встигла "захопити" критичну секцію m_lockObject, то при спробі нитки №2 отримати цю ж критичну секцію у своє одноосібне користування, її виконання буде призупинено доти, доки нитка №1 не"відпустить" m_lockObject за допомогою виклику:: LeaveCriticalSection(). І навпаки, якщо нитка №2 встигла раніше нитки №1, то та "зачекає", перш ніж розпочне роботу зm_pObject.

Робота з критичними секціями

Що ж відбувається всередині критичних секцій та як вони влаштовані? Насамперед, слід зазначити, що критичні секції – це об'єкти ядра операційної системи. Практично вся робота з критичними секціями відбувається в процесі, що створив їх. З цього випливає, що критичні секції можуть бути використані лише для синхронізації в межах одного процесу. Тепер розглянемо критичні секції ближче.

Структура RTL_CRITICAL_SECTION

ПолеLockCountзбільшується на одиницю за кожним викликом ::EnterCriticalSection() і зменшується за кожного виклику ::LeaveCriticalSection(). Це перша (а часто й єдина перевірка) на шляху до захоплення критичної секції. Якщо після збільшення в цьому полі знаходиться нуль, це означає, що до цього моменту непарних дзвінків ::EnterCriticalSection() з інших ниток не було. В цьому випадку можна забрати дані, які охороняються цією критичною секцією в монопольне користування. Таким чином, якщо критична секція інтенсивно використовується не більше ніж однією ниткою, ::EnterCriticalSection() практично вироджується++LockCount, а ::LeaveCriticalSection()--LockCount. Це дуже важливо. Це означає, що використання багатьох тисяч критичних секцій в одному процесі не спричинить значних витрат ні системних ресурсів, ні процесорного часу.

Не варто заощаджувати на критичних секціях. Багато заощадити все одно не вийде.

У поліRecursionCountзберігається кількість повторних викликів ::EnterCriticalSection() з однієї і тієї ж нитки. Справді, якщо викликати::EnterCriticalSection() з однієї і тієї ж нитки кілька разів, всі виклики будуть успішними. Тобто. ось такий код не зупиниться назавжди у другому викликі ::EnterCriticalSection(), а відпрацює до кінця.

Дійсно, критичні секції призначені захисту даних від доступу з кількох ниток. Багаторазове використання однієї й тієї критичної секції з однієї нитки не призведе до помилки. Це цілком нормальне явище. Слідкуйте, щоб кількість викликів ::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. Але тільки не в Windows XP. Розробники ядра цієї операційної системи надійшли по-іншому. Замість генерації виключення функції ::Enter/LeaveCriticalSection(), якщо не можуть створити власну подію, починають використовувати заздалегідь створений глобальний об'єкт. Один на всіх. Таким чином, у разі катастрофічної нестачі системних ресурсів, програма під керуванням Windows XP шкандибає якийсь час далі. Дійсно, писати програми, здатні продовжувати працювати після того, як ::EnterCriticalSection() згенерувала виняток, надзвичайно складно. Як правило, якщо програмістом і передбачено такий поворот подій, то далі виведення повідомлення про помилку та аварійне завершення програми справа не йде. Як наслідок, Windows XP ігнорує старший біт поля LockCount.

І, нарешті, полеSpinCount. Це поле використовується лише багатопроцесорними системами. В однопроцесорних системах, якщо критична секція зайнята іншою ниткою, можна лише переключити управління на неї та зачекати настання події. У багатопроцесорних системах є альтернатива: прогнати кілька разів холостий цикл, перевіряючи щоразу, чи не звільнилася наша критична секція. Якщо за SpinCount раз це не вийшло, переходимо до очікування. Це набагато ефективніше, ніж перемикання на планувальник ядра і назад. Крім того, у WindowsNT/2k старший біт цього поля служить для індикації того, що об'єкт ядра, хендл якого знаходиться в полі LockSemaphore, має бутистворено заздалегідь. Якщо системних ресурсів цього недостатньо, система згенерує виняток, і програма може " урізати " свою функціональність. Або зовсім завершити роботу.

Все це правильно для Windows NT/2k/XP. У Windows 9x/Me використовується лише поле LockCount. Там знаходиться вказівник на об'єкт ядра, можливо просто взаємовиключення (mutex). Усі інші поля дорівнюють нулю.

API для роботи з критичними секціями

BOOLInitializeCriticalSection(LPCRITICAL_SECTIONlpCriticalSection);

BOOLInitializeCriticalSectionAndSpinCount(LPCRITICAL_SECTIONlpCriticalSection, DWORDdwSpinCount);

DWORDSetCriticalSectionSpinCount(LPCRITICAL_SECTIONlpCriticalSection, DWORDdwSpinCount);

Встановлює значення поля SpinCount та повертає його попереднє значення. Нагадую, що старший біт відповідає за "прив'язку" події, що використовується для очікування доступу до цієї критичної секції.

VOIDDeleteCriticalSection(LPCRITICAL_SECTIONlpCriticalSection);

Звільняє ресурси, які займає критична секція.

VOIDEnterCriticalSection(LPCRITICAL_SECTIONlpCriticalSection);

BOOLTryEnterCriticalSection(LPCRITICAL_SECTIONlpCriticalSection);

Здійснюють "захоплення" критичної секції. Якщо критична секція зайнята іншою ниткою, ::EnterCriticalSection() буде чекати, поки та звільниться, а ::TryEnterCriticalSection() поверне FALSE. Відсутня у Windows 9x/ME.

VOIDLeaveCriticalSection(LPCRITICAL_SECTIONlpCriticalSection);