Потоки виконання та моделі багатопотоковості
Процес надає програмі ресурси й ізоляцію, а потік виконання задає послідовність команд усередині процесу. Один процес може мати кілька потоків, які працюють зі спільними даними, але кожен перебуває у власній точці виконання.
Цілі лекції
Після опрацювання матеріалу ви зможете:
- пояснювати поняття потоку,
TCBі легковагового процесу (LWP); - порівнювати процеси й потоки за ізоляцією, ресурсами та вартістю створення;
- розрізняти потоки рівня користувача й рівня ядра;
- пояснювати моделі
N:1,1:1,M:Nі дворівневу модель; - визначати спільні та приватні ресурси потоків;
- створювати POSIX-потоки через
pthread_createі чекати їх черезpthread_join; - розпізнавати ситуації, у яких доступ до спільних змінних потребує синхронізації.
Передумови
Потрібно знати структуру адресного простору процесу, призначення PCB, стани процесу та загальну ідею перемикання контексту. Для запуску прикладу потрібні Linux, компілятор C і бібліотека POSIX Threads.
1. Потік як послідовність виконання
Потік (thread) - найменша послідовність команд, яку планувальник може виконувати окремо. Потік має власну поточну точку виконання і контекст, але належить процесу та використовує його ресурси.
Для кожного потоку потрібні окремі:
- лічильник команд (
program counter); - значення регістрів процесора;
- стек викликів;
- стан планування: готовий, виконується, очікує тощо;
- ідентифікатор та інші службові відомості.
Якщо процес має три потоки, код програми не копіюється тричі. Натомість існують три незалежні контексти виконання: один потік може обробляти запит, другий чекати дані, третій формувати журнал.
2. TCB і LWP
Thread Control Block (TCB) - структура даних із відомостями, потрібними для керування потоком. Конкретний склад залежить від ОС і бібліотеки, але концептуально TCB містить ідентифікатор, стан, збережені регістри, відомості про стек, параметри планування та посилання на ресурси процесу.
TCB не слід ототожнювати зі змінною типу pthread_t. pthread_t є непрозорим ідентифікатором бібліотеки POSIX Threads, а внутрішні структури ядра й бібліотеки є деталями реалізації.
Легковаговий процес (LWP) - історично й концептуально проміжна сутність, через яку потік користувача отримує виконання ядром. Значення терміна залежить від системи. У сучасному Linux потоки й процеси подано задачами ядра, які можуть спільно використовувати вибрані ресурси. Тому фраза «LWP - це завжди окремий універсальний об'єкт між потоком і ядром» була б надто спрощеною.
Практична ідея LWP: ядру потрібен планований контекст, який воно може поставити на процесор, заблокувати й відновити.
3. Процес і потік: що відрізняється
| Ознака | Окремі процеси | Потоки одного процесу |
|---|---|---|
| Адресний простір | зазвичай окремий | спільний |
| Код і глобальні дані | ізольовані за адресними просторами | спільні |
| Стек і регістри | окремі | окремі для кожного потоку |
| Обмін даними | потребує IPC або іншого явного механізму | можливий через спільну пам'ять процесу |
| Наслідок помилки пам'яті | переважно обмежений одним процесом | може пошкодити весь процес |
| Створення і перемикання | зазвичай дорожчі | зазвичай дешевші |
Потоки зручні не тому, що «завжди швидші», а тому, що дають кілька послідовностей виконання зі спільним станом. Ця перевага одночасно створює ризик: помилкова адреса або неузгоджений запис одного потоку впливає на інші потоки процесу.
4. Спільні та приватні ресурси
Потоки одного процесу зазвичай спільно використовують:
- код програми;
- глобальні та статичні дані;
- купу;
- адресний простір;
- відкриті файлові дескриптори;
- частину атрибутів і прав процесу.
Кожен потік має власні:
- регістри й лічильник команд;
- стек;
- стан виконання;
- ідентифікатор потоку;
- дані, явно оголошені як thread-local.
Локальна змінна функції зазвичай лежить у стеку конкретного виклику конкретного потоку. Проте переданий у функцію вказівник може вести до об'єкта в купі, спільного для всіх. Отже, слово «локальна» в коді не гарантує, що всі доступні через неї дані приватні.
5. Потоки рівня користувача і ядра
Потоки рівня користувача керуються бібліотекою або середовищем виконання без окремої видимості кожного логічного потоку для ядра. Перемикання між ними може бути дешевим, а політику легко адаптувати до мови чи застосунку.
Потоки рівня ядра відомі ОС як окремі плановані сутності. Ядро може незалежно блокувати й запускати їх та розміщувати на різних ядрах процесора. Це потребує участі ядра й має більші службові витрати.
Термін «green thread», goroutine, virtual thread або coroutine не задає автоматично одну й ту саму реалізацію. Потрібно з'ясувати, хто планує логічну роботу та на скільки потоків ядра вона відображається.
6. Моделі відображення потоків
N:1 U1 ─┐
U2 ─┼── K1
U3 ─┘
1:1 U1 ─── K1
U2 ─── K2
U3 ─── K3
M:N U1 ─┐ ┌── K1
U2 ─┼───┤
U3 ─┤ └── K2
U4 ─┘
Тут U - потік рівня користувача, K - потік ядра.
N:1
Багато користувацьких потоків відображаються на один потік ядра. Перемикання може відбуватися без системного виклику. Проте ядро бачить лише один контекст: блокувальний виклик може зупинити весь набір, а справжнього паралельного виконання на кількох ядрах немає.
1:1
Кожному користувацькому потоку відповідає потік ядра. Блокування одного не зупиняє інші, а кілька потоків можуть одночасно працювати на різних ядрах. Недолік - витрати ядра й практичні обмеження на кількість потоків. POSIX Threads у Linux у типовому застосуванні відповідають цій моделі.
M:N
M користувацьких потоків мультиплексуються на N потоків ядра. Модель поєднує багато дешевих логічних потоків із паралельністю, але потребує складної взаємодії планувальника середовища з ядром.
Дворівнева модель
Це варіант M:N, у якому окремі користувацькі потоки можна прив'язати до конкретних потоків ядра. Гнучкість зростає, але реалізація та керування стають складнішими.
7. Конкурентність і паралельність
Конкурентність означає, що кілька задач просуваються в перекривних проміжках часу. На одному ядрі це можливо через чергування.
Паралельність означає фізичне виконання кількох задач одночасно, наприклад на двох ядрах.
Багатопотокова програма може бути конкурентною на одному ядрі й паралельною на багатоядерній системі. Кількість створених потоків сама по собі не гарантує прискорення: впливають обсяг роботи, синхронізація, кеші, введення-виведення та рішення планувальника.
8. Приклад POSIX Threads без спільного запису
Програма передає кожному потоку окремий елемент масиву. Потік обчислює квадрат свого числа й записує результат лише у власне поле. Головний потік читає результати після pthread_join.
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
struct task {
int value;
int square;
};
static void *calculate_square(void *argument)
{
struct task *task = argument;
task->square = task->value * task->value;
return NULL;
}
int main(void)
{
enum { THREAD_COUNT = 3 };
pthread_t threads[THREAD_COUNT];
struct task tasks[THREAD_COUNT] = {
{ .value = 2, .square = 0 },
{ .value = 3, .square = 0 },
{ .value = 4, .square = 0 }
};
for (int i = 0; i < THREAD_COUNT; ++i) {
int error = pthread_create(
&threads[i], NULL, calculate_square, &tasks[i]
);
if (error != 0) {
fprintf(stderr, "pthread_create failed: %d\n", error);
return EXIT_FAILURE;
}
}
for (int i = 0; i < THREAD_COUNT; ++i) {
int error = pthread_join(threads[i], NULL);
if (error != 0) {
fprintf(stderr, "pthread_join failed: %d\n", error);
return EXIT_FAILURE;
}
}
for (int i = 0; i < THREAD_COUNT; ++i) {
printf("%d^2 = %d\n", tasks[i].value, tasks[i].square);
}
return EXIT_SUCCESS;
}
Збережіть код у squares.c, скомпілюйте й запустіть:
cc -std=c11 -Wall -Wextra -Wpedantic -pthread squares.c -o squares
./squares
Очікувані рядки:
2^2 = 4
3^2 = 9
4^2 = 16
Параметр -pthread задає потрібні параметри і для компіляції, і для компонування. Порядок обчислень не визначений, але друк виконує головний потік після усіх join, тому рядки виходять у порядку масиву.
У цьому прикладі немає конкурентного запису в один об'єкт: tasks[i] різні, а головний потік не читає поле square, доки відповідний потік не завершився. Це не означає, що будь-який запис у різні елементи будь-якої структури автоматично безпечний; завжди перевіряйте, чи адреси справді не перекриваються та чи немає інших одночасних доступів.
9. Де з'являється стан гонитви
Небезпечний варіант:
static int counter = 0;
static void *increment(void *argument)
{
(void)argument;
counter++;
return NULL;
}
Якщо кілька потоків виконують counter++ без синхронізації, маємо стан гонитви і data race у термінах C. Операція складається принаймні з читання, обчислення та запису; pthread_join лише очікує завершення і не робить попередні конкурентні записи взаємно виключними. Такий код не слід використовувати для отримання правильного підсумку. М'ютекси й атомарні операції розглядатимемо в наступних лекціях.
Практичні завдання
Завдання 1. Відтворіть безпечний приклад
- Скомпілюйте
squares.cз параметром-pthread. - Запустіть програму кілька разів.
- Покажіть у коді місце створення потоку, його функцію та місце очікування.
Завдання 2. Передайте нові аргументи
Збільште THREAD_COUNT до 4, додайте значення 5 та отримайте рядок 5^2 = 25. Не передавайте всім потокам адресу однієї змінної циклу.
Завдання 3. Розділіть роботу над даними
Створіть чотири завдання з полями begin, end, sum. Кожен потік має обчислити суму свого неперекривного діапазону цілих чисел і записати її у власне поле sum. Після всіх pthread_join головний потік має скласти часткові суми. Поясніть, чому підсумовування виконують після очікування.
Підсумок
- Потік є окремою послідовністю виконання всередині процесу.
TCBзберігає контекст і службові відомості потоку;pthread_tне є синонімом внутрішньогоTCB.- Потоки одного процесу спільно використовують адресний простір, код, купу та файлові дескриптори, але мають власні регістри й стеки.
- Потоки користувача планує бібліотека або runtime, потоки ядра - ОС.
- Моделі
N:1,1:1іM:Nвизначають відображення логічних потоків на плановані контексти ядра. - Конкурентність не обов'язково означає фізичну паралельність.
pthread_createстворює POSIX-потік,pthread_joinочікує його завершення, а-pthreadпотрібний під час складання програми.- Спільна пам'ять спрощує обмін, але конкурентні записи без синхронізації можуть утворити стан гонитви.
