$ sudo teach|
    $ sudo teach|
    IT school
  • Telegram
  • Партнёрам
  • Все курсы
$ sudo teach IT
OOO "SALEPROFIT"Контакты и реквизитыIT-Park Logo

Школа

  • Блог
  • Проверить сертификат

Сотрудничество

  • Стать учителем
  • Партнёрская программа
  • О проекте

Право

  • Оферта
  • Политика конфиденциальности

© 2023–2026 $ sudo teach IT™. All Rights Reserved. Public user contributions licensed under CC BY-SA 4.0 license with attribution required
TelegramGitHubYouTube
ГлавнаяБлогМногопоточность в C++: std::thread, мьютексы и гонки данных

Многопоточность в C++: std::thread, мьютексы и гонки данных

$ sudo teach IT
·17 июля 2026 г.·9 мин·67
Многопоточность в C++: std::thread, мьютексы и гонки данных

Объясняем основы параллельного программирования на C++11 и выше: создаём потоки, защищаем разделяемые данные и разбираем классические ошибки, которые приводят к недетерминированным багам.

Содержание
Модель памяти C++11: почему это важноСоздание потоков: std::threadjoin() и detach(): в чём разницаRAII-обёртка для потокаГонки данных: главный враг многопоточного кодаПочему гонки так сложно отлаживатьМьютексы: базовая защита разделяемых данныхВиды мьютексов в стандартной библиотекеПаттерн читатели-писателиКлассические ошибки синхронизацииДедлокЛожное ощущение безопасностиЗахват мьютекса на слишком долгое времяАтомарные типы: когда мьютекс избыточенУсловные переменные: координация между потокамиПрактические рекомендацииЗаключение

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

С появлением 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: функцию, лямбду или функтор.

code
#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() — отсоединяем поток. Он продолжает работать независимо, но мы теряем над ним контроль. Использовать с осторожностью: если главный поток завершится раньше, отсоединённый поток может обращаться к уже уничтоженным объектам.
code
std::thread t([]() {
    // долгая работа
});

// Плохая практика: detach без чёткого понимания времени жизни
t.detach();

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

Классическая проблема: между созданием потока и вызовом join() может быть выброшено исключение. Тогда join() не вызовется, и программа упадёт. Решение — обернуть поток в RAII-класс:

code
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.


Гонки данных: главный враг многопоточного кода

Рассмотрим классический пример — счётчик, который инкрементируют несколько потоков:

code
#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) — примитив синхронизации, который гарантирует, что в критической секции одновременно находится не более одного потока.

code
#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: позволяет отложенный захват, освобождение до конца области видимости, передачу владения.

code
std::unique_lock<std::mutex> lock(mtx, std::defer_lock);
// ... какая-то работа ...
lock.lock(); // захватываем позже
// критическая секция
lock.unlock(); // освобождаем досрочно

Паттерн читатели-писатели

Если данные читаются часто, а пишутся редко, эксклюзивный мьютекс — узкое место. std::shared_mutex (C++17) позволяет нескольким читателям работать одновременно:

code
#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.

code
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(), который захватывает несколько мьютексов атомарно, без риска дедлока:

code
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:

code
std::scoped_lock lock(m1, m2); // захватывает оба атомарно

Ложное ощущение безопасности

Частая ошибка — думать, что мьютекс защищает объект, тогда как на самом деле он защищает только конкретный блок кода. Если в одном месте вы используете мьютекс, а в другом — нет, защиты нет.

code
// Плохо: итератор может стать невалидным между вызовами
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, сетевые запросы, сложные вычисления), все остальные потоки простаивают.

code
// Плохо
{
    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> обеспечивает атомарность без явной блокировки:

code
#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).

code
#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. Без предиката такое пробуждение приведёт к обработке пустой очереди.


Практические рекомендации

Несколько принципов, которые снижают вероятность ошибок:

  1. Минимизируйте разделяемое состояние. Чем меньше данных разделяется между потоками, тем меньше поводов для синхронизации. Предпочитайте передачу данных через очереди сообщений, а не через общую память.

  2. Используйте RAII для всех примитивов синхронизации. lock_guard, unique_lock, scoped_lock — никогда не вызывайте lock()/unlock() вручную.

  3. Всегда захватывайте мьютексы в одном порядке. Это единственный надёжный способ избежать дедлоков без специальных инструментов.

  4. Запускайте TSan в CI. ThreadSanitizer находит гонки данных, которые не воспроизводятся вручную. Это должно быть частью пайплайна, а не разовой проверкой.

  5. Не оптимизируйте синхронизацию без профилировщика. Мьютексы быстры, если нет высокой конкуренции. Переход на lock-free код усложняет программу и оправдан только там, где измерения показывают реальную проблему.


Заключение

Многопоточность в C++ — мощный инструмент и постоянный источник трудноуловимых ошибок. C++11 дал разработчикам стандартный, переносимый набор примитивов: std::thread, мьютексы, атомарные типы, условные переменные. C++17 и C++20 сделали работу с ними удобнее и безопаснее.

Ключевое правило остаётся неизменным: любой одновременный доступ к разделяемым данным, где хотя бы одна операция — запись, требует синхронизации. Нарушение этого правила — неопределённое поведение, которое может проявиться в любой момент и в самой непредсказуемой форме.

Базовые примитивы, описанные в этой статье, покрывают большинство практических задач. Но за ними стоит целый пласт более глубоких тем: модель памяти и порядки операций, lock-free структуры данных, std::future и std::async, корутины C++20. Если хочется разобраться в C++ системно — от основ языка до продвинутой многопоточности — стоит посмотреть на курс «C++ King», где эти темы выстроены в последовательную программу с практикой.

Многопоточный код не прощает небрежности. Но именно поэтому умение писать его правильно — одно из тех качеств, которые отличают опытного C++-разработчика.

Войдите, чтобы поставить лайк и оставить комментарий.

Автор

$ sudo teach IT

C++ King | Полный курс

Курс по теме

C++ King | Полный курс

C++ King — с нуля до уверенной работы на современном C++ (C++11/20/23). Для начинающих и опытных. Освойте указатели, ООП, STL, многопоточность и шаблоны — и пишите быстрый, надёжный код.

Открыть курс

Продолжите обучение

Все курсы
Python – для начинающих!

Python – для начинающих!

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

Перейти к курсу
Простыми словами о защищенном соединении

Простыми словами о защищенном соединении

Базовый курс для обычных людей: что такое защищённое соединение, как оно устроено, что оно умеет и чего не умеет, как выбрать сервис и настроить его на своих устройствах.

Перейти к курсу
Ботостроение Telegram

Ботостроение Telegram

Лёгкий, быстрый и доступный способ познакомиться с миром ботостроения в Telegram. Видео, конспекты, практика и помощь – всё у нас на курсе.

Перейти к курсу
Сервис, который не падает - как построить безопасную зашифрованную сеть

Сервис, который не падает - как построить безопасную зашифрованную сеть

Практический курс по безопасности сети: как собрать собственный сервис защищённого доступа - от пустого сервера до подписки с автовыбором - и, что важнее, как потом не уронить его своими руками.

Перейти к курсу

Другие статьи

Модели данных и типизация в TypeScript: как проектировать контракт между фронтом и API
модели

Модели данных и типизация в TypeScript: как проектировать контракт между фронтом и API

Разберём, как описывать доменные сущности, DTO и error-model на уровне типов, чтобы ловить несовпадения схем до продакшена. Покажем практические шаблоны для типобезопасного маппинга и версионирования контрактов.

15 сентября 2026 г.
1040
GIL в CPython: почему многопоточность не всегда ускоряет ваш Python-код
cpython

GIL в CPython: почему многопоточность не всегда ускоряет ваш Python-код

Разбираем, как устроен Global Interpreter Lock, почему потоки в Python не работают параллельно на CPU-задачах и когда стоит переключиться на multiprocessing или asyncio.

16 июля 2026 г.
710
Создание Telegram бота в 2026 легко и просто! Полный курсы!
создание

Создание Telegram бота в 2026 легко и просто! Полный курсы!

19 июня 2026 г.
1431
Как выбрать, куда идти в IT: веб, боты, данные — без хаоса
выбрать

Как выбрать, куда идти в IT: веб, боты, данные — без хаоса

Разберём самые понятные направления для новичка (веб, боты, аналитика/данные) и чем они отличаются по шагам обучения. В конце вы получите простой план, как проверить, что вам подходит, и какой стартовый путь выбрать.

5 октября 2026 г.
580
Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть
путь

Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть

Разберём структуру репозитория, как упаковать проект в понятную историю (проблема → решение → запуск → метрики качества) и какие артефакты повышают доверие: скринкасты, архитектурная схема, чек-лист продакшн-готовности.

17 сентября 2026 г.
1070
Как понять, какой язык программирования учить первым: простой тест и выбор
понять

Как понять, какой язык программирования учить первым: простой тест и выбор

Помогу новичку выбрать первый язык без стресса: объясню варианты по целям (веб, боты, анализ данных), что учить в первую очередь и как не потратить месяцы впустую. В конце — краткий план старта на ближайшие недели.

2 октября 2026 г.
630

Комментарии

Станьте первым в обсуждении

Поделитесь мнением о статье или задайте вопрос автору

Содержание

Модель памяти C++11: почему это важноСоздание потоков: std::threadjoin() и detach(): в чём разницаRAII-обёртка для потокаГонки данных: главный враг многопоточного кодаПочему гонки так сложно отлаживатьМьютексы: базовая защита разделяемых данныхВиды мьютексов в стандартной библиотекеПаттерн читатели-писателиКлассические ошибки синхронизацииДедлокЛожное ощущение безопасностиЗахват мьютекса на слишком долгое времяАтомарные типы: когда мьютекс избыточенУсловные переменные: координация между потокамиПрактические рекомендацииЗаключение