Перейти к содержанию

Основы потоков выполнения (threads)

Что такое поток выполнения

Поток выполнения (thread) — это единица параллельного исполнения внутри одного процесса. В отличие от процессов, потоки одного процесса совместно используют:

  • одно адресное пространство (сегменты кода, данных .data, .bss, кучу);
  • открытые файловые дескрипторы;
  • текущую директорию, umask.

При этом каждый поток имеет собственные:

  • стек (и thread-local storage, TLS);
  • счётчик команд (program counter);
  • набор регистров процессора;
  • идентификатор потока (TID).
Процесс с тремя потоками (адресное пространство)

┌─────────────────────────────────────────────────────────────────┐
│                         ПРОЦЕСС (PID)                           │
│                                                                 │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │                  ОБЩАЯ ПАМЯТЬ (shared)                   │   │
│  ├──────────────┬─────────────────────┬─────────────────────┤   │
│  │   .text      │   .data / .bss      │       heap          │   │
│  │  (код)       │  (глобальные,       │  (malloc/new)       │   │
│  │              │   статические)      │                     │   │
│  └──────────────┴─────────────────────┴─────────────────────┘   │
│                                                                 │
│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐  │
│  │   Thread 1      │  │   Thread 2      │  │   Thread 3      │  │
│  │  (main thread)  │  │                 │  │                 │  │
│  │                 │  │                 │  │                 │  │
│  │  собственное:   │  │  собственное:   │  │  собственное:   │  │
│  │  ┌───────────┐  │  │  ┌───────────┐  │  │  ┌───────────┐  │  │
│  │  │   stack   │  │  │  │   stack   │  │  │  │   stack   │  │  │
│  │  ├───────────┤  │  │  ├───────────┤  │  │  ├───────────┤  │  │
│  │  │ registers │  │  │  │ registers │  │  │  │ registers │  │  │
│  │  │    PC     │  │  │  │    PC     │  │  │  │    PC     │  │  │
│  │  ├───────────┤  │  │  ├───────────┤  │  │  ├───────────┤  │  │
│  │  │    TLS    │  │  │  │    TLS    │  │  │  │    TLS    │  │  │
│  │  │  (thread_ │  │  │  │  (thread_ │  │  │  │  (thread_ │  │  │
│  │  │   local)  │  │  │  │   local)  │  │  │  │   local)  │  │  │
│  │  └───────────┘  │  │  └───────────┘  │  │  └───────────┘  │  │
│  └─────────────────┘  └─────────────────┘  └─────────────────┘  │
│                                                                 │
│  Файловые дескрипторы, CWD, umask — общие для всех потоков      │
└─────────────────────────────────────────────────────────────────┘

Основные сценарии использования потоков:

  • параллельная обработка данных на многоядерных процессорах;
  • перекрытие ожидания I/O вычислениями (один поток ждёт диск, другой считает);
  • более быстрое переключение контекста по сравнению с переключением между процессами.

Создание потоков в C++

Пример создания и использования thread на C++

В C++11 и выше используется <thread>:

#include <thread>
#include <iostream>

void worker(int id) {
    for (int i = 0; i < 5; ++i) {
        std::cout << "Thread " << id << ": iteration " << i << "\n";
    }
}

int main() {
    // Создаём поток, который выполняет функцию worker(1)
    std::thread t1(worker, 1);

    // Создаём второй поток
    std::thread t2(worker, 2);

    // Основной поток может продолжить свою работу
    std::cout << "Main thread continues...\n";

    // Дожидаемся завершения обоих потоков
    t1.join();
    t2.join();

    std::cout << "All threads finished\n";
    return 0;
}

Компиляция:

g++ -std=c++11 -pthread thread_example.cpp -o thread_example
./thread_example

Пример параллельной обработки данных из двух потоков

Два потока обрабатывают разные части вектора и суммируют результаты:

#include <thread>
#include <vector>
#include <iostream>
#include <mutex>

std::mutex result_mutex;
long long total = 0;

void sum_range(const std::vector<int>& data, int start, int end) {
    long long partial_sum = 0;

    // Каждый поток считает свою часть БЕЗ блокировок (быстро)
    for (int i = start; i < end; ++i) {
        partial_sum += data[i];
    }

    // Только для добавления результата нужен мьютекс
    {
        std::lock_guard<std::mutex> lock(result_mutex);
        total += partial_sum;
    }
}

int main() {
    std::vector<int> data(1000);
    for (int i = 0; i < 1000; ++i) {
        data[i] = i + 1;
    }

    // Первый поток обрабатывает элементы 0-499
    std::thread t1(sum_range, std::ref(data), 0, 500);

    // Второй поток обрабатывает элементы 500-999
    std::thread t2(sum_range, std::ref(data), 500, 1000);

    t1.join();
    t2.join();

    std::cout << "Total sum: " << total << "\n";  // 500500
    return 0;
}

Оба потока работают параллельно, каждый обрабатывает свою половину данных.

Методы join и detach

После создания потока необходимо явно решить, что с ним делать: ждать его завершения или отпустить в фоновый режим.

join() — блокирует вызывающий поток до завершения целевого. После join() объект std::thread становится неактивным. Метод можно вызвать только один раз.

std::thread t(worker);
t.join();  // ждём завершения потока
std::cout << "Thread finished\n";

detach() — отсоединяет поток от объекта std::thread. Поток продолжает выполняться в фоне независимо от объекта. После detach() управлять потоком через объект невозможно.

std::thread t(worker);
t.detach();  // отпускаем поток
std::cout << "Thread is running in background\n";
Операция join detach
Ожидание завершения Да Нет
Контроль потока Есть Нет
Повторный вызов Нельзя Нельзя
Когда очищаются ресурсы После join Когда поток сам завершится

Завершение программы при наличии потоков

Если main() возвращает управление, а некоторые потоки ещё не завершились, поведение зависит от того, как с ними обращались:

  • Если вызван join() — программа корректно дождалась всех потоков и завершается нормально.
  • Если вызван detach() — поток продолжает выполняться в фоне, но разделяемые ресурсы (глобальные объекты, статические переменные) начинают разрушаться. Доступ к ним из потока приводит к неопределённому поведению.
  • Если не вызван ни join(), ни detach() — деструктор std::thread вызывает std::terminate(), и программа аварийно завершается.

Плохой код — забытый join():

int main() {
    std::thread t(worker);
    return 0;  // Крах: деструктор ~thread вызовет std::terminate()
}

Правильный код:

int main() {
    std::thread t(worker);
    t.join();
    return 0;
}

RAII-обёртка для потока

Чтобы гарантировать вызов join() даже при исключениях, можно использовать RAII-паттерн:

class ThreadGuard {
    std::thread& t;
public:
    explicit ThreadGuard(std::thread& t_) : t(t_) {}
    ~ThreadGuard() {
        if (t.joinable()) {
            t.join();
        }
    }
    ThreadGuard(const ThreadGuard&) = delete;
    ThreadGuard& operator=(const ThreadGuard&) = delete;
};

int main() {
    std::thread t(worker);
    ThreadGuard guard(t);  // при выходе из scope guard вызовет join()
    // ... код, который может бросить исключение ...
    return 0;
}

В C++20 аналогичная функциональность встроена в std::jthread, который автоматически вызывает join() в деструкторе.

POSIX Threads (pthreads) — C API

std::thread реализован поверх POSIX Threads. Прямой POSIX API используется при работе на чистом C или когда нужен более тонкий контроль над атрибутами потока:

#include <pthread.h>
#include <stdio.h>

void *worker(void *arg) {
    int id = *(int *)arg;
    printf("Thread %d running\n", id);
    return NULL;
}

int main() {
    pthread_t t1, t2;
    int id1 = 1, id2 = 2;

    pthread_create(&t1, NULL, worker, &id1);
    pthread_create(&t2, NULL, worker, &id2);

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    return 0;
}

Компиляция: gcc -pthread prog.c -o prog

Атрибуты потока (размер стека, политика планировщика) настраиваются через pthread_attr_t:

pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setstacksize(&attr, 2 * 1024 * 1024);  // стек 2 МБ
pthread_create(&t, &attr, worker, NULL);
pthread_attr_destroy(&attr);

Thread-Local Storage (TLS)

TLS — механизм, при котором каждый поток имеет собственную копию переменной. Изменения в одном потоке не видны другим. С точки зрения программиста переменная выглядит как обычная глобальная, но физически каждый поток обращается к своему экземпляру по разным адресам.

В Linux/glibc различают два варианта TLS:

  • Static TLS — переменные, известные на этапе компиляции (__thread / thread_local). Адреса считаются через смещения от TCB и почти не уступают по скорости обычной глобальной памяти.
  • Dynamic TLS — ключи, создаваемые в runtime через POSIX API pthread_key_create. Гибче, но каждое обращение проходит через функцию libpthread.

__thread и thread_local

Ключевое слово __thread — расширение GCC, появившееся задолго до C11. Стандартизованный вариант thread_local появился в C11 (<threads.h> или _Thread_local) и C++11.

#include <thread>
#include <iostream>

thread_local int counter = 0;          // C++11
__thread int gcc_counter;              // GCC, аналогично, но без динамической инициализации

void worker(int id) {
    for (int i = 0; i < 5; ++i) {
        counter++;                     // у каждого потока свой счётчик
    }
    std::cout << "thread " << id << ": counter=" << counter << "\n";
}

int main() {
    std::thread t1(worker, 1);
    std::thread t2(worker, 2);
    t1.join();
    t2.join();
    // оба напечатают counter=5, общая переменная не делится
}

Ограничения:

  • __thread работает только с POD-типами (тривиальная инициализация). thread_local в C++ умеет вызывать конструкторы и деструкторы, но это требует поддержки __cxa_thread_atexit в libc.
  • Инициализация happens per-thread: каждый новый поток получает свежий экземпляр со значением из .tdata (или нулями из .tbss).
  • TLS-переменная без extern живёт в единице трансляции; через extern thread_local можно расшарить объявление между файлами.

ELF-секции и инициализация

Компилятор раскладывает TLS-переменные по двум секциям ELF:

Секция Содержимое Аналог обычных данных
.tdata TLS-переменные с ненулевым init .data
.tbss TLS-переменные с нулевой инициализацией .bss

Линкер собирает их в TLS image — шаблон, который копируется в TLS-блок каждого нового потока при создании. Размер шаблона известен на этапе линковки, поэтому glibc выделяет нужное место сразу за TCB при pthread_create.

# Посмотреть TLS-секции в бинарнике
readelf -S ./prog | grep -E '\.t(data|bss)'
readelf -l ./prog | grep TLS    # PT_TLS-сегмент с шаблоном

Реализация на x86-64: FS-регистр и TCB

Каждый поток на Linux x86-64 имеет собственный сегментный регистр FS, указывающий на Thread Control Block (TCB). На Windows используется GS, на 32-битном Linux — GS. Установка значения регистра выполняется через системный вызов arch_prctl(ARCH_SET_FS, addr) при создании потока.

Память одного потока (Variant II, x86-64 glibc)

  низкие адреса                                  высокие адреса
        │                                              │
        ▼                                              ▼
  ┌─────────────────────────────────┬───────────────┐
  │       TLS area (image copy)     │      TCB      │
  │  ┌─────────────┬─────────────┐  │  ┌─────────┐  │
  │  │   .tdata    │   .tbss     │  │  │ self    │  │
  │  │ (copy from  │ (zeroed)    │  │  │ ptr     │  │
  │  │   image)    │             │  │  ├─────────┤  │
  │  └─────────────┴─────────────┘  │  │  ...    │  │
  │     (negative offsets от %fs)   │  └─────────┘  │
  └─────────────────────────────────┴───────▲───────┘
                                          %fs ── указывает сюда

  thread stack — отдельный регион (не примыкает к TCB),
                  аллоцируется отдельным mmap()

В glibc TCB начинается со структуры tcbhead_t, в которой первое поле — указатель self на саму себя. Это нужно потому, что многие архитектуры не позволяют делать mov %fs, %rax — приходится читать %fs:0, и удобно получить адрес TCB одним обращением.

Доступ к TLS-переменной компилируется в одну инструкцию относительно %fs:

__thread int counter;

void inc(void) { counter++; }
inc:
    addl    $1, %fs:counter@tpoff(%rip)
    ret

counter@tpoff — это TLS Offset, известное на этапе линковки смещение переменной от TCB. Никаких вызовов функций, никакого захвата мьютексов — обычный mov с префиксом сегмента.

Модели доступа TLS

GCC поддерживает четыре модели доступа к TLS, которые компилятор выбирает по флагу -ftls-model= или по контексту. Выбор зависит от того, где живёт переменная (исполняемый файл или .so) и как линкуется код.

Модель Где переменная Где используется Скорость Особенность
Local Exec (LE) в самом executable в executable быстрее всего прямой %fs:offset, offset известен линкеру
Initial Exec (IE) в .so, грузится при старте в executable или другой .so очень быстро offset берётся через GOT: mov %fs:0,%rax; mov tpoff(%rip),%rdx
General Dynamic (GD) в .so, может быть dlopen в .so, может dlopen медленнее вызов __tls_get_addr для разрешения адреса
Local Dynamic (LD) в .so, доступ только из своего модуля в том же .so оптимизация GD один вызов __tls_get_addr для модуля, дальше offsets

Local Exec — выбор по умолчанию для main-программы. Initial Exec используется для библиотек, загружаемых только при старте процесса (через DT_NEEDED). General Dynamic нужен, если библиотека потенциально загружается через dlopen уже после старта — тогда TLS-блок выделяется лениво через DTV (Dynamic Thread Vector), и каждый доступ требует вызова __tls_get_addr.

# Заставить GCC использовать конкретную модель
gcc -ftls-model=initial-exec ...

В большинстве случаев модель выбирается автоматически и трогать её не нужно. Но если библиотека, активно использующая __thread, грузится через dlopen — можно получить заметный overhead на каждом обращении.

POSIX API: pthread_key_create

До появления __thread единственным портативным способом был dynamic TLS через POSIX-ключи. Этот механизм используется до сих пор, когда нужна деструкция TLS-значения при завершении потока — например, чтобы освободить malloc-блок.

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static pthread_key_t buf_key;
static pthread_once_t buf_once = PTHREAD_ONCE_INIT;

// Вызывается при завершении потока для каждого ключа с ненулевым значением
static void buf_destroy(void *p) {
    free(p);
}

static void buf_init(void) {
    pthread_key_create(&buf_key, buf_destroy);
}

char *get_thread_buffer(void) {
    pthread_once(&buf_once, buf_init);

    char *buf = pthread_getspecific(buf_key);
    if (!buf) {
        buf = malloc(1024);
        pthread_setspecific(buf_key, buf);
    }
    return buf;
}

Как это работает:

  • pthread_key_create(&key, destructor) — резервирует слот в массиве TLS-указателей. Лимит: PTHREAD_KEYS_MAX = 1024.
  • pthread_setspecific(key, ptr) — записывает указатель в слот текущего потока.
  • pthread_getspecific(key) — читает указатель из своего слота. Возвращает NULL, если не установлено.
  • Когда поток завершается (pthread_exit, возврат из start-функции, отмена через pthread_cancel), pthreads перебирает все ключи и вызывает destructor(value) для каждого ненулевого значения. Если деструктор сам установит значение через pthread_setspecific, проход повторится — но не более PTHREAD_DESTRUCTOR_ITERATIONS = 4 раз.

Деструкторы — главное, чего нет у __thread в C: при завершении потока статические TLS-переменные просто пропадают вместе с TLS-блоком, без вызова освобождающей логики. В C++ деструкторы thread_local-объектов вызываются автоматически — это реализовано поверх того же механизма (__cxa_thread_atexit).

Сравнение __thread и pthread_key_create

Характеристика __thread / thread_local pthread_key_create
Стандарт C11, C++11, GCC extension POSIX.1
Где живёт значение TLS-блок потока, известный на линковке массив указателей в TCB, индекс в runtime
Доступ один mov %fs:offset, %reg вызов функции libpthread (несколько инструкций)
Скорость максимальная в десятки раз медленнее
Лимит количества переменных ограничен только памятью PTHREAD_KEYS_MAX = 1024
Деструктор при завершении потока только C++ thread_local да, явно через 2-й аргумент pthread_key_create
Динамическое создание ключа нет да
Работа в dlopen-библиотеке требует GD/LD модели и __tls_get_addr работает прозрачно
Когда выбирать быстрый счётчик, кэш, error-state управляемый ресурс с деструктором, плагины

Для нового C++-кода почти всегда правильный выбор — thread_local: он даёт скорость static TLS и при этом корректно вызывает деструкторы. К pthread_key_create обращаются в чистом C, в legacy-коде и когда нужны произвольные runtime-ключи (например, в библиотеках с plugin-архитектурой).

Подробнее о реализации на уровне clone() и устройстве TCB см. Реализация потоков (clone).

Связанные темы

Источники