Основы потоков выполнения (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;
}
Компиляция:
Пример параллельной обработки данных из двух потоков¶
Два потока обрабатывают разные части вектора и суммируют результаты:
#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 становится
неактивным. Метод можно вызвать только один раз.
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()
}
Правильный код:
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:
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.
В большинстве случаев модель выбирается автоматически и трогать её не нужно. Но если библиотека, активно использующая
__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).
Связанные темы¶
- Синхронизация: мьютексы, семафоры, futex — примитивы синхронизации потоков
- Реализация потоков (clone) — как
std::threadустроен на уровне ядра, устройство TCB - ELF-формат — секции
.tdata/.tbssиPT_TLS-сегмент
Источники¶
man 3 pthread_create— создание потока (уровень POSIX)man 3 pthread_attr_setstacksize— атрибуты потокаman 3 pthread_key_create— dynamic TLS-ключи и деструкторыman 3 pthread_getspecific— чтение TLS-значения по ключу- cppreference: std::thread — документация std::thread
- cppreference: std::jthread — C++20 jthread с автоматическим join
- cppreference: thread_local — спецификатор thread_local
- ELF Handling For Thread-Local Storage — Ulrich Drepper — исчерпывающее описание TLS-моделей
- cppreference: std::mutex — синхронизация потоков