Многопоточность в C++: std::thread, мьютексы и гонки данных
Объясняем основы параллельного программирования на C++11 и выше: создаём потоки, защищаем разделяемые данные и разбираем классические ошибки, которые приводят к недетерминированным багам.
Содержание
Многопоточность в C++: std::thread, мьютексы и гонки данных
Параллельное программирование — одна из тех областей, где разрыв между «работает на моей машине» и «работает в продакшене» особенно болезнен. Программа может проходить тысячи тестов и всё равно падать раз в несколько дней с повреждёнными данными или зависать намертво. Причина почти всегда одна: неправильная работа с потоками и разделяемым состоянием.
С появлением C++11 язык наконец получил стандартную модель памяти и набор примитивов для многопоточности — std::thread, мьютексы, условные переменные, атомарные типы. До этого разработчики были вынуждены использовать платформозависимые API: pthreads на Unix, WinAPI на Windows. Теперь можно писать переносимый многопоточный код, не выходя за пределы стандартной библиотеки.
Эта статья — практический разбор основ: как создавать потоки, как защищать разделяемые данные и какие ошибки стоят за большинством многопоточных багов.
Модель памяти C++11: почему это важно
Прежде чем говорить о потоках, нужно понять, что изменилось в C++11 на фундаментальном уровне. До 2011 года стандарт C++ вообще не знал о существовании потоков. Компилятор и процессор могли переупорядочивать инструкции как угодно — и это было легально, потому что стандарт не накладывал никаких ограничений на многопоточный доступ к памяти.
C++11 ввёл формальную модель памяти: набор правил, определяющих, когда запись одного потока становится видна другому. Ключевые понятия:
- happens-before — отношение порядка между операциями: если A happens-before B, то B гарантированно видит результат A.
- data race — ситуация, когда два потока одновременно обращаются к одному объекту и хотя бы одно обращение — запись, без синхронизации. Это неопределённое поведение (undefined behavior).
- sequentially consistent — самый строгий порядок, при котором все потоки видят операции в одном глобальном порядке.
Понимание модели памяти критично, потому что компилятор имеет право переставлять инструкции, а процессор — буферизовать записи. То, что выглядит как последовательный код, на уровне железа может исполняться совсем иначе.
Создание потоков: std::thread
Базовый способ запустить поток — создать объект std::thread, передав ему callable: функцию, лямбду или функтор.
#include <iostream>
#include <thread>
void worker(int id) {
std::cout << "Thread " << id << " is running\n";
}
int main() {
std::thread t1(worker, 1);
std::thread t2(worker, 2);
t1.join();
t2.join();
return 0;
}
Здесь join() блокирует вызывающий поток до завершения дочернего. Это важно: если объект std::thread уничтожается, а поток ещё не завершён и не был detach()-нут, программа вызывает std::terminate().
join() и detach(): в чём разница
join()— ждём завершения потока. Ресурсы освобождаются, объект становится не-joinable.detach()— отсоединяем поток. Он продолжает работать независимо, но мы теряем над ним контроль. Использовать с осторожностью: если главный поток завершится раньше, отсоединённый поток может обращаться к уже уничтоженным объектам.
std::thread t([]() {
// долгая работа
});
// Плохая практика: detach без чёткого понимания времени жизни
t.detach();
RAII-обёртка для потока
Классическая проблема: между созданием потока и вызовом join() может быть выброшено исключение. Тогда join() не вызовется, и программа упадёт. Решение — обернуть поток в RAII-класс:
class ScopedThread {
std::thread t_;
public:
explicit ScopedThread(std::thread t) : t_(std::move(t)) {
if (!t_.joinable()) {
throw std::logic_error("No thread");
}
}
~ScopedThread() {
t_.join();
}
ScopedThread(const ScopedThread&) = delete;
ScopedThread& operator=(const ScopedThread&) = delete;
};
В C++20 эту роль выполняет std::jthread, который автоматически вызывает join() в деструкторе и поддерживает кооперативную отмену через std::stop_token.
Гонки данных: главный враг многопоточного кода
Рассмотрим классический пример — счётчик, который инкрементируют несколько потоков:
#include <iostream>
#include <thread>
#include <vector>
int counter = 0;
void increment(int n) {
for (int i = 0; i < n; ++i) {
++counter; // ГОНКА ДАННЫХ
}
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i) {
threads.emplace_back(increment, 100000);
}
for (auto& t : threads) t.join();
std::cout << "Counter: " << counter << "\n"; // Ожидаем 1000000, получаем меньше
}
Операция ++counter не атомарна. Она раскладывается в три шага: загрузить значение, увеличить, сохранить. Два потока могут одновременно загрузить одно и то же значение, оба увеличат его и оба запишут одинаковый результат — одно из приращений потеряется.
Это гонка данных (data race). По стандарту — неопределённое поведение. На практике: неверные результаты, иногда падения, а в худшем случае — корректная работа на одной машине и сбои на другой.
Почему гонки так сложно отлаживать
Гонки данных недетерминированы. Они зависят от планировщика ОС, нагрузки на систему, количества ядер, кэш-поведения процессора. Программа может работать корректно часами и упасть в самый неподходящий момент. Стандартные отладчики здесь почти бесполезны — под отладчиком тайминги меняются, и баг перестаёт воспроизводиться.
Инструменты, которые реально помогают:
- ThreadSanitizer (TSan) — динамический анализатор, встроенный в GCC и Clang. Флаг
-fsanitize=thread. Обнаруживает большинство гонок данных во время выполнения. - Helgrind — инструмент Valgrind для обнаружения ошибок синхронизации.
Мьютексы: базовая защита разделяемых данных
Мьютекс (mutual exclusion) — примитив синхронизации, который гарантирует, что в критической секции одновременно находится не более одного потока.
#include <mutex>
int counter = 0;
std::mutex mtx;
void increment(int n) {
for (int i = 0; i < n; ++i) {
std::lock_guard<std::mutex> lock(mtx);
++counter;
}
}
std::lock_guard — RAII-обёртка: захватывает мьютекс в конструкторе и освобождает в деструкторе. Даже если в критической секции выбросится исключение, мьютекс будет корректно освобождён.
Виды мьютексов в стандартной библиотеке
| Тип | Особенности |
|---|---|
std::mutex | Базовый, не рекурсивный |
std::recursive_mutex | Один поток может захватить несколько раз |
std::timed_mutex | Поддерживает захват с таймаутом |
std::shared_mutex | Читатели/писатели: много читателей или один писатель |
std::unique_lock — более гибкая альтернатива lock_guard: позволяет отложенный захват, освобождение до конца области видимости, передачу владения.
std::unique_lock<std::mutex> lock(mtx, std::defer_lock);
// ... какая-то работа ...
lock.lock(); // захватываем позже
// критическая секция
lock.unlock(); // освобождаем досрочно
Паттерн читатели-писатели
Если данные читаются часто, а пишутся редко, эксклюзивный мьютекс — узкое место. std::shared_mutex (C++17) позволяет нескольким читателям работать одновременно:
#include <shared_mutex>
std::shared_mutex rw_mutex;
std::map<int, std::string> cache;
std::string read(int key) {
std::shared_lock<std::shared_mutex> lock(rw_mutex); // разделяемый захват
auto it = cache.find(key);
return it != cache.end() ? it->second : "";
}
void write(int key, const std::string& value) {
std::unique_lock<std::shared_mutex> lock(rw_mutex); // эксклюзивный захват
cache[key] = value;
}
Классические ошибки синхронизации
Дедлок
Дедлок возникает, когда два потока ждут друг друга. Классический сценарий: поток A захватил мьютекс M1 и ждёт M2, поток B захватил M2 и ждёт M1.
std::mutex m1, m2;
void thread_a() {
std::lock_guard<std::mutex> l1(m1);
std::this_thread::sleep_for(std::chrono::milliseconds(1));
std::lock_guard<std::mutex> l2(m2); // ждёт, пока B освободит m2
}
void thread_b() {
std::lock_guard<std::mutex> l2(m2);
std::this_thread::sleep_for(std::chrono::milliseconds(1));
std::lock_guard<std::mutex> l1(m1); // ждёт, пока A освободит m1
}
Решение — всегда захватывать мьютексы в одном и том же порядке. Или использовать std::lock(), который захватывает несколько мьютексов атомарно, без риска дедлока:
std::lock(m1, m2);
std::lock_guard<std::mutex> l1(m1, std::adopt_lock);
std::lock_guard<std::mutex> l2(m2, std::adopt_lock);
В C++17 это ещё проще через std::scoped_lock:
std::scoped_lock lock(m1, m2); // захватывает оба атомарно
Ложное ощущение безопасности
Частая ошибка — думать, что мьютекс защищает объект, тогда как на самом деле он защищает только конкретный блок кода. Если в одном месте вы используете мьютекс, а в другом — нет, защиты нет.
// Плохо: итератор может стать невалидным между вызовами
std::vector<int> data;
std::mutex mtx;
void bad_usage() {
mtx.lock();
auto it = data.begin();
mtx.unlock();
// Здесь другой поток может изменить data!
// it теперь невалиден
std::cout << *it << "\n"; // UB
}
Захват мьютекса на слишком долгое время
Критическая секция должна быть минимальной. Если внутри неё выполняются долгие операции (I/O, сетевые запросы, сложные вычисления), все остальные потоки простаивают.
// Плохо
{
std::lock_guard<std::mutex> lock(mtx);
auto result = expensive_computation(); // держим мьютекс всё это время
data = result;
}
// Лучше
auto result = expensive_computation(); // вычисляем без мьютекса
{
std::lock_guard<std::mutex> lock(mtx);
data = result; // только запись под мьютексом
}
Атомарные типы: когда мьютекс избыточен
Для простых операций — счётчиков, флагов — мьютекс может быть избыточен по накладным расходам. std::atomic<T> обеспечивает атомарность без явной блокировки:
#include <atomic>
std::atomic<int> counter{0};
void increment(int n) {
for (int i = 0; i < n; ++i) {
++counter; // атомарная операция, без гонки
}
}
Атомарные типы поддерживают различные порядки памяти — от memory_order_relaxed (только атомарность, без синхронизации) до memory_order_seq_cst (полная последовательная согласованность, дефолт). Выбор порядка влияет на производительность и корректность — это отдельная глубокая тема, требующая тщательного изучения.
Тем, кто хочет разобраться в многопоточности C++ системно — от базовых примитивов до lock-free структур данных и модели памяти — может быть полезен курс «C++ King»: там эти темы разбираются последовательно, с практическими задачами.
Условные переменные: координация между потоками
Мьютекс решает задачу взаимного исключения. Но иногда нужно, чтобы поток ждал наступления какого-то условия — например, появления данных в очереди. Для этого используются условные переменные (std::condition_variable).
#include <condition_variable>
#include <queue>
std::queue<int> task_queue;
std::mutex queue_mutex;
std::condition_variable cv;
bool done = false;
void producer() {
for (int i = 0; i < 10; ++i) {
{
std::lock_guard<std::mutex> lock(queue_mutex);
task_queue.push(i);
}
cv.notify_one(); // будим одного ожидающего потока
}
{
std::lock_guard<std::mutex> lock(queue_mutex);
done = true;
}
cv.notify_all();
}
void consumer() {
while (true) {
std::unique_lock<std::mutex> lock(queue_mutex);
cv.wait(lock, [] { return !task_queue.empty() || done; });
if (task_queue.empty()) break;
int task = task_queue.front();
task_queue.pop();
lock.unlock();
// обрабатываем задачу
std::cout << "Processing: " << task << "\n";
}
}
Важный нюанс: cv.wait() принимает лямбду-предикат. Это защита от spurious wakeups — ситуации, когда поток просыпается без вызова notify. Без предиката такое пробуждение приведёт к обработке пустой очереди.
Практические рекомендации
Несколько принципов, которые снижают вероятность ошибок:
-
Минимизируйте разделяемое состояние. Чем меньше данных разделяется между потоками, тем меньше поводов для синхронизации. Предпочитайте передачу данных через очереди сообщений, а не через общую память.
-
Используйте RAII для всех примитивов синхронизации.
lock_guard,unique_lock,scoped_lock— никогда не вызывайтеlock()/unlock()вручную. -
Всегда захватывайте мьютексы в одном порядке. Это единственный надёжный способ избежать дедлоков без специальных инструментов.
-
Запускайте TSan в CI. ThreadSanitizer находит гонки данных, которые не воспроизводятся вручную. Это должно быть частью пайплайна, а не разовой проверкой.
-
Не оптимизируйте синхронизацию без профилировщика. Мьютексы быстры, если нет высокой конкуренции. Переход на lock-free код усложняет программу и оправдан только там, где измерения показывают реальную проблему.
Заключение
Многопоточность в C++ — мощный инструмент и постоянный источник трудноуловимых ошибок. C++11 дал разработчикам стандартный, переносимый набор примитивов: std::thread, мьютексы, атомарные типы, условные переменные. C++17 и C++20 сделали работу с ними удобнее и безопаснее.
Ключевое правило остаётся неизменным: любой одновременный доступ к разделяемым данным, где хотя бы одна операция — запись, требует синхронизации. Нарушение этого правила — неопределённое поведение, которое может проявиться в любой момент и в самой непредсказуемой форме.
Базовые примитивы, описанные в этой статье, покрывают большинство практических задач. Но за ними стоит целый пласт более глубоких тем: модель памяти и порядки операций, lock-free структуры данных, std::future и std::async, корутины C++20. Если хочется разобраться в C++ системно — от основ языка до продвинутой многопоточности — стоит посмотреть на курс «C++ King», где эти темы выстроены в последовательную программу с практикой.
Многопоточный код не прощает небрежности. Но именно поэтому умение писать его правильно — одно из тех качеств, которые отличают опытного C++-разработчика.
Комментарии
Пока нет комментариев