Вісім простих правил розробки багатопотокових додатків. Приклад простого багатопотокового додатка Управління пріоритетами процесів

У більш ранніх постах було розказано про багатопоточність у Windows за допомогою CreateThread та іншого WinAPI, а також багатопоточність у Linux та інших *nix системах за допомогою pthreads. Якщо ви пишете на C++11 або пізніших версіях, то вам доступні std::thread та інші багатопотокові примітиви, що з'явилися в цьому стандарті мови. Далі буде показано, як із ними працювати. На відміну від WinAPI та pthreads, код, написаний на std::thread, є кросплатформним.

Примітка:Наведений код був перевірений на GCC 7.1 та Clang 4.0 під Arch Linux, GCC 5.4 та Clang 3.8 під Ubuntu 16.04 LTS, GCC 5.4 та Clang 3.8 під FreeBSD 11, а також Visual Studio Community 2017 під Windows 10. CMake до версії 3.8 не вміє говорити компілятор використовувати стандарт C++17, зазначений у властивостях проекту. Як встановити CMake 3.8 в Ubuntu 16.04. Щоб код компілювався за допомогою Clang, *nix системах повинен бути встановлений пакет libc++. Для Arch Linux пакет доступний на AUR. У Ubuntu є пакет libc++-dev, але ви можете зіткнутися з , через який код так просто збиратися не буде. Воркераунд описаний на StackOverflow. У FreeBSD для компіляції проекту необхідно встановити пакет cmake-modules.

М'ютекси

Нижче наведено найпростіший приклад використання тредів та м'ютексів:

#include
#include
#include
#include

Std:: mutex mtx;
static int counter = 0;


for (;; ) (
{
std::lock_guard< std:: mutex >lock(mtx);

break;
int ctr_val = ++ counter;
std:: cout<< "Thread " << tnum << ": counter = " <<
ctr_val<< std:: endl ;
}

}
}

int main() (
std:: vector< std:: thread >threads;
for (int i = 0; i< 10 ; i++ ) {


}

// can"t use const auto& here since .join() is not marked const

thr.join();
}

Std:: cout<< "Done!" << std:: endl ;
return 0;
}

Зверніть увагу на обертання std::mutex у std::lock_guard відповідно до ідіоми RAII . Такий підхід гарантує, що м'ютекс буде відпущений при виході зі скоупу у будь-якому випадку, у тому числі при виникненні винятків. Для захоплення відразу кількох мьютексів з метою запобігання дідлок існує клас std::scoped_lock . Однак він з'явився тільки в C + + 17 і тому може працювати не скрізь. Для ранніх версій C++ є аналогічний по функціоналу шаблон std::lock , щоправда для коректного звільнення локів по RAII вимагає написання додаткового коду.

RWLock

Нерідко виникає ситуація, у якій доступ до об'єкту частіше відбувається читання, ніж запис. У цьому випадку замість звичайного м'ютексу ефективніше використовувати read-write lock, він же RWLock. RWLock може бути захоплений відразу кількома потоками читання, або лише одним потоком на запис. RWLock'у C++ відповідають класи std::shared_mutex і std::shared_timed_mutex:

#include
#include
#include
#include

// std::shared_mutex mtx; // will not work with GCC 5.4
std:: shared_timed_mutex mtx;

static int counter = 0;
static const int MAX_COUNTER_VAL = 100;

void thread_proc(int tnum) (
for (;; ) (
{
// see also std::shared_lock
std:: unique_lock< std:: shared_timed_mutex >lock(mtx);
if (counter == MAX_COUNTER_VAL)
break;
int ctr_val = ++ counter;
std:: cout<< "Thread " << tnum << ": counter = " <<
ctr_val<< std:: endl ;
}
std:: this_thread :: sleep_for (std:: chrono :: milliseconds (10 ) );
}
}

int main() (
std:: vector< std:: thread >threads;
for (int i = 0; i< 10 ; i++ ) {
std:: thread thr(thread_proc, i);
threads.emplace_back (std:: move (thr) ) ;
}

for (auto & thr : threads) (
thr.join();
}

Std:: cout<< "Done!" << std:: endl ;
return 0;
}

За аналогією з std::lock_guard для захоплення RWLock'а використовуються класи std::unique_lock та std::shared_lock, залежно від того, як ми хочемо захопити лок. Клас std::shared_timed_mutex з'явився в C++14 і працює на всіх сучасних платформах (не скажу за мобільні пристрої, ігрові консолі, і так далі). На відміну від std::shared_mutex він має методи try_lock_for, try_lock_unti та інші, які намагаються захопити м'ютекс протягом заданого часу. Я сильно підозрюю, що std::shared_mutex має бути дешевшим за std::shared_timed_mutex. Однак std::shared_mutex з'явився лише в C++17, а значить, підтримується не скрізь. Зокрема, GCC 5.4, який все ще широко використовується, про нього не знає.

Thread Local Storage

Іноді буває потрібно створити змінну, начебто глобальну, але яку бачить лише один потік. Інші потоки теж бачать змінну, але вони мають своє локальне значення. Для цього вигадали Thread Local Storage, або TLS (не має нічого спільного з Transport Layer Security!). Крім іншого, TLS може бути використаний для суттєвого прискорення генерації псевдовипадкових чисел. Приклад використання TLS на C++:

#include
#include
#include
#include

Std:: mutex io_mtx;
thread_local int counter = 0;
static const int MAX_COUNTER_VAL = 10;

void thread_proc(int tnum) (
for (;; ) (
counter++;
if (counter == MAX_COUNTER_VAL)
break;
{
std::lock_guard< std:: mutex >lock(io_mtx);
std:: cout<< "Thread " << tnum << ": counter = " <<
counter<< std:: endl ;
}
std:: this_thread :: sleep_for (std:: chrono :: milliseconds (10 ) );
}
}

int main() (
std:: vector< std:: thread >threads;
for (int i = 0; i< 10 ; i++ ) {
std:: thread thr(thread_proc, i);
threads.emplace_back (std:: move (thr) ) ;
}

for (auto & thr : threads) (
thr.join();
}

Std:: cout<< "Done!" << std:: endl ;
return 0;
}

М'ютекс тут використовується виключно для синхронізації виведення консоль. Для доступу до thread_local змінних жодна синхронізація не потрібна.

Атомарні змінні

Атомарні змінні часто використовуються для виконання простих операцій без використання м'ютексів. Наприклад, вам потрібно інкрементувати лічильник із кількох потоків. Замість того, щоб обертати int до std::mutex, ефективніше скористатися std::atomic_int. Також C++ пропонує типи std::atomic_char, std::atomic_bool та багато інших. Ще на атомарних змінних реалізують lock-free алгоритми та структури даних. Варто відзначити, що вони дуже складні в розробці та налагодженні, і не на всіх системах працюють швидше за аналогічні алгоритми і структури даних з локами.

Приклад коду:

#include
#include
#include
#include
#include

static std:: atomic_int atomic_counter(0 );
static const int MAX_COUNTER_VAL = 100;

Std:: mutex io_mtx;

void thread_proc(int tnum) (
for (;; ) (
{
int ctr_val = ++ atomic_counter;
if (ctr_val >= MAX_COUNTER_VAL)
break;

{
std::lock_guard< std:: mutex >lock(io_mtx);
std:: cout<< "Thread " << tnum << ": counter = " <<
ctr_val<< std:: endl ;
}
}
std:: this_thread :: sleep_for (std:: chrono :: milliseconds (10 ) );
}
}

int main() (
std:: vector< std:: thread >threads;

int nthreads = std:: thread :: hardware_concurrency();
if (nthreads == 0) nthreads = 2;

for (int i = 0; i< nthreads; i++ ) {
std:: thread thr(thread_proc, i);
threads.emplace_back (std:: move (thr) ) ;
}

for (auto & thr : threads) (
thr.join();
}

Std:: cout<< "Done!" << std:: endl ;
return 0;
}

Зверніть увагу на використання процедури hardware_concurrency. Вона повертає оцінку кількості тредів, що у поточної системі може виконуватися паралельно. Наприклад, на машині з чотириядерним процесором, який підтримує hyper threading, процедура повертає число 8. Також процедура може повертати нуль, якщо зробити оцінку не вдалося або процедура просто не реалізована.

Деяку інформацію про роботу атомарних змінних на рівні асемблера можна знайти в нотатці Шпаргалка за основними інструкціями асемблера x86/x64.

Висновок

Наскільки я бачу, все це справді непогано працює. Тобто при написанні кросплатформових додатків на C++ про WinAPI і pthreads можна забути. У чистому C починаючи з C11 також існують кросплатформні треди. Але вони все ще не підтримуються Visual Studio (я перевірив) і навряд чи будь-коли будуть підтримуватися. Не секрет, що Microsoft не бачить інтересу у розвитку підтримки мови C у своєму компіляторі, воліючи концентруватися на C++.

За кадром залишилося ще чимало примітивів: std::condition_variable(_any), std::(shared_)future, std::promise, std::sync та інші. Для ознайомлення з ними я рекомендую сайт cppreference.com. Також може мати сенс прочитати книгу C++ Concurrency in Action. Але повинен попередити, що вона вже не нова, містить забагато води, і по суті переказує десяток статей із cppreference.com.

Повна версія вихідних джерел до цієї нотатки, як завжди, лежить на GitHub . А як ви зараз пишите багатопотокові додатки на C++?

Потоки та процеси – це пов'язані поняття у обчислювальній техніці. Обидва являють собою послідовність інструкцій, які повинні виконуватися в певному порядку. Інструкції в окремих потоках або процесах можуть виконуватися паралельно.

Процеси існують в операційній системі і відповідають тому, що користувачі бачать як програми або програми. Потік, з іншого боку, існує усередині процесу. Тому потоки іноді називаються "полегшені процеси". Кожен процес складається з одного або кількох потоків. Існування кількох процесів дозволяє комп'ютеру "одночасно" виконувати кілька завдань. Існування кількох потоків дозволяє процесу розділяти роботу для паралельного виконання. На багатопроцесорному комп'ютері процеси чи потоки можуть працювати різних процесорах. Це дозволяє виконувати реально паралельну роботу.

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

Перше, що треба засвоїти процес складається хоча б із одного потоку. В ОС кожному процесу відповідає адресний простір і одиночний потік, що управляє. Фактично це визначає процес.

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

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

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

Будь-який потік складається здвох компонентів:

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

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

1. Потік (thread) визначає послідовність виконання коду у процесі.

2. Процес нічого не виконує, він просто служить контейнером потоків.

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

4. Потоки можуть виконувати той самий код і маніпулювати одними й тими самими даними, і навіть спільно використовувати описувачі об'єктів ядра, оскільки таблиця описувачів створюється над окремих потоках, а процесах.

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

Багатозадачність(англ. multitasking) - властивість операційної системи або середовища програмування забезпечувати можливість паралельної (або псевдопаралельної) обробки кількох процесів. Справжня багатозадачність операційної системи можлива лише у розподілених обчислювальних системах.

Файл:Screenshot of Debian (Release 7.1, "Wheezy") running the GNOME desktop environment, Firefox, Tor, and VLC Player.jpg

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

Існує 2 типи багатозадачності:

· Процесна багатозадачність(Заснована на процесах - одночасно виконуються програмах). Тут програма – найменший елемент коду, яким може керувати планувальник операційної системи. Більш відома більшості користувачів (робота в текстовому редакторі та прослуховування музики).

· Поточна багатозадачність(заснована на потоках). Найменший елемент керованого коду - потік (одна програма може виконувати 2 і більше задач одночасно).

Багатопотоковість - спеціалізована форма багатозадачності.

· 1 Властивості багатозадачного середовища

· 2 Проблеми реалізації багатозадачного середовища

· 3 Історія багатозадачних операційних систем

· 4 Типи псевдопаралельної багатозадачності

o 4.1 Невитісняюча багатозадачність

o 4.2 Спільна чи кооперативна багатозадачність

o 4.3 Витіснюючи або пріоритетна багатозадачність (режим реального часу)

· 5 Проблемні ситуації у багатозадачних системах

o 5.1 Голодування (starvation)

o 5.2 Гонка (race condition)

· 7 Примітки

Властивості багатозадачного середовища[ред. редагувати вихідний текст]

Примітивні багатозадачні середовища забезпечують чисте «розподіл ресурсів», коли за кожним завданням закріплюється певний ділянку пам'яті, і завдання активізується в певні інтервали часу.

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

· Кожне завдання має свій пріоритет, відповідно до якого отримує процесорний час та пам'ять

· Система організує черги завдань так, щоб усі завдання отримали ресурси, залежно від пріоритетів та стратегії системи

· Система організує обробку переривань, якими завдання можуть активуватися, деактивуватися і видалятися

· Після закінчення покладеного кванта часу ядро ​​тимчасово переводить завдання зі стану виконання стан готовності, віддаючи ресурси іншим завданням. При нестачі пам'яті сторінки завдань, що не виконуються, можуть бути витіснені на диск (свопінг), а потім через певний системою час, відновлюватися в пам'яті

· Система забезпечує захист адресного простору завдання від несанкціонованого втручання інших завдань

· Система забезпечує захист адресного простору свого ядра від несанкціонованого втручання завдань

· Система розпізнає збої та зависання окремих завдань та припиняє їх

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

· Система гарантує кожному завданню, що рано чи пізно вона буде активована

· Система обробляє запити реального часу

· Система забезпечує комунікацію між процесами

Проблеми реалізації многозадачной среды[ред. редагувати вихідний текст]

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

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

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

Такі потокиназивають також потоками виконання(Від англ. thread of execution); іноді називають «нитками» (літеральний переклад англ. thread) чи неформально «тредами».

Сутью багатопоточності є квазимного завдання на рівні одного виконуваного процесу, тобто всі потоки виконуються в адресному просторі процесу. Крім цього, всі потоки процесу мають не тільки загальний адресний простір, а й загальні дескриптори файлів. Процес, що виконується, має як мінімум один (головний) потік.

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

До переваг багатопоточності в програмуванні можна віднести наступне:

· Спрощення програми у деяких випадках за рахунок використання загального адресного простору.

· Менші щодо процесу тимчасові витрати на створення потоку.

· Підвищення продуктивності процесу за рахунок розпаралелювання процесорних обчислень та операцій введення-виведення.

· 1 Типи реалізації потоків

· 2 Взаємодія потоків

· 3 Критика термінології

· 6 Примітки

Типи реалізації потоков[ред. редагувати вихідний текст]

· Потік у просторі користувача. Кожен процес має таблицю потоків, аналогічну до таблиці процесів ядра.

Переваги та недоліки цього типу такі: Недоліки

1. Відсутність переривання за таймером усередині одного процесу

2. При використанні блокуючого системного запиту процесу всі його потоки блокуються.

3. Складність реалізації

· Потік у просторі ядра. Поруч із таблицею процесів у просторі ядра є таблиця потоків.

· «Волокна» (англ. fibers). Декілька потоків режиму користувача, що виконуються в одному потоці режиму ядра. Потік простору ядра споживає помітні ресурси, в першу чергу фізичну пам'ять та діапазон адрес режиму ядра для стека режиму ядра. Тому було запроваджено поняття «волокна» - полегшеного потоку, виконуваного у режимі користувача. Кожен поток може мати кілька «волокон».

Взаємодія потоків[ред. редагувати вихідний текст]

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

· Взаємовиключення (mutex, м'ютекс) - це об'єкт синхронізації, який встановлюється в особливий сигнальний стан, коли не зайнятий будь-яким потоком. Тільки один потік володіє цим об'єктом у будь-який момент часу, звідси і назва таких об'єктів (від англійської mut ually ex clusive access - взаємно виключає доступ) - одночасний доступом до загального ресурсу виключається. Після всіх необхідних дій М'ютекс звільняється, надаючи іншим потокам доступ до спільного ресурсу. Об'єкт може підтримувати рекурсивне захоплення другий раз тим самим потоком, збільшуючи лічильник, не блокуючи потік, і вимагаючи потім багаторазового звільнення. Така, наприклад, критична секція Win32. Тим не менш, є і такі реалізації, які не підтримують таке і призводять до взаємного блокування потоку при спробі рекурсивного захоплення. Це FAST_MUTEX у ядрі Windows.

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

· Події. Об'єкт, що зберігає у собі 1 біт інформації «просигналізований чи ні», з якого визначені операції «просигналізувати», «скинути у непросигналізований стан» і «очікувати». Очікування на просигналізованому події є відсутність операції з негайним продовженням виконання потоку. Очікування на непросигналізованому події призводить до припинення виконання потоку до тих пір, поки інший потік (або друга фаза обробника переривання в ядрі ОС) не просигналізує подію. Можливе очікування кількох подій в режимах будь-якого або всіх. Можливе також створення події, що автоматично скидається в непросигналізований стан після пробудження першого ж - і єдиного - очікуваного потоку (такий об'єкт використовується як основа для реалізації об'єкта «критична секція»). Активно використовуються в MS Windows як в режимі користувача, так і в режимі ядра. Аналогічний об'єкт є й у ядрі Linux під назвою kwait_queue.

· Критичні секції забезпечують синхронізацію подібно до мьютексам за винятком того, що об'єкти, що представляють критичні секції, доступні в межах одного процесу. Події, м'ютекси і семафори також можна використовувати в однопроцесному додатку, проте реалізації критичних секцій в деяких ОС (наприклад, Windows NT) забезпечують швидший і ефективніший механізм взаємно-виключної синхронізації - операції «отримати» та «звільнити» на критичній секції оптимізовані для випадку єдиного потоку (відсутності конкуренції) з метою уникнути будь-яких системних викликів, що ведуть в ядро ​​ОС. Подібно до м'ютексу об'єкт, що представляє критичну секцію, може використовуватися тільки одним потоком в даний момент часу, що робить їх вкрай корисними при розмежуванні доступу до загальних ресурсів.

· Умовні змінні (condvars). Схожі з подіями, але не є об'єктами, що займають пам'ять - використовується тільки адреса змінної, поняття «вміст змінної» не існує, як умовна змінна може використовуватися адреса довільного об'єкта. На відміну від подій, встановлення умовної змінної в просигналізований стан не тягне за собою жодних наслідків у разі, якщо на даний момент немає потоків, які чекають на змінну. Встановлення події в аналогічному випадку спричиняє запам'ятовування стану «просигналізовано» всередині самої події, після чого наступні потоки, які бажають очікувати на події, продовжують виконання негайно без зупинки. Для повноцінного використання такого об'єкта необхідна також операція «звільнити mutex і чекати на умовну змінну атомарно». Активно використовуються в UNIX-подібних ОС. Дискусії про переваги та недоліки подій та умовних змінних є помітною частиною дискусій про переваги та недоліки Windows та UNIX.

· Порт завершення введення-виведення (IO completion port, IOCP). Реалізований в ядрі ОС і доступний через системні виклики об'єкт «черга» з операціями «помістити структуру у хвіст черги» та «взяти наступну структуру з голови черги» - останній виклик призупиняє виконання потоку у разі, якщо черга порожня, і доти, доки інший потік не здійснить виклик "помістити". Найважливішою особливістю IOCP є те, що структури в нього можуть бути не тільки явним системним викликом з режиму користувача, але й неявно всередині ядра ОС як результат завершення асинхронної операції введення-виведення на одному з дескрипторів файлів. Для досягнення такого ефекту необхідно використати системний виклик "зв'язати дескриптор файлу з IOCP". У цьому випадку вміщена в чергу структура містить код помилки операції введення-виведення, а також, для випадку успіху цієї операції - число реально введених або виведених байт. Реалізація порту завершення також обмежує кількість потоків, що виконуються одному процесорі/ядрі після отримання структури з черги. Об'єкт специфічний для MS Windows, і дозволяє обробку вхідних запитів з'єднання та порцій даних у серверному програмному забезпеченні в архітектурі, де кількість потоків може бути меншою за кількість клієнтів (немає вимоги створювати окремий потік з витратами ресурсів на нього для кожного нового клієнта).

· ERESOURCE. М'ютекс, що підтримує рекурсивне захоплення, з семантикою захоплення, що розділяється або ексклюзивного. Семантика: об'єкт може бути або вільний, або захоплений довільним числом потоків, що розділяється, або захоплений всього одним потоком ексклюзивним чином. Будь-які спроби здійснити захоплення, що порушує це правило, призводять до блокування потоку доти, доки об'єкт не звільниться так, щоб зробити захоплення дозволеним. Також є операції виду TryToAcquire – ніколи не блокує потік, або захоплює, або (якщо потрібне блокування) повертає FALSE, нічого не роблячи. Використовується в ядрі Windows, особливо у файлових системах - так, наприклад, будь-якому кимось відкритому дисковому файлу відповідає структура FCB, в якій є два таких об'єкти для синхронізації доступу до розміру файлу. Один з них - paging IO resource - захоплюється ексклюзивно тільки в обрізанні файлу, і гарантує, що в момент обрізання на файлі немає активного введення-виведення від кеша і від відображення в пам'ять.

· Rundown protection. Напівдокументований (дзвінки присутні у файлах-заголовках, але відсутні в документації) об'єкт у ядрі Windows. Лічильник з операціями «збільшити», «зменшити» та «чекати». Очікування блокує потік, поки операції зменшення не зменшать лічильник до нуля. Крім того, операція збільшення може відмовити, і наявність активного в даний момент очікування часу змушує відмовляти всі операції збільшення.

Приклад посторіння простого багатопотокового додатка.

Народжений про причину великої кількості питань про побудову багатопотокових додатків у Delphi.

Мета цього прикладу - продемонструвати як правильно будувати багатопотоковий додаток, з винесенням тривалої роботи в окремий потік. І як у такому додатку забезпечити взаємодію основного потоку з робітником передачі даних з форми (візуальних компонентів) в потік і назад.

Приклад не претендує на повноту, він лише демонструє найпростіші способи взаємодії потоків. Дозволяючи користувачеві "швиденько зліпити" (хто б знав як я цього не люблю) правильно працює багатопотоковий додаток.
У ньому все докладно (на мій погляд) прокоментовано, але якщо будуть питання, ставте.
Але ще раз застерігаю: Потоки – справа не проста. Якщо Ви не уявляєте як все це працює, тобто величезна небезпека, що часто у Вас все буде працювати нормально, а іноді програма буде поводитися більш ніж дивно. Поведінка неправильно написаної багатопоткової програми дуже залежить від великої кількості факторів, які часом неможливо відтворити при налагодженні.

Отже, приклад. Для зручності помістив і код, і прикріпив архів із кодом модуля та форми

unit ExThreadForm;

uses
Windows, Messages, SysUtils, Variants, Classes, Graphics, Controls, Forms,
Dialogs, StdCtrls;

// Константи використовувані при передачі даних з потоку у форму за допомогою
// Посилання віконних повідомлень
const
WM_USER_SendMessageMetod = WM_USER+10;
WM_USER_PostMessageMetod = WM_USER+11;

type
// опис класу потоку, нащадка від tThread
tMyThread = class(tThread)
private
SyncDataN:Integer;
SyncDataS:String;
procedure SyncMetod1;
protected
procedure Execute; override;
public
Param1:String;
Param2:Integer;
Param3:Boolean;
Stopped:Boolean;
LastRandom:Integer;
IterationNo:Integer;
ResultList:tStringList;

Constructor Create (aParam1: String);
destructor Destroy; override;
end;

// опис класу використовує потік форми
TForm1 = class(TForm)
Label1: TLabel;
Memo1: TMemo;
btnStart: TButton;
btnStop: TButton;
Edit1: TEdit;
Edit2: TEdit;
CheckBox1: TCheckBox;
Label2: TLabel;
Label3: TLabel;
Label4: TLabel;
procedure btnStartClick(Sender: TObject);
procedure btnStopClick(Sender: TObject);
private
(Private declarations)
MyThread:tMyThread;
procedure EventMyThreadOnTerminate (Sender:tObject);
procedure EventOnSendMessageMetod (var Msg: TMessage);message WM_USER_SendMessageMetod;
процедура EventOnPostMessageMetod (var Msg: TMessage); message WM_USER_PostMessageMetod;

Public
(Public declarations)
end;

var
Form1: TForm1;

{
Stopped – демонструє передачу даних від форми до потоку.
Додаткової синхронізації не вимагає, оскільки є простим
однослівним типом, і пишеться лише одним потоком.
}

procedure TForm1.btnStartClick(Sender: TObject);
begin
Randomize(); // забезпечення випадковості в послідовності Random() - до потоком відношення не має

// Створення екземпляра об'єкта потоку, із передачею йому вхідного параметра
{
УВАГА!
Конструктор потоку написаний таким чином, що потік створюється
призупиненим, оскільки це дозволяє:
1. Контролювати момент його запуску. Це в більшості випадків зручніше, т.к.
дозволяє ще до запуску налаштувати потік, передати йому вхідні
параметри і т.п.
2. Т.к. посилання на створений об'єкт буде збережено в полі форми, то
після самознищення потоку (див.нижче) яке при запущеному потоці
може статися будь-якої миті, це посилання стане недійсним.
}
MyThread:= tMyThread.Create(Form1.Edit1.Text);

// Однак, оскільки потік створений зупиненим, то за будь-яких помилок
// під час його ініціалізації (до запуску), ми маємо його самі знищити
// Для чого використовуємо try / except блок
try

// Призначення обробника завершення потоку в якому прийматимемо
// результати роботи потоку, і "затирати" посилання на нього
MyThread.OnTerminate:= EventMyThreadOnTerminate;

// Оскільки результати забиратимемо в OnTerminate, тобто. до самознищення
// потоку то знімемо з себе турботи щодо його знищення
MyThread.FreeOnTerminate:= True;

// Приклад передачі вхідних параметрів через поля об'єкта-потоку, у точці
// Створення екземпляра, коли він ще не запущений.
// Особисто я, волію робити це через параметри перевизначуваного
// конструктора (tMyThread.Create)
MyThread.Param2:= StrToInt(Form1.Edit2.Text);

MyThread.Stopped:= False; // свого роду теж параметр, але змінюється в
// час роботи потоку
except
// оскільки потік ще не запущений і не зможе самознищити, знищимо його "вручну"
FreeAndNil(MyThread);
// а далі нехай виняткова ситуація обробляється звичайним порядком
raise;
end;

// Оскільки об'єкт потоку успішно створено та налаштовано, настав час запустити його
MyThread.Resume;

ShowMessage("Потік запущений");
end;

procedure TForm1.btnStopClick(Sender: TObject);
begin
// Якщо екземпляр потоку ще існує, то попросимо його зупинитися
// Причому саме "попросимо". "Змусити" в принципі теж можемо, але це буде
// Винятково аварійний варіант, що вимагає чіткого розуміння всієї цієї
// Потоковий кухні. Тому тут не розглядається.
if Assigned(MyThread) then
MyThread.Stopped:= True
else
ShowMessage("Потік не запущений!");
end;

procedure TForm1.EventOnSendMessageMetod(var Msg: TMessage);
begin
//Метод обробки синхронного повідомлення
// у WParam адресу об'єкта tMyThread, в LParam тек.значення LastRandom потоку
with tMyThread(Msg.WParam) do begin
Form1.Label3.Caption:= Format("%d %d %d",);
end;
end;

procedure TForm1.EventOnPostMessageMetod(var Msg: TMessage);
begin
//Метод обробки асинхронного повідомлення
// в WParam тек.значення IterationNo, в LParam тек.значення LastRandom потоку
Form1.Label4.Caption:= Format("%d %d",);
end;

procedure TForm1.EventMyThreadOnTerminate (Sender:tObject);
begin
// ВАЖЛИВО!
// Метод обробки події OnTerminate завжди викликається в контексті основного
// Потоку - це гарантується реалізацією tThread. Тому в ньому можна вільно
// використовувати будь-які властивості та методи будь-яких об'єктів

// Про всяк випадок, переконаємося, що екземпляр об'єкта ще існує
if not Assigned(MyThread) then Exit; // якщо його немає, то й робити нічого

// Отримання результатів роботи потоку екземпляра об'єкта потоку
Form1.Memo1.Lines.Add(Format("Потік завершився з результатом %d",));
Form1.Memo1.Lines.AddStrings((Sender as tMyThread).ResultList);

// Знищення посилання екземпляр об'єкта потоку.
// Оскільки потік у нас самознищується (FreeOnTerminate:= True)
// то після завершення обробника OnTerminate, екземпляр об'єкта-потоку буде
// знищений (Free), і посилання на нього стануть недійсними.
// Щоб випадково не напоротися на таке посилання, затремтить MyThread
// Ще раз зауважу - не знищимо об'єкт, а тільки затрем посилання. Об'єкт
// знищиться сам!
MyThread:= Nil;
end;

конструктор tMyThread.Create (aParam1:String);
begin
// Створюємо екземпляр ПРИЗУПИНЕНОГО потоку (див. коментар при створенні екземпляра)
inherited Create(True);

// Створення внутрішніх об'єктів (якщо необхідно)
ResultList:= tStringList.Create;

//Отримання вихідних даних.

// Копіювання вхідних даних, переданих через параметр
Param1: = aParam1;

// Приклад отримання вхідних даних із VCL-компонентів у конструкторі об'єкта-потоку
// Таке у разі допустимо, оскільки конструктор викликається у тих
/ / Основного потоку. Отже, тут можна звертатись до VCL-компонентів.
// Але, я такого не люблю, оскільки вважаю, що погано коли потік знає щось
// Про якусь там форму. Але чого не зробиш для демонстрації.
Param3:= Form1.CheckBox1.Checked;
end;

destructor tMyThread.Destroy;
begin
// Знищення внутрішніх об'єктів
FreeAndNil(ResultList);
// Знищення базового tThread
inherited;
end;

procedure tMyThread.Execute;
var
t: Cardinal;
s:String;
begin
IterationNo:= 0; // лічильник результатів (номер циклу)

// У моєму прикладі тіло потоку є циклом, який завершується
// або на зовнішнє "прохання" завершитися переданий через змінний параметр Stopped,
// або просто зробивши 5 циклів
// Мені приємніше таке записувати через "вічний" цикл.

While True do begin

Inc(IterationNo); // черговий номер циклу

LastRandom: = Random (1000); // слючайне число - для демонстрації передачі параметрів від потоку до форми

T:= Random(5)+1; // час на який засипатимемо якщо нас не завершать

// Тупа робота (що залежить від вхідного параметра)
if not Param3 then
Inc(Param2)
else
Dec(Param2);

// Сформуємо проміжний результат
s:= Format("%s %5d %s %d %d",
);

// Додамо проміжний результат до списку результатів
ResultList.Add(s);

//// Приклади передачі проміжного результату форму

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

//// Переваги:
//// - стандартність та універсальність
//// - у синхронізованому методі можна користуватися
//// усіма полями об'єкта-потоку.
// спочатку, якщо необхідно, треба зберегти дані в
// Спеціальні поля об'єкта об'єкта.
SyncDataN:= IterationNo;
SyncDataS:="Sync"+s;
// і потім забезпечити синхронізований виклик методу
Synchronize(SyncMetod1);

//// Передача через синхронне надсилання повідомлення (SendMessage)
//// у разі, дані можна передати як через параметри повідомлення (LastRandom),
//// і через поля об'єкта, передавши у параметрі повідомлення адресу екземпляра
//// об'єкта-потоку – Integer (Self).
//// Недоліки:
//// - потік повинен знати handle вікна форми
//// - як і при Synchronize, поточний потік буде припинено до
//// завершення обробки повідомлення основним потоком
//// - вимагає суттєвих витрат процесорного часу на кожний виклик
//// (на перемикання потоків) тому небажаний дуже частий виклик
//// Переваги:
//// - як і при Synchronize, при обробці повідомлення можна скористатися
//// усіма полями об'єкта-потоку (якщо звичайно було передано його адресу)


//// Запуск потоку.
SendMessage(Form1.Handle,WM_USER_SendMessageMetod,Integer(Self),LastRandom);

//// Передача через асинхронне надсилання повідомлення (PostMessage)
//// Оскільки в цьому випадку на момент отримання повідомлення основним потоком,
//// посилаючий потік може завершитися, передача адреси екземпляра
//// об'єкта-потоку неприпустима!
//// Недоліки:
//// - потік повинен знати handle вікна форми;
//// - через асинхронність, передача даних можлива тільки через параметри
//// повідомлення, що суттєво ускладнює передачу даних, які мають розмір
//// Більше двох машинних слів. Зручно застосовувати передачі Integer і т.п.
//// Переваги:
//// - на відміну від попередніх методів, поточний потік не буде
//// призупинено, а відразу ж продовжить своє виконання
//// - на відміну від синхронізованого виклику, обробником повідомлення
//// є метод форми, який повинен мати знання про об'єкт-поток,
//// або зовсім нічого не знати про потік, якщо дані передаються тільки
//// Через параметри повідомлення. Тобто потік може нічого не знати про форму
//// взагалі - тільки її Handle, який може бути переданий як параметр до
//// Запуск потоку.
PostMessage(Form1.Handle,WM_USER_PostMessageMetod,IterationNo,LastRandom);

//// Перевірка можливого завершення

// Перевірка завершення за параметром
if Stopped then Break;

// Перевірка завершення з нагоди
if IterationNo >= 10 then Break;

Sleep(t*1000); // Засинаємо на t секунд
end;
end;

procedure tMyThread.SyncMetod1;
begin
// Цей метод викликається методом Synchronize.
// Тобто, незважаючи на те, що він є методом потоку tMyThread,
// він виконується у контексті основного потоку програми.
// Отже, йому все можна, чи майже все:)
// Але пам'ятаємо, тут не варто довго "возитися"

// Передані параметри, ми можемо витягти зі спеціальних поле, куди ми їх
// Зберегли перед викликом.
Form1.Label1.Caption:= SyncDataS;

// або з інших полів об'єкта потоку, наприклад відбивають його тек.
Form1.Label2.Caption:= Format("%d %d",);
end;

А взагалі, прикладу передували такі мої міркування на тему.

По перше:
Найважливіше правило багатопотокового програмування на Delphi:
У контексті не основного потоку не можна, звертатися до властивостей та методів форм, та й взагалі всіх компонентів, які "ростуть" з tWinControl.

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

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

Якщо коротенько на пальцях:

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

У першому випадку, реалізація роботи всередині основного потоку призводить до «гальмування» інтерфейсу користувача - поки робиться робота, не виконується цикл обробки повідомлень. Як наслідок – програма не реагує на дії користувача, і не промальовується форма, наприклад, після її переміщення користувачем.

У другому випадку, коли робота має на увазі активний обмін із зовнішнім світом, то під час вимушених «простоїв». В очікуванні отримання/надсилання даних, можна паралельно робити ще щось, наприклад, знову ж таки інші посилати/приймати дані.

Існують інші випадки, але рідше. Втім, це й байдуже. Нині не про це.

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

Робота, що виноситься в окремий потік, у загальному випадку має чотири сутності (уже не знаю як назвати точніше):
1. Вихідні дані
2. Власне сама робота (вона може залежати від вихідних даних)
3. Проміжні дані (наприклад, інформація про поточний стан виконання роботи)
4. Вихідні дані (результат)

Найчастіше для зчитування та виведення більшої частини даних використовуються візуальні компоненти. Але, як було сказано вище, не можна з потоку безпосередньо звертатися до візуальних компонентів. Як же бути?
Розробники Delphi пропонують використовувати метод Synchronize класу TThread. Тут я не описуватиму те, як його застосовувати – для цього є вищезгадана стаття. Скажу лише, що його застосування, навіть правильне, не завжди є виправданим. Є дві проблеми:

По-перше, тіло методу викликаного через Synchronize завжди виконується в контексті основного потоку, і тому, поки воно виконується, знову ж таки не виконується цикл обробки віконних повідомлень. Отже, воно має виконуватися швидко, інакше, ми отримаємо ті ж проблеми, що і при однопотоковій реалізації. В ідеалі, метод, що викликається через Synchronize, взагалі повинен використовуватися тільки для звернення до властивостей і методів візуальних об'єктів.

По-друге, виконання методу через Synchronize, це «дорого» задоволення, викликане необхідністю двох перемикань між потоками.

Причому обидві проблеми взаємопов'язані, і викликають протиріччя: з одного боку, для вирішення першої, треба «подрібнювати» методи, що викликаються через Synchronize, а з іншого, їх тоді частіше доводиться викликати, втрачаючи дорогоцінний процесорний ресурс.

Тому, як завжди, треба підходити розумно, і для різних випадків, використовувати різні способи взаємодії потоку із зовнішнім світом:

Вихідні дані
Усі дані які передаються потік, і змінюються під час його роботи, треба передавати ще до його запуску, тобто. під час створення потоку. Для їх використання в тілі потоку потрібно зробити їх локальну копію (зазвичай в полях нащадка TThread).
Якщо є вихідні дані які можуть змінюватися під час роботи потоку, то доступ до таких даних потрібно здійснювати через синхронізовані методи (методи викликаються через Synchronize), або через поля об'єкта-потоку (нащадок TThread). Останнє потребує певної обережності.

Проміжні та вихідні дані
Тут, знову ж таки, є кілька способів (у порядку моїх переваг):
- Метод асинхронного надсилання повідомлень головному вікну додатку.
Використовується зазвичай для відсилання основного вікна програми повідомлень про стан протікання процесу з передачею незначного обсягу даних (наприклад, відсотка виконання)
- Метод синхронного надсилання повідомлень головному вікну додатку.
Використовується зазвичай для тих же цілей як і асинхронне відсилання, але дозволяє передати більший обсяг даних, без створення окремої копії.
- Синхронізовані методи, по можливості, поєднуючи в один метод передачі якомога більшого обсягу даних.
Можна використовувати і отримання даних з форми.
- через поля об'єкта-потоку, забезпеченням взаємовиключного доступу.
Докладніше, можна почитати у статті.

Ех. Коротенько знову не вийшло

Клей Бреширс (Clay Breshears)

Вступ

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

Вийшло у світ чимало книг, присвячених паралельним алгоритмам і паралельним обчисленням. Проте, у цих виданнях переважно розкриваються передача повідомлень, системи з розподіленою пам'яттю чи теоретичні паралельні моделі обчислень, часом непридатні до реальних багатоядерних платформ. Якщо ви готові серйозно займатися багатопоточним програмуванням, вам напевно знадобляться знання про розробку алгоритмів для цих моделей. Звичайно, застосування даних моделей досить обмежене, тому багатьом розробникам програмного забезпечення, можливо, так і доведеться реалізувати їх на практиці.

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

Правило 1. Виділіть операції, які виконуються в програмному коді незалежно одна від одної

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

Розглянемо ще один приклад – робочий цикл пункту прокату DVD-дисків, до якого надходять замовлення на певні фільми. Замовлення розподіляють між працівниками пункту, які шукають ці фільми на складі. Природно, якщо один із працівників візьме зі складу диск, на якому записаний фільм за участю Одрі Хепберн, це жодним чином не торкнеться іншого працівника, який шукає черговий бойовик з Арнольдом Шварценеггером, і тим більше не вплине на їхнього колегу, який перебуває у пошуках дисків з новим сезоном серіалу "Друзі". У нашому прикладі ми вважаємо, що всі проблеми, пов'язані з відсутністю фільмів на складі, були вирішені до того, як замовлення надійшли до пункту прокату, а упаковка та відправка будь-якого замовлення не вплине на обробку інших.

У своїй роботі ви, напевно, зіткнетеся з обчисленнями, обробка яких можлива лише в певній послідовності, а не паралельно, оскільки різні ітерації або кроки циклу залежать один від одного і повинні виконуватися в строгому порядку. Візьмемо живий приклад із дикої природи. Уявіть собі вагітну олениху. Оскільки виношування плоду триває в середньому вісім місяців, то, як не крути, оленя не з'явиться через місяць, навіть якщо вісім оленіх завагітніють одночасно. Однак, вісім оленіх одночасно чудово б упоралися зі своїм завданням, якщо запрягти їх усіх у сани Санта-Клауса.

Правило 2. Застосовуйте паралельність із низьким рівнем деталізації

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

У підході «знизу-вгору» реалізується багатопотокова обробка «гарячих» точок коду. Якщо паралельний поділ знайдених точок неможливий, слід досліджувати стек викликів програми, щоб визначити інші сегменти, доступні для паралельного поділу і виконуються досить тривалий час. Припустимо, ви працюєте над програмою, призначеною для стиснення графічних зображень. Стиснення можна реалізувати за допомогою кількох незалежних паралельних потоків, що обробляють окремі сегменти зображення. Однак навіть якщо вам вдалося реалізувати багатопоточність «гарячих» точок, не нехтуйте аналізом стека викликів, в результаті якого можна знайти доступні для паралельного поділу сегменти, що знаходяться на вищому рівні програмного коду. Таким чином, ви зможете збільшити ступінь деталізації паралельної обробки.

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

Під ступенем деталізації паралельних обчислень розуміється обсяг обчислень, які потрібно виконати перед синхронізацією між потоками. Іншими словами, чим рідше здійснюється синхронізація, тим нижчий ступінь деталізації. Поточні обчислення з високою мірою деталізації можуть призвести до того, що системні витрати, пов'язані з організацією потоків, перевищать обсяг корисних обчислень, що виконуються цими потоками. Збільшення кількості потоків при постійному обсязі обчислень ускладнює процес обробки. Багатопотоковість з низькою деталізацією викликає менше системних затримок і має більший потенціал для масштабування, яке може бути здійснене за допомогою організації додаткових потоків. Для реалізації паралельної обробки з низькою деталізацією рекомендується використовувати підхід зверху-вниз і організовувати потоки на високому рівні стека викликів.

Правило 3. Закладайте у свій код можливості масштабування, щоб його продуктивність зростала зі зростанням кількості ядер.

Нещодавно, крім двоядерних процесорів, на ринку з'явилися чотириядерні. Більш того, Intel вже оголосила про створення процесора з 80 ядрами, здатного виконувати трильйон операцій із плаваючою точкою за секунду. Оскільки кількість ядер у процесорах буде з часом лише зростати, ваш програмний код повинен мати відповідний потенціал для масштабування. Масштабованість – параметр, яким можна будувати висновки про здатності докладання адекватно реагувати такі зміни, як збільшення системних ресурсів (кількість ядер, обсяг пам'яті, частота шини та ін.) чи збільшення обсягу даних. Враховуючи, що кількість ядер у процесорах майбутнього збільшиться, створюйте код, що масштабується, продуктивність якого зростатиме завдяки збільшенню системних ресурсів.

Перефразовуючи один із законів Норткота Паркінсона (C. Northecote Parkinson), можна сказати, що «обробка даних займає всі доступні системні ресурси». Це означає, що при збільшенні обчислювальних ресурсів (наприклад, кількості ядер) всі вони, найімовірніше, будуть використовуватися для обробки даних. Повернімося до програми для стиснення відео, розглянутого вище. Поява у процесора додаткових ядер навряд чи позначиться розмірі оброблюваних кадрів – натомість збільшиться кількість потоків, обробних кадр, що призведе до зменшення кількості пікселів на потік. В результаті, через організацію додаткових потоків, зросте обсяг службових даних, а ступінь деталізації паралелізму знизиться. Ще одним ймовірнішим сценарієм може стати збільшення розміру чи кількості відеофайлів, які потрібно буде кодувати. У цьому випадку організація додаткових потоків, які будуть обробляти більші (або додаткові) відеофайли, дозволить розділити весь обсяг робіт безпосередньо на тому етапі, де відбулося збільшення. У свою чергу, додаток з такими можливостями матиме високий потенціал масштабування.

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

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

Правило 4. Застосовуйте поточно-орієнтовані бібліотеки

Якщо для обробки даних у гарячих точках коду може знадобитися якась бібліотека, обов'язково подумайте про використання готових функцій замість власного коду. Одним словом, не намагайтеся винайти велосипед, розробляючи сегменти коду, функції яких вже передбачені оптимізованими процедурами зі складу бібліотек. Багато бібліотек, включаючи Intel® Math Kernel Library (Intel® MKL) та Intel® Integrated Performance Primitives (Intel® IPP), вже містять багатопотокові функції, оптимізовані під багатоядерні процесори.

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

Правило 5. Використовуйте потрібну модель багатопоточності

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

Мінусом явної багатопоточності є неможливість точного керування потоками.

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

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

Правило 6. Результат роботи програмного коду не повинен залежати від послідовності виконання паралельних потоків

Для послідовного програмного коду досить просто визначити вираз, який виконуватиметься після будь-якого іншого виразу. У багатопотоковому коді порядок виконання потоків не визначений і залежить від вказівок планувальника операційної системи. Строго кажучи, практично неможливо передбачити послідовність потоків, що запускаються для виконання будь-якої операції, або визначити який потік буде запущений планувальником в наступний момент. Прогнозування головним чином використовується для зниження часу затримки при виконанні програми, особливо при роботі на платформі з процесором, кількість ядер якого менша за кількість організованих потоків. Якщо якийсь потік заблокований через те, що йому потрібний доступ до області, не записаної в кеш-пам'ять, або через необхідність виконати запит на операцію вводу/виводу, планувальник призупинить його та запустить потік, готовий до запуску.

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

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

Правило 7. Використовуйте локальне зберігання потоків. При необхідності призначайте блокування на окремі області даних

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

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

Якщо локальне зберігання потоків неможливе, доступ до спільних ресурсів синхронізується за допомогою різних об'єктів, наприклад блокування. При цьому важливо правильно призначити блокування конкретним блокам даних, що найпростіше зробити, якщо кількість блокувань дорівнює кількості блоків даних. Єдиний механізм блокування, що синхронізує доступ до кількох областей пам'яті, застосовується тільки тоді, коли всі ці області постійно знаходяться в тому самому критичному розділі програмного коду.

Як зробити, якщо виникла потреба синхронізувати доступ до великого обсягу даних, наприклад, масиву, що складається з 10000 елементів? Організувати єдине блокування для всього масиву – значить, напевно, створити вузьке місце в додатку. Невже доведеться організовувати блокування для кожного елемента окремо? Тоді, навіть якщо до даних звертатимуться 32 або 64 паралельні потоки, доведеться запобігати конфліктам доступу до досить великої області пам'яті, причому ймовірність виникнення таких конфліктів – 1%. На щастя, існує своєрідна золота середина, так звані блокування по модулю. Якщо використовується N блокувань по модулю, кожна з них синхронізуватиме доступ до N-ї частини загальної області даних. Наприклад, якщо організовано два таких блокування, одне з них запобігатиме доступу до парних елементів масиву, а друге – до непарних. У такому разі потоки, звертаючись до необхідного елементу, визначають його парність і встановлюють відповідне блокування. Кількість блокувань по модулю вибирається з урахуванням кількості потоків та ймовірності одночасного звернення кількох потоків до однієї і тієї ж області пам'яті.

Зауважимо, що синхронізації доступу до однієї області пам'яті не допускається одночасне використання кількох механізмів блокування. Згадаймо закон Сегала: «Людина, яка має один годинник, твердо знає, яка година. Людина, яка має кілька годин, ні в чому не впевнена». Припустимо, що доступ до змінної контролюють два різні блокування. У цьому випадку перше блокування може скористатися один сегмент коду, а другий - інший сегмент. Тоді потоки, що виконують ці сегменти, опиняться у ситуації гонки за загальні дані, до яких одночасно звертаються.

Правило 8. Змініть програмний алгоритм, якщо це потрібно для реалізації багатопоточності

Критерієм оцінки продуктивності додатків, як послідовних, і паралельних, є час виконання. Як оцінка алгоритму підходить асимптотичний порядок. За цим теоретичним показником практично завжди можна оцінити продуктивність програми. Тобто, за всіх інших рівних умов, додаток зі ступенем зростання O(n log n) (швидке сортування), буде працювати швидше за додаток зі ступенем зростання O(n2) (вибіркове сортування), хоча результати роботи цих додатків однакові.

Чим краще асимптотичний порядок виконання, тим швидше виконується паралельна програма. Однак навіть найпродуктивніший послідовний алгоритм не завжди можна буде розділити на паралельні потоки. Якщо «гарячу» точку програми дуже складно розділити, і на вищому рівні стека викликів цієї «гарячої» точки теж немає можливості реалізувати багатопоточність, слід спочатку замислитися про застосування іншого послідовного алгоритму, простішого для поділу порівняно з вихідним. Безумовно, для підготовки програмного коду до потокової обробки є й інші методи.

Як ілюстрацію останнього затвердження розглянемо множення двох квадратних матриць. Алгоритм Штрассена має один із кращих асимптотичних порядків виконання: O(n2.81), який набагато кращий, ніж порядок O(n3) алгоритму із звичайним потрійним вкладеним циклом. Згідно з алгоритмом Штрассена, кожна матриця ділиться на чотири підматриці, після чого здійснюється сім рекурсивних викликів для перемноження n/2 × n/2 підматриці. Для розпаралелювання рекурсивних викликів можна створити новий потік, який послідовно виконає сім незалежних перемножень підматриць, поки вони не досягнуть заданого розміру. У такому разі кількість потоків буде експоненційно зростати, а ступінь деталізації обчислень, що виконуються кожним новоствореним потоком, буде підвищуватися зі зменшенням розміру підматриць. Розглянемо інший варіант – організацію пулу із семи потоків, що працюють одночасно і виконують по одному перемноженню підматриць. По завершенню роботи пулу потоків відбувається рекурсивний виклик методу Штрассена для множення підматриць (як і послідовної версії програмного коду). Якщо в системі, яка виконує таку програму, буде більше восьми процесорних ядер, частина з них простоюватиме.

Алгоритм перемноження матриць набагато простіше піддавати паралельному поділу за допомогою потрійного вкладеного циклу. У цьому випадку застосовується декомпозиція даних, при якій матриці поділяються на рядки, стовпці або підматриці, а кожен із потоків виконує певні обчислення. Реалізація такого алгоритму здійснюється за допомогою прагм OpenMP, що вставляються на якомусь рівні циклу, або явною організацією потоків, що виконують розподіл матриць. Для цього більш простого послідовного алгоритму потрібно набагато менше доробок у програмному коді, проти реалізацією многопоточного алгоритму Штрассена.

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

Щоб повернутись на web-сторінку навчальних курсів з багатопоточного програмування, перейдіть по

Яка тема викликає найбільше питань та труднощів у початківців? Коли я запитала про це викладача та Java-програміста Олександра Пряхіна, він одразу відповів: «Багатопоточність». Дякуємо йому за ідею та допомогу у підготовці цієї статті!

Ми заглянемо у внутрішній світ програми та її процесів, розберемося, у чому суть багатопоточності, коли вона корисна і як її реалізувати - з прикладу Java. Якщо вчіть іншу мову ООП, не засмучуйтесь: базові принципи одні й самі.

Про потоки та їх витоки

Щоб зрозуміти багатопоточність, спочатку вникнемо, що таке процес. Процес - це частина віртуальної пам'яті та ресурсів, яку ОС виділяє для виконання програми. Якщо відкрити кілька екземплярів однієї програми, під кожну систему виділить процес. У сучасних браузерах за кожну вкладку може відповідати окремий процес.

Ви напевно стикалися з «Диспетчером завдань» Windows (в Linux це – «Системний монітор») і знаєте, що зайві запущені процеси вантажать систему, а «найважчі» з них часто зависають, тому їх доводиться завершувати примусово.

Але користувачі люблять багатозадачність: хлібом не годуй - дай відкрити з десяток вікон і пострибати туди-сюди. Наявна дилема: потрібно забезпечити одночасну роботу додатків і при цьому знизити навантаження на систему, щоб вона не гальмувала. Припустимо, «залізу» не наздогнати потреб власників - потрібно вирішувати питання на програмному рівні.

Ми хочемо, щоб у одиницю часу процесор встигав виконати більше команд та обробити більше даних. Тобто нам треба вмістити в кожному кванті більше виконаного коду. Уявіть одиницю виконання коду як об'єкта - і є потік.

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

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

Різниця між потоками та процесами

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

    Процеси працюють кожен зі своїми даними - обмінюватись чимось вони можуть лише через механізм міжпроцесної взаємодії. Потоки звертаються до даних та ресурсів один одного безпосередньо: що змінив один – одразу доступно всім. Потік може контролювати «побратимів» у процесі, тоді як процес контролює виключно своїх «дочок». Тому перемикатися між потоками швидше та комунікація між ними організована простіше.

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

Але чому така популярна програма як Firefox йде шляхом створення декількох процесів? Тому що саме для браузера ізольована робота вкладок – це надійно та гнучко. Якщо з одним процесом щось не так, не обов'язково завершувати програму цілком - є можливість зберегти хоча б частину даних.

Що таке багатопоточність

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

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

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

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

Жди сигналу: синхронізація в багатопотокових програмах

Уявіть, що кілька потоків намагаються одночасно змінити ту саму область даних. Чиї зміни будуть у результаті ухвалені, а чиї - скасовані? Щоб робота із загальними ресурсами не призводила до плутанини, потокам потрібно координувати свої дії. І тому вони обмінюються інформацією з допомогою сигналів. Кожен потік повідомляє іншим, що він зараз робить і на які зміни чекати. Так, дані всіх потоків про поточний стан ресурсів синхронізуються.

Основні засоби синхронізації

Взаємовиключення (mutual exclusion, скорочено – mutex) – «прапорець», що переходить до потоку, який на даний момент має право працювати із загальними ресурсами. Виключає доступ інших потоків до зайнятої ділянки пам'яті. М'ютексів у додатку може бути кілька, і вони можуть розділятися між процесами. Є каверза: mutex змушує додаток щоразу звертатися до ядра операційної системи, що накладно.

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

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

Критична секція - більш складний механізм, який поєднує в собі лічильник циклу та семафор. Лічильник дозволяє відкласти запуск семафора на потрібний час. Перевага в тому, що ядро ​​задіяне лише у випадку, якщо секція зайнята і потрібно включати семафор. В решту часу потік виконується в режимі користувача. На жаль, секцію можна використовувати лише всередині одного процесу.

Як реалізувати багатопоточність у Java

За роботу з потоками Java відповідає клас Thread. Створити новий потік для виконання завдання – значить створити екземпляр класу Thread та зв'язати його з потрібним кодом. Зробити це можна двома шляхами:

    утворити від Thread підклас;

    Імплементувати у своєму класі інтерфейс Runnable, після чого передавати екземпляри класу в конструктор Thread.

Поки ми не торкатимемося теми тупикових ситуацій (deadlock"ів), коли потоки блокують роботу один одного і зависають - залишимо це для наступної статті. А зараз перейдемо до практики.

Приклад багатопоточності Java: пінг-понг м'ютексами

Якщо ви думаєте, що зараз буде щось страшне – видихніть. Роботу з об'єктами синхронізації ми розглянемо майже в ігровій формі: два потоки будуть перекидатися mutex"ом. Але по суті ви побачите реальну програму, де в один момент часу тільки один потік може обробляти загальнодоступні дані.

Спочатку створимо клас, який успадковує властивості вже відомого нам Thread, і напишемо метод «удару по м'ячу» (kickBall):

Public class PingPongThread extends Thread( PingPongThread(String name)( this.setName(name); // перевизначаємо ім'я потоку ) @Override public void run() ( Ball ball = Ball.getBall(); while(ball.isInGame()) ( kickBall(ball); ) ) private void kickBall( Ball ball) ( if(!ball.getSide().equals(getName()))( ball.kick(getName()); ) ) )

Тепер подбаємо про м'ячик. Буде він у нас не простий, а пам'ятливий: щоб міг розповісти, хто вдарив по ньому, з якого боку і скільки разів. Для цього використовуємо mutex: він збиратиме інформацію про роботу кожного з потоків – це дозволить ізольованим потокам спілкуватися один з одним. Після 15-го удару виведемо м'яч із гри, щоб його сильно не травмувати.

Public class Ball ( private int kicks = 0; private static Ball instance = new Ball(); private String side = ""; private Ball()() static Ball getBall()( return instance; ) synchronized void kick(String playername) ( kicks++; side = playername; System.out.println(kicks + " " + side); ) String getSide()( return side; ) boolean isInGame()( return (kicks< 15); } }

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

Public class PingPongGame ( PingPongThread player1 = новий PingPongThread("Ping"); PingPongThread player2 = новий PingPongThread("Pong"); Ball Ball; PingPongGame()( Ball = Ball.getBall(); ) .start(); player2.start();

«Повний стадіон народу – час розпочинати матч». Оголосимо про відкриття зустрічі офіційно – у головному класі додатку:

Public class PingPong ( public static void main(String args) throws InterruptedException ( PingPongGame game = new PingPongGame(); game.startGame(); ) )

Як бачите, нічого зубодробного тут немає. Це поки що лише введення в багатопоточність, але ви вже уявляєте, як це працює, і можете експериментувати - обмежувати тривалість гри не числом ударів, а за часом, наприклад. Ми повернемося до теми багатопоточності - розглянемо пакет java.util.concurrent, бібліотеку Akka і механізм volatile. А ще поговоримо про реалізацію багатопоточності на Python.