Логотип коледжу
Оптико-механічний фаховий коледж

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

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

Цілі лекції

Після опрацювання матеріалу ви зможете:

  • пояснювати поняття потоку, 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. Відтворіть безпечний приклад

  1. Скомпілюйте squares.c з параметром -pthread.
  2. Запустіть програму кілька разів.
  3. Покажіть у коді місце створення потоку, його функцію та місце очікування.

Завдання 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 потрібний під час складання програми.
  • Спільна пам'ять спрощує обмін, але конкурентні записи без синхронізації можуть утворити стан гонитви.

Завдання

1. Що зазвичай має окремий екземпляр для кожного потоку одного процесу?

2. Яке призначення TCB?

3. У моделі 1:1 скільком потокам ядра відповідають 4 потоки рівня користувача? Запишіть лише число.

4. Який типовий недолік моделі N:1?

5. Який параметр команди cc підключає належні параметри компіляції та компонування POSIX Threads? Запишіть параметр точно.

6. Два потоки без синхронізації виконують counter++ над тією самою змінною. Яке твердження коректне?