Добавил:
Upload Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
Лекція № 5_МЗКІТ (10 ТП).docx
Скачиваний:
0
Добавлен:
01.07.2025
Размер:
4.37 Mб
Скачать

5.3.3. Умовні змінні та концепція монітора

Поняття умовної змінної

Умовною змінною називають синхронізаційний примітив, який дає змогу органі¬зувати очікування виконання умови всередині критичної секції, заданої м’ютексом. Умовна змінна завжди пов’язана із конкретним м’ютексом і даними, захищеними цим м’ютексом. Для умовної змінної визначено такі операції.

Очікування (wait). Додатковим вхідним параметром ця операція приймає м’ютекс, який повинен перебувати в закритому стані. Виклик wait відбувається в ситуації, коли не виконується деяка умова, потрібна потоку для продовження роботи. Внаслідок виконання wait потік (позначатимемо його Тw) припиняється (кажуть, що він «очікує на умовній змінній»), а м’ютекс відкривається (ці дві дії відбуваються атомарно). Так інші потоки отримують можливість увійти в критичну секцію і змінити там дані, які вона захищає, можливо, виконавши умову, потрібну потоку Tw. На цьому операція wait не завершується -

її завершить інший потік, викликавши операцію signal після того, як умову буде виконано.

Сигналізація (signal). Цю операцію потік (назвемо його Ts) мае виконати після того, як увійде у критичну секцію і завершить роботу з даними (виконавши умову, яку очікував потік, що викликав операцію wait). Ця операція перевіряє, чи немає потоків, які очікують на умовній змінній, і якщо такі потоки є, переводить один із них (Tw.) у етан готовності (лей потік буде поновлено, коли відповідний потік Ts вийде із критичної секції). Внаслідок поновлення потік Tw. завершує виконання операції wait — блокує м’ютекс знову (поновлення і блокування теж відбуваються атомарно). Якщо немає жодного потоку, який очікує на умовній змінній, операція signal не робить нічого, і інформацію про її виконання в системі не зберігають.

Широкомовна сигналізація (broadcast) відрізняється від звичайної тим, що переведення у стан готовності і, зрештою, поновлення виконують для всіх потоків, які очікують на цій умовній змінній, а не тільки для одного з них.

Отже, виконання операції wait складається з таких етапів: відкриття м’ютекса, очікування (поки інший потік не виконає операцію signal або broadcast), закриття м’ютекса.

По суті, це перша неатомарна операція, визначена для синхронізаційного примітива, але така відсутність атомарпості цілком контрольована (завжди відомо, де потік Tw перейшов у стан очікування і що його з цього стану виведе).

Особливості виконання операцій над умовною змінною

Операцію wait викликають, коли деяка умова, необхідна потоку для продовження, не виконується. Умова повинна бути пов’язана зі спільно використовуваними даними. Перед викликом wait потрібно перевірити цю умову в циклі while

Розглянемо, чому в цій перевірці використовують цикл while, а не умовний оператор іf. На перший погляд, використання whi1е зайве — нам досить перевірити умову один раз, для чого можна скористатися й іf.

Пояснимо, чому це не так. Після того, як потік Ts виконає операцію signal, потік Tw не запускається негайно, а переходить у стан готовності до виконання. Виконуватися він почне тоді, коли його вибере планувальник (звичайно це відбувається швидко). Проте за проміжок часу між викликом signal і початком виконання потоку Tw ще один потік (позначимо його Тх) може ввійти у критичну секцію (згадаймо, що м’ютекс у цей час поки що відкритий) і змінити в ній дані так, що умова знову перестане виконуватися. Тепер, якби виклик wait стояв за if, після виходу з wait потік Тw заблокував би м’ютекс і продовжив своє виконання, незважаючи на те, що умова все одно не виконується. Це помилка, яка практично налагодженню не підлягає (її називають помилковим поновленням — spurious wakeup). Коли ж використати while, то після виходу з wait умова знову буде перевірена, і якщо вона далі не виконується, потік Тw розблокує м’ютекс і знову перейде у стан очікування.

Використовуючи while у поєднанні з очікуванням на умовній змінній, ми гарантуємо, що потік продовжить свою роботу тільки під час виконання відповідної умови.

Наведемо приклад використання операції wait для організації очікування, поки звільниться місце у буфері (це фрагмент розв’язання задачі виробників-споживачів, яке розглянемо далі). Тут умовою є порожність буфера.

Операція signal повинна викликатися із критичної секції (тим паче, що параметром цієї операції теж є м’ютекс). Потік Ts має викликати signal, коли умова, якої очікував потік Tw, почне виконуватися.

Ось приклад використання операції signal для повідомлення про те, що буфер звільнився:

Розглядаючи цей фрагмент у поєднанні із попереднім, зауважимо: якщо якийсь потік вставить об’єкт у буфер між викликом signal і поновленням виконання потоку, це негайно зробить умову в циклі while знову істинною, внаслідок чого операція wait буде виконана знову.

Операція broaocast може виявитися корисною тоді, коли виконання умови має спричинити негайне розблокування всіх потоків, які очікують. Наприклад, якщо один потік записує у базу даних, а інші чекають, поки він закінчить це робити, щоб прочитати дані з неї (при цьому потоки-читачі один одному не заважають), є сенс після завершення записування виконати саме операцію broadcast, щоб усі читачі змогли негайно розпочати читання.

Відмінності умовних змінних від семафорів

Наведемо принципові відмінності умовних змінних від семафорів, які використовують для організації очікування за умовою.

♦ Умовні змінні можуть бути використані лише всередині критичних секцій, при цьому інші потоки можуть входити у критичну секцію під час очікування на умовній змінній. Семафори небезпечно використовувати всередині критичних секцій, оскільки це зазвичай призводить до взаємного блокування.

♦ Умовні змінні не зберігають стану, а семафори — зберігають. Ось що це означає.

  • Якщо потік Ts виконує signal для умовної змінної і. потоки, які очікують на цій умовній змінній, відсутні, нічого не відбувається і жодної інформації в системі не зберігають. Коли потім потік Tw виконає операцію wait на цій умовній змінній, то він призупиниться.

  • Якщо потік Ts виконує up для семафора і потоки, які очікую ті) на цьому семафорі, відсутні, значення семафора все одно збільшують і нове значення зберігають у системі. Коли потім потік Tw виконає операцію down для цього семафора, він замість призупинення зменшить семафор і продовжить своє виконання (незважаючи на те, що потік Ts раніше виконав свою операцію намарно).

Рекурсивні м’ютекси й умовні змінні

Рекурсивні м’ютекси не можуть бути використані у поєднанні з умовними змінними, оскільки рекурсивний м’ютекс може не звільнитися разом із початком очікування всередині wait, якщо він перед цим був зайнятий кілька разів (а це гарантує взаємне блокування).

Очікування виконання кількох умов

Можна організовувати очікування виконання кількох умов, хоча це рекомендують робити тільки у разі необхідності, щоб не ускладнювати код. Для такого очікування потрібно використати одну умовну змінну й у циклі whi1е перевіряти виконання кількох умов:

Кожну умову треба сигналізувати окремо:

Поняття монітора

Як ми бачимо, умовні змінні не використовують окремо від м’ютексів, причому є кілька правил взаємодії між цими примітивами. Ці правила є підґрунтям поняття монітора — синхронізаційної концепції вищого рівня.

Монітором називають набір функцій, які використовують один загальний м'ютекс і нуль або більше умовних змінних для керування паралельним доступом до спільно використовуваних даних відповідно до певних правил. Функції цього набору називають функціями монітора.

Ось правила, яких слід дотримуватися у разі реалізації монітора.

♦ Під час входу в кожну функцію монітора потрібно займати м’ютекс, під час виходу — звільняти. Отже, у кожний момент часу тільки один потік може перебувати всередині монітора (під яким розуміють сукупність усіх його функцій).

♦ Під час роботи з умовною змінною (і під час очікування, і під час сигналізації) необхідно завжди вказувати відповідний м’ютекс. Не можна працювати з умовними змінними, якщо м’ютекс незайнятий.

♦ Під час перевірки на виконання умови очікування потрібно використати цикл, а не умовний оператор.

Ідея монітора була вперше запропонована в 1974 році відомим ученим у галузі комп’ютерних наук Ч. А. Хоаром. Монітор часто розуміють як високорівневу конструкцію мови програмування (як приклад такої мови звичайно наводять Java), а саме як набір функцій або методів класу, всередині яких автоматично зберігається неявний загальний м’ютекс разом із операціями очікування і сигналізації. Насправді, як ми бачимо, концепція монітора може ґрунтуватися на базових примітивах — м’ютексах і умовних змінних — і не повинна бути обмежена якоюсь однією мовою.

Монітори Хоара відрізняються від тих, що були розглянуті тут (ці монітори ще називають MESA-моніторами за назвою мови, у якій вони вперше з’явилися). Головна відмінність полягає у реалізації сигналізації.

♦ У моніторах Хоара після сигналізації потік Ts негайно припиняють, і керування переходить до потоку Tw , який при цьому захоплює блокування. Коли потік Tw вийде із критичної секції або знову виконає операцію очікування, потік Ts буде поновлено.

♦ У MESA-моніторах, як було видно, після сигналізації потік Ts продовжує своє виконання, а потік Tw просто переходить у стан готовності до виконання. Він зможе продовжити своє виконання, коли потік Ts вийде з монітора (чекати цього доведеться недовго, тому що звичайно сигналізація відбувається наприкінці функції монітора).

Результатом є те, що для моніторів Хоара не обов’язково перевіряти умову очікування в циклі, досить умовного оператора (потік негайно отримує керування після виходу з очікування і не може статися так, що за цей час інший потік увійде в монітор і змінить умову). З іншого боку, ці монітори менш ефективні (потрібно витрачати час на те, щоб припиняти і поновлювати потік Ts); потрібно мати повну гарантію того, що між виконанням сигналізації та переданням керування потоку Тw планувальник не передасть керування іншому потокові Тх, який увійде у функцію монітора. Забезпечення такої гарантії потребує втручання в алгоритм роботи планувальника ОС.

Ці недоліки призводять до того, що на практиці використовують переважно MESA-монітори.

Ось приклад розв’язання задачі очікування завершення потоку із використанням монітора.

Реалізація задачі виробників-споживачів за допомогою монітора

Розглянемо синхронізаційні примітиви, які можуть знадобитися лід час реалізації цієї задачі.

♦ Для того щоб забезпечити перебування в моніторі тільки одного потоку в конкретний момент часу, використовуватимемо м’ютекс 1оск. Він буде спільним для всіх функцій семафора, з ним працюватимуть як виробник, так і споживач.

♦ Для організації очікування виробника у разі повного буфера потрібна умовна змінна, сигналізація якої означатиме, що місце у буфері звільнилося. Назвемо цю змінну not_full. Перед спробою додати новий об’єкт у буфер виробник перевіряє, чи буфер повний і, якщо це так, виконує очікування на цій змінній. Споживач, забравши об’єкт із буфера, сигналізує not_full, повідомляючи цим про наявність вільного місця виробникам, які очікують (і перевівши у стан готовності одного з них).

♦ Для організації очікування споживача під час порожнього буфера потрібна умовна змінна, сигналізація якої означатиме, що у буфері з’явився об’єкт. Назвемо цю змінну not_empty. Перед спробою забрати об’єкт із буфера споживач перевіряє, чи буфер порожній і, якщо це так, виконує очікування на цій змінній. Виробник, додавши об’єкт у буфер, сигналізує not_errpry, повідомляючи цим про наявність об’єктів споживачам, які очікують (і перевівши у стан готовності одного з них).

Завданнями функцій монітора буде забезпечення роботи із буфером. Для кращої організації коду запишемо ці функції окремо від коду виробника і споживача. Ось псевдокод розв’язання цієї задачі:

Цей код зрозуміліший, ніж код із використанням семафорів, насамперед тому, що не потрібно здогадуватися, що означає збільшення або зменшення того чи іншого семафора — відразу видно, який примітив відповідає за взаємне виключення, а який — за організацію очікування.

Деякі джерела взагалі не рекомендують користуватися семафорами для синхронізації, обмежуючи себе тільки моніторами (м’ютексами й умовними змінними). Треба, однак, зазначити, що:

♦ у деяких системах (наприклад, у Linux до появи NPTL) семафори — це єдиний засіб синхронізації потоків різних процесів;

♦ в інших системах (наприклад, у Win32 АРІ) майже не підтримуються умовні змінні (принаймні, реалізувати їх там складно);

♦ існує великий обсяг коду, написаного із використанням семафорів, який може виявитися необхідним для читання і підтримки.

Отже, вивчення семафорів є необхідним.

Загальна стратегія організації паралельного виконання

Для коректної організації виконання багатопотокових програм особливо важливі два із розглянутих раніше правил.

♦ М’ютекс захищає не код критичної секції, а спільно використовувані дані всередині цієї секції.

♦ Виклик wait для умовної змінної відбувається тоді, коли не виконується умова, пов’язана зі спільно використовуваними даними всередині критичної секції', виклик signal — коли умова, пов’язана з цими даними, починає виконуватися.

Як бачимо, м’тотексами і умовними змінними керують дані, що вони захищають. Так само вся концепція монітора побудована навколо спільно використовуваних даних. У разі розробки на C++ е сенс надати самим спільно використовуваним даним право відповідати за свою синхронізацію перетворенням їх в об’єкти класів та інкапсуляцією всіх синхронізаційних примітивів у методах цих класів. Рекомендують такий базовий підхід до розробки багатопотокових програм на C++.

1. Виділити одиниці паралельного виконання. Зробити кожну з них потоком. Потоки можуть бути інкапсульовані у класи з методом до(), який виконує функцію потоку.

2. Виділити спільно використовувані структури даних. Зробити кожну з таких структур класом. Виділити методи класів — дії, які потоки виконуватимуть із цими структурами даних.

3. Записати основний цикл виконання кожного потоку.

На цих трьох етапах ми поки що не займаємося синхронізацією — усе відбувається на більш високому рівні. Тепер для кожного класу потрібно виконати такі дії.

1. Визначити всі синхронізаційні дії, котрі необхідно виконувати з об’єктами цього класу. Визначити тип кожної дії: взаємне блокування або очікування умови.

2. Створити м’ютекси або умовні змінні для кожної дії.

3. Розробити методи класів, використовуючи для синхронізації ці м’ютекси і умовні змінні (звичайно ці: методи роблять функціями монітора).

Реалізація умовних змінних у POSIX

Умовні змінні POSIX цілком відповідають наведеному опису. Для роботи з ними використовують тип pthread_cord_t. Для ініціалізації умовної змінної найпростіше скористатися статичним ініціалі затором

Очікування умовної змінної реалізовано функцією pthread_cond_wait(). Ось приклад використання цієї функції у поєднанні з м’ютексом:

Для сигналізації умовної змінної використовують функцію pthread_cond_signal( ), для широкомовної сигналізації — pthread_cond_broadcast( ). Обидві ці функції приймають покажчик на pthread_cond_t і повертають int: