Багатозадачність в Windows, C / C + +, Програмування, статті

Alex Jenter, СофтТерра

Інтро

Дуже багато програмістів, перейшовши з DOS на Windows, протягом довгого часу все ще намагаються програмувати по-старому. Звичайно, повністю це зробити не виходить – такі речі, як обробка повідомлень, є невід’ємною частиною будь-якого Windows-програми. Однак, 32-розрядна платформа в силу своєї структури надає програмістам нові захоплюючі дух можливості. І якщо ви їх не використовуєте, а намагаєтеся вирішити проблему так, як звикли, то цілком природно, що з цього не виходить нічого хорошого.

Ці можливості – можливості багатозадачності. Насамперед дуже важливо з’ясувати для себе, КОЛИ вам слід подумати про її використання в своєму додатку. Відповідь так само очевидна, як і визначення терміну “Багатозадачність” – вона потрібна тоді, коли ви хочете, щоб декілька ділянок коду виконувалося ОДНОЧАСНО. Наприклад, ви хочете, щоб якісь дії виконувалися у фоновому режимі, або щоб протягом ресурсномістких обчислень, вироблених вашою програмою, вона продовжувала реагувати на дії користувача. Я думаю, ви легко зможете придумати ще кілька прикладів.

Процеси і потоки

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

Так як практично завжди потоків набагато більше, ніж фізичних процесорів для їх виконання, то потоки насправді виконуються не одночасно, а по черзі. (Зауважте, що розподіл процесорного часу відбувається саме між потоками, оскільки, ще раз повторю, процеси не виконуються.) Але перемикання між ними відбувається так часто, що здається ніби вони виконуються паралельно.

В залежності від ситуації потоки можуть перебувати в трьох станах. Давайте подивимося, що це за стан. По-перше, потік може виконуватися, коли йому виділено процесорний час, тобто він може перебувати в стані активності. По-друге, він може бути неактивним і чекати виділення процесора, тобто бути в змозі готовності. І є ще третє, теж дуже важливе стан – стан блокування. Коли потік заблокований, йому взагалі не виділяється час. Зазвичай блокування ставиться на час очікування якої-небудь події. При виникненні цієї події потік автоматично переводиться зі стану блокування в стан готовності. Наприклад, якщо один потік виконує обчислення, а інший повинен чекати результатів, щоб зберегти їх на диск. Другий міг би використовувати цикл типу “while (! IsCalcFinished) continue;”, але легко переконатися на практиці, що під час виконання цього циклу процесор зайнятий на 100% (це називається активним очікуванням). Таких ось циклів слід по можливості уникати, в чому нам надає неоціненну допомогу механізм блокування. Другий потік може заблокувати себе до тих пір, поки перший не встановить подію, що сигналізує про те, що читання закінчено.

В системі виділяються два види потоків – інтерактивні, Що крутять свій цикл обробки повідомлень (такі, як головний потік програми), і робочі, Що представляють собою просту функцію. У другому випадку потік завершується у міру завершення виконання цієї функції.

Вартим уваги моментом є також спосіб організації черговості потоків. Можна було б, звичайно, обробляти всі потоки по черзі, але такий спосіб далеко не найефективніший. Набагато розумніше виявилося ранжувати всі потоки по пріоритетам. Пріоритет потоку позначається числом від 0 до 31, і визначається виходячи з пріоритету процесу, що народив потік, і відносного пріоритету самого потоку. Таким чином, досягається найбільша гнучкість, і кожен потік в ідеалі отримує стільки часу, скільки йому необхідно.

Іноді пріоритет потоку може змінюватися динамічно. Так інтерактивні потоки, які мають зазвичай клас пріоритету Normal, система обробляє трохи інакше і дещо підвищує фактичний пріоритет таких потоків, коли процес, їх породив, перебуває на передньому плані (foreground). Це зроблено для того, щоб додаток, з яким в даний момент працює користувач, швидше реагувало на його дії.

Необхідність синхронізації

Отже, в Windows виконуються не процеси, а потоки. При створенні процесу автоматично створюється його основний потік. Цей потік в процесі виконання може створювати нові потоки, які, в свою чергу, теж можуть створювати потоки і т.д. Процесорний час розподіляється саме між потоками, і виходить, що кожен потік працює незалежно.

Всі потоки, що належать одному процесу, розділяють деякі загальні ресурси – такі, як адресний простір оперативної пам’яті або відкриті файли. Ці ресурси належать всьому процесу, а значить, і кожному його потоку. Отже, кожен потік може працювати з цими ресурсами без будь-яких обмежень. Але чи так це насправді? Згадаймо, що в Windows реалізована витісняє багатозадачність – Це означає, що в будь-який момент система може перервати виконання одного потоку і передати управління іншому. (Раніше використовувався спосіб організації, званий кооперативної багатозадачністю. Система чекала, поки потік сам не зволить передати їй управління. Саме тому в разі глухого зависання одного застосування доводилося перезавантажувати комп’ютер. Так була організована, наприклад, Windows 3.1). Що станеться, якщо один потік ще не закінчив працювати з будь-яким загальним ресурсом, а система перемкнулася на інший потік, який використовує той же ресурс? Відбудеться штука дуже неприємна, я вам це можу з упевненістю сказати, і результат роботи цих потоків може надзвичайно сильно відрізнятися від задуманого. Такі конфлікти можуть виникнути і між потоками, що належать різним процесам. Завжди, коли два або більше потоків використовують небудь загальний ресурс, виникає ця проблема.

Саме тому необхідний механізм, що дозволяє потокам погоджувати свою роботу з загальними ресурсами. Цей механізм отримав назву механізму синхронізації потоків (thread synchronization).

Структура механізму синхронізації

Що ж являє собою цей механізм? Це набір об’єктів операційної системи, які створюються і управляються програмно, є загальними для всіх потоків в системі (деякі – для потоків, що належать одному процесу) і використовуються для координування доступу до ресурсів. В якості ресурсів може виступати все, що може бути загальним для двох і більше потоків – файл на диску, порт, запис в базі даних, об’єкт GDI, і навіть глобальна змінна програми (яка може бути доступна з потоків, що належать одному процесу).

Об’єктів синхронізації існує декілька, найважливіші з них – це взаємовиключення (mutex), критична секція (critical section), подія (event) і семафор (semaphore). Кожен з цих об’єктів реалізує свій спосіб синхронізації. Який з них слід використовувати в кожному конкретному випадку ви зрозумієте, детально ознайомившись з кожним із цих об’єктів. Також в якості об’єктів синхронізації можуть використовуватися самі процеси і потоки (коли один потік чекає завершення іншого потоку або процесу); а також файли, комунікаційні пристрої, консольне введення і повідомлення про зміну (на жаль, висвітлення цих об’єктів синхронізації виходить за рамки цієї статті).

У чому сенс об’єктів синхронізації? Кожен з них може перебувати в так званому сигнальному стані. Для кожного типу об’єктів цей стан має різний зміст. Потоки можуть перевіряти поточний стан об’єкта та / або чекати зміни цього стану і таким чином погоджувати свої дії. Що ще дуже важливо – гарантується, що коли потік працює з об’єктами синхронізації (створює їх, змінює стан) система не перерве його виконання, поки він не завершить цю дію. Таким чином, всі кінцеві операції з об’єктами синхронізації є атомарними (неподільними), як би виконуються за один такт.

Важливо розуміти, що ніякого реального зв’язку між об’єктами синхронізації і ресурсами немає. Вони не зможуть запобігти небажаному доступ до ресурсу, вони лише підказують потокам, коли можна працювати з ресурсом, а коли потрібно почекати. Можна провести аналогію грубу зі світлофорами – вони показують, коли можна їхати, але ж в принципі водій може і не звернути уваги на червоне світло (правда, потім він про це швидше за все пошкодує 😉

Робота з об’єктами синхронізації

Щоб створити той чи інший об’єкт синхронізації, проводиться виклик спеціальної функції WinAPI типу Create … (Напр. CreateMutex). Цей виклик повертає дескриптор об’єкта (HANDLE), який може використовуватися всіма потоками, що належать даному процесу. Є можливість отримати доступ до об’єкта синхронізації з іншого процесу – або успадкувавши дескриптор цього об’єкту, або, що краще, скориставшись викликом функції відкриття об’єкту (Open. ..). Після цього виклику процес отримає дескриптор, який в подальшому можна використовувати для роботи з об’єктом. Об’єкту, якщо тільки він не призначений для використання усередині одного процесу, обов’язково присвоюється ім’я. Імена всіх об’єктів повинні бути різні (навіть якщо вони різного типу). Не можна, наприклад, створити подію і семафор з одним і тим же ім’ям.

За наявного дескриптору об’єкту можна визначити його поточний стан. Це робиться за допомогою т.зв. чекаючих функцій. Найчастіше використовується функція WaitForSingleObject. Ця функція приймає два параметри, перший з яких – дескриптор об’єкта, другий – час очікування в мсек. Функція повертає WAIT_OBJECT_0, якщо об’єкт знаходиться в сигнальному стані, WAIT_TIMEOUT – якщо минув час очікування, і WAIT_ABANDONED, якщо об’єкт-взаємовиключення не був звільнений до того, як володіє їм потік завершився. Якщо час очікування вказано рівним нулю, функція повертає результат негайно, інакше вона чекає протягом зазначеного проміжку часу. У випадку, якщо стан об’єкта стане сигнальним до закінчення цього часу, функція поверне WAIT_OBJECT_0, в іншому випадку функція поверне WAIT_TIMEOUT.
Якщо як часу вказана символічна константа INFINITE, то функція буде чекати необмежено довго, поки стан об’єкта не стане сигнальним.
Якщо необхідно дізнаватися про стан відразу декількох об’єктів, слід скористатися функцією WaitForMultipleObjects.
Щоб закінчити роботу з об’єктом і звільнити дескриптор викликається функція CloseHandle.

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

Тепер давайте розглянемо кожен тип об’єктів синхронізації окремо.

Взаємовиключає
Об’єкти-взаємовиключає (мьютекси, mutex – від MUTual EXclusion) дозволяють координувати взаємне виключення доступу до ресурсу. Сигнальне стан об’єкта (тобто стан “встановлений”) відповідає моменту часу, коли об’єкт не належить жодному потоку і його можна “захопити”. І навпаки, стан “скинутий” (не сигнальне) відповідає моменту, коли який-небудь потік вже володіє цим об’єктом. Доступ до об’єкта дозволяється, коли потік, що володіє об’єктом, звільнить його.

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

Події
Об’єкти-події використовуються для повідомлення чекаючих потоків про настання якої-небудь події. Розрізняють два види подій – з ручним і автоматичним скиданням. Ручний скидання здійснюється функцією ResetEvent. Події з ручним скиданням використовуються для повідомлення відразу декількох потоків. При використанні події з автоскиданням повідомлення отримає і продовжить своє виконання тільки один чекаючий потік, інші будуть чекати далі.

Функція CreateEvent створює об’єкт-подію, SetEvent – встановлює подія в сигнальний стан, ResetEvent-скидає подія. Функція PulseEvent встановлює подія, а після відновлення чекаючих це подія потоків (всіх при ручному скиданні і лише одного при автоматичному), скидає його. Якщо очікують потоків немає, PulseEvent просто скидає подію.

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

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

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

Захищений доступ до змінних
Існує ряд функцій, що дозволяють працювати з глобальними змінними зі всіх потоків не піклуючись про синхронізацію, т.к. ці функції самі за нею стежать. Це функції InterlockedIncrement / InterlockedDecrement, InterlockedExchange, InterlockedExchangeAdd і InterlockedCompareExchange. Наприклад, функція InterlockedIncrement збільшує значення 32-бітової змінної на одиницю – зручно використовувати для різних лічильників. Більш докладно про ці функції див в документації.

Синхронізація в MFC

Бібліотека MFC містить спеціальні класи для синхронізації потоків (CMutex, CEvent, CCriticalSection і CSemaphore). Ці класи відповідають об’єктам синхронізації WinAPI і є похідними від класу CSyncObject. Щоб зрозуміти, як їх використовувати, досить просто поглянути на конструктори і методи цих класів – Lock і Unlock. Фактично ці класи – всього лише обгортки для об’єктів синхронізації.

Eсть ще один спосіб використання цих класів – написання так званих потоково-безпечних класів (thread-safe classes). Потоково-безпечний клас – це клас, що представляє який або ресурс у вашій програмі. Вся робота з ресурсом здійснюється тільки через цей клас, який містить всі необхідні для цього методи. Причому клас спроектований таким чином, що його методи самі піклуються про синхронізацію, так що в додатку він використовується як звичайний клас. Об’єкт синхронізації MFC додається в цей клас в якості закритого члена класу, і всі функції цього класу, які здійснюють доступ до ресурсу, погоджують з ним свою роботу.

З класами синхронізації MFC можна працювати як безпосередньо, використовуючи методи Lock і Unlock, так і через проміжні класи CSingleLock і CMultiLock (хоча на мій погляд, працювати через проміжні класи трохи незручно. Але використання класу Сmultilock необхідно, якщо ви хочете стежити за станом відразу декількох об’єктів).

Висновок

Ігноруючи можливості багатозадачності, які надає Windows, ви ігноруєте переваги цієї операційної системи. Це якраз те, чого не може собі дозволити жоден поважаючий себе програміст. А як ви могли переконатися, багатозадачність – це зовсім не так складно, як здається на перший погляд.

Цікавиться даною темою можу порекомендувати наступні статті і розділи MSDN:


  • Platform SDK / Windows Base Services / Executables / Processes and Threads
  • Platform SDK / Windows Base Services / Interprocess Communication / Synchronization
  • Periodicals 1996 / MSJ / December / First Aid For Thread-impaired:Using Multiple Threads with MFC
  • Periodicals 1996 / MSJ / March / Win32 Q&A
  • Periodicals 1997 / MSJ / July / C++ Q&A.
  • Periodicals 1997 / MSJ / January / Win32 Q&A.

    Приклад

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

    #include <windows.h>
    #include <iostream.h>
    void main()
    {
    DWORD res;

    / / Створюємо об’єкт-взаємовиключення
    HANDLE mutex = CreateMutex(NULL, FALSE, “APPNAME-MTX01”);
    / / Якщо він вже існує, CreateMutex / / Поверне дескриптор існуючого об’єкта,
    / / А GetLastError поверне ERROR_ALREADY_EXISTS

    / / В протягом 20 секунд намагаємося захопити об’єкт
    cout<<“Trying to get mutex…\n”; cout.flush();
    res = WaitForSingleObject(mutex,20000);
    if (res == WAIT_OBJECT_0) / / Якщо захоплення вдався
    {
    / / Чекаємо 10 секунд
    cout<<“Got it! Waiting for 10 secs…\n”; cout.flush();
    Sleep(10000);

    / / Звільняємо об’єкт
    cout<<“Now releasing the object.\n”; cout.flush();
    ReleaseMutex(mutex);
    }

    / / Закриваємо дескриптор
    CloseHandle(mutex);
    }

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

    Схожі статті:


    Сподобалася стаття? Ви можете залишити відгук або підписатися на RSS , щоб автоматично отримувати інформацію про нові статтях.

    Коментарів поки що немає.

    Ваш отзыв

    Поділ на параграфи відбувається автоматично, адреса електронної пошти ніколи не буде опублікований, допустимий HTML: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

    *

    *