Практические лучшие практики многопоточности: издание C++ — устранять аварии структурой с RAII и jthread

· · Windows, Многопоточность, C++, Visual Studio, Бизнес-приложения, Расследование ошибок, Проектирование

«Проект, который нормально работал на C#, после переноса на C++ начал изредка падать.» «Мы использовали std::thread, и при исключении всё приложение мгновенно умерло через terminate.» «Останавливали флагом volatile bool, но только в release-сборке оно не останавливалось.» — Многопоточность в C++ несёт опасность, которой у управляемых языков просто нет: гонка данных как таковая есть неопределённое поведение (UB). Дело не только в том, что можно прочитать испорченное значение; предположения оптимизаций компилятора рушатся, и вы оказываетесь в состоянии, где буквально может случиться что угодно.

Эта статья — издание C++ нашей практической серии о многопоточности. Она рассчитана на разработчиков, которые пишут бизнес-приложения, ПО управления оборудованием и DLL на современном C++ (C++17/20), и переводит общие принципы многопоточного проектирования — не добавлять потоки напрямую, сокращать разделяемое изменяемое состояние, соблюдать дисциплину блокировок, проектировать остановку прежде всего — на инструменты C++ и Windows вместе с ловушками, специфичными для C++, опираясь на первоисточники по состоянию на август 2026. Статья написана так, чтобы читаться самостоятельно. Те же принципы, разобранные для других языков, есть в «издании .NET», «издании C» и «издании Java».

1. Сначала суть

  • В C++ гонка данных — не «можно прочитать испорченное значение», а неопределённое поведение. Не оставить ни одного несинхронизированного разделяемого изменяемого доступа в коде — абсолютное требование, сильнее, чем в других языках.1
  • Не используйте голый std::thread. Если деструктор std::thread срабатывает, пока поток ещё joinable, std::terminate мгновенно убивает процесс. std::jthread из C++20 автоматически join’ит в деструкторе и имеет встроенный механизм запроса остановки (stop_token).23
  • Всегда держите блокировки через RAII. Перестаньте писать mtx.lock() вручную; используйте lock_guard / scoped_lock. Деструктор надёжно отпускает блокировку даже при исключении. Когда захватываете несколько блокировок сразу, scoped_lock берёт это на себя алгоритмом избежания взаимных блокировок.4
  • volatile — не инструмент синхронизации. Для общих флагов и счётчиков используйте std::atomic, для защиты нескольких переменных вместе — std::mutex. std::atomic даёт и атомарность, и упорядочение на основе memory_order.5
  • Rendezvous-ожидание делайте предикатной формой wait у condition_variable. Условные переменные подвержены ложным пробуждениям (пробуждение без уведомления), поэтому вызов wait без предиката — рассадник ошибок.6
  • jthread + stop_token (C++20) — базовая форма того, как останавливать поток. В более ранних средах собирайте кооперативную остановку вручную из std::atomic<bool> плюс условная переменная. Принудительное завершение потока считайте тем, чего в мире C++ просто не существует.3
  • Знайте, что деструктор future может блокироваться, прежде чем пользоваться std::async. Отбросьте возвращаемое значение — и получите тот же эффект, что у последовательного выполнения.7
  • Объекты синхронизации Win32 заслуживают места только для «работы с Win32 API ожидания» и «межпроцессных» сценариев. Везде ещё писать против стандартной библиотеки выгоднее для переносимости и сопровождения.8

2. Почему многопоточность трудна — гонки, взаимные блокировки и неопределённое поведение

Если свести к сути, проблемы, которые приносит многопоточность, бывают двух видов, независимо от языка.

Гонка (race condition) — ошибка, при которой результат зависит от порядка, в котором несколько потоков доходят до конкретного участка кода. Классический пример — общий счётчик: одно выражение ++count на уровне машинного кода распадается на три шага — чтение, сложение, запись назад. Если два потока входят в эти три шага одновременно, сложение одного потока перезаписывается и теряется записью другого. Результат меняется от запуска к запуску, и какой получится — непредсказуемо.

Поток BОбщая переменная countПоток AПоток BОбщая переменная countПоток Acount = 10Два инкремента произошли,но count = 11 — сложение потока A потеряноЧтение (10)Чтение (10)Локальное сложение (11)Локальное сложение (11)Запись назад (11)Запись назад (11)

Рис. 1: Классическая гонка, в которой инкремент общего счётчика теряется. Если другой поток вклинивается в три шага ++count, какая бы запись назад ни случилась последней, она перезаписывает другую

Взаимная блокировка (deadlock) — состояние, в котором два потока каждый ждут блокировку, которую держит другой, так что ни один не может продвинуться. Поток A держит блокировку 1 и ждёт блокировку 2; поток B держит блокировку 2 и ждёт блокировку 1 — этого одного достаточно, чтобы оба остановились навсегда.

ждёт захвата блокировки 2ждёт захвата блокировки 1Поток Aдержит блокировку 1Поток Bдержит блокировку 2

Рис. 2: Циклическое ожидание взаимной блокировки. В момент, когда стрелки ожидания образуют кольцо, каждый поток в этом кольце останавливается навсегда

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

2.1. В C++ гонка данных напрямую есть неопределённое поведение

Сверх того у C++ есть ещё один слой, которого нет у других языков. По стандарту C++, если несколько потоков обращаются к одной и той же ячейке памяти без синхронизации и хотя бы один пишет, это гонка данных, и это неопределённое поведение. Глава о параллелизме C++ Core Guidelines (CP.2, «Avoid data races») ставит это самой первой абсолютной правилом.1 Неопределённое поведение — не кроткая история «можно прочитать либо старое значение, либо новое». Компилятор оптимизирует из предпосылки, что гонки данных нет, поэтому поведение, которое из исходного кода предсказать невозможно — исчезновение проверки условия из цикла, перестановка или слияние записей — происходит законно. Классическая авария, когда «флаг остановки volatile bool не работает только в release-сборке», — учебный случай именно этого.

2.2. RAII — фундамент

Ещё одна предпосылка, специфичная для C++, — исключения и управление ресурсами. У C++ нет finally; вместо него есть RAII (автоматическое освобождение через деструкторы), и инструментарий многопоточности спроектирован в расчёте, что вы им воспользуетесь. «Управлять блокировками через время жизни объекта»; «гарантировать join потока тоже через время жизни объекта» — следовать этому соглашению и есть фундамент безопасного многопоточного C++.

3. Как запускать поток — ловушка thread и jthread

3.1. Деструктор std::thread «спроектирован так, чтобы вызывать аварии»

У std::thread известная ловушка. Если его деструктор срабатывает, пока поток ещё joinable (ни join, ни detach не сделаны), вызывается std::terminate, и процесс мгновенно умирает.9

void process()
{
    std::thread worker([]{ HeavyWork(); });
    DoSomething();      // ← если здесь брошено исключение...
    worker.join();      // ← join так и не достигается; деструктор worker вызывает terminate
}

Чтобы сделать это безопасным к исключениям, приходилось гарантировать join через try/catch — искажённое положение в языке RAII, где одни только потоки нужно было вести вручную. std::jthread C++20 это решает. Поскольку его деструктор автоматически выдаёт запрос остановки и затем join’ит, код выше становится безопасным к исключениям просто заменой на std::jthread.2 В MSVC <stop_token> и jthread доступны начиная с Visual Studio 2019 16.9.3

std::threadни join, ни detach не сделаныstd::threadуже join'итstd::jthread - C++20Поток запущенЧто происходитпри выходе из области видимости?std::terminateпроцесс мгновенно умираетБезопасный joinАвтоматические request_stop + joinбезопасно даже при исключении

Рис. 3: Время жизни объекта потока и как оно заканчивается. std::thread специфицирован так, что забытый join убивает мгновенно, поэтому с C++20 сделайте jthread значением по умолчанию

Как правило, не используйте detach(). Поток, потерявший всякое средство join, становится классической причиной крашей при выходе, соревнуясь с уничтожением статических переменных и кучи при завершении процесса.

3.2. Инструменты «выше уровня потока» — async, future и параллельные алгоритмы

Принцип издания .NET «не создавайте потоки сами» в C++ отображается на следующие инструменты.

  • std::async + std::future: для одноразовой асинхронной задачи и получения её результата. Есть, однако, важная особенность: future (или последний shared_future), связанный с задачей, запущенной через std::async, блокируется до завершения, если его деструктор срабатывает, пока задача ещё не закончена.7 Для работы, реально запущенной с std::launch::async, отбросить возвращённый future равносильно синхронному выполнению в этой точке. Хуже того, если не указать политику запуска, реализация вольна выбрать deferred (ленивое выполнение) по умолчанию, и тогда, если никто не вызовет get() / wait(), работа вообще не выполняется и молча исчезает. Если нужно гарантировать параллельное выполнение, явно укажите std::launch::async и пусть владелец управляет временем жизни future.
  • PPL — Parallel Patterns Library — concurrency::parallel_for / parallel_for_each: применять работу параллельно к каждому элементу коллекции. Однако если работа в одной итерации слишком мала, накладные расходы fork/join съедают выигрыш, поэтому как правило параллельте на внешнем цикле.10
  • Параллельные алгоритмы C++17 — std::execution::par: в MSVC основные алгоритмы распараллелены (не все).11 Заметьте: если исключение вырывается из обработки элемента при политике выполнения, вызывается std::terminate. Ставить собственную границу исключений (try/catch) внутри обратного вызова — то же мышление, что граница потока в разделе 6.

Линия, проведённая в другом месте, — «ожидание ввода-вывода не решается добавлением потоков» — действует без изменений. Для нативного кода Windows OVERLAPPED I/O и IOCP — инструменты, которые принимают эту работу (о механике см. «Глубины ввода-вывода Windows, часть 2»).

4. Сокращать разделяемое изменяемое состояние — разделять, передавать по значению, const и очереди

Состязание возникает только когда есть и «несколько потоков», и «разделяемые изменяемые данные». Число потоков диктуют требования, поэтому проект может урезать именно разделение. Средства делятся на три семейства — разделение, неизменяемость и передача данных — и вот как каждое пишется на C++.

Разделять. В работе вроде параллельной агрегации вместо того, чтобы каждый поток писал в общую сумму, дайте каждому потоку свой локальный промежуточный итог и слейте их один раз, в конце. Записи в общее значение падают с «каждую итерацию» до «один раз на поток», сокращая и стоимость синхронизации, и окно состязания на порядки. Этот единственный шаг слияния можно сделать через std::mutex или через fetch_add на std::atomic — оба варианта годятся.

Передавать по значению. Если данные, нужные потоку, вы отдаёте ему копией (или перемещением) при старте, эти данные становятся исключительно его, и синхронизация не нужна. Захватывать лямбды по ссылке ([&]) и затем трогать переменную, чьё время жизни кончилось, — частая авария, поэтому лямбды, передаваемые в потоки, должны использовать явные захваты, как правило копией или перемещением. При этом «скопировано, значит исключительно» верно только когда значение — глубокий граф значений без псевдонимов вроде указателей или shared_ptr. Копирование структуры с сырым указателем всё равно оставляет то, на что он указывает, общим.

Разделять как const. Данные, которые только читают, безопасно читать с любого числа потоков сразу. Значения конфигурации, справочники, входы вычислений и тому подобное можно разделять без синхронизации, если сделать их const-разделением, которое не переписывают после конструирования (std::shared_ptr<const Config>, например). Оговорка: shared_ptr<const T> запрещает только изменение через этот конкретный дескриптор. Если где-то ещё живёт не-const псевдоним или переписывается член mutable, состязание остаётся — поэтому проектируйте и это, вплоть до «после окончания конструирования отпустить не-const ссылку и никому больше не писать». Простое решение «когда нужно изменение, построить новый объект и подменить, а не мутировать на месте» снимает один кусок изменяемого состояния, который иначе пришлось бы охранять (об управлении временем жизни самой подмены см. предостережение в разделе 5.2).

Передавать через очередь. Направляйте поток данных между потоками через очередь производитель/потребитель, а не через общую переменную. В стандарте C++ нет типа канала, поэтому написать небольшую очередь на std::mutex + std::condition_variable — устоявшийся образец.

template <typename T>
class BlockingQueue {
public:
    explicit BlockingQueue(std::size_t capacity) : capacity_(capacity)
    {
        if (capacity == 0)                          // ёмкость 0 — ловушка, в которой каждый Push ждёт вечно
            throw std::invalid_argument("capacity must be positive");
    }

    // Ждёт, пока появится место (или запрос остановки), если полна. false означает запрос остановки.
    bool Push(T item, std::stop_token st)
    {
        {
            std::unique_lock lock(mtx_);
            if (!not_full_.wait(lock, st, [this]{ return queue_.size() < capacity_; }))
                return false;                       // разбужен запросом остановки
            if (st.stop_requested())                // если место и остановка совпали, предпочесть остановку
                return false;                       // и не принимать вставки после начала остановки
            queue_.push(std::move(item));
        }
        not_empty_.notify_one();   // уведомлять вне блокировки
        return true;
    }

    // Ждёт запрос остановки (stop_token) или прибытие элемента. nullopt, когда остановлено.
    std::optional<T> Pop(std::stop_token st)
    {
        std::optional<T> item;
        {
            std::unique_lock lock(mtx_);
            if (!not_empty_.wait(lock, st, [this]{ return !queue_.empty(); }))
                return std::nullopt;                // разбужен запросом остановки
            if (st.stop_requested())                // если элемент и остановка совпали, предпочесть остановку
                return std::nullopt;                // и не начинать новую работу после начала остановки
            item = std::move(queue_.front());
            queue_.pop();
        }
        not_full_.notify_one();
        return item;
    }

private:
    const std::size_t capacity_;
    std::mutex mtx_;
    std::condition_variable_any not_empty_;   // condition_variable_any, чтобы использовать wait, знающий о stop_token
    std::condition_variable_any not_full_;
    std::queue<T> queue_;
};

Здесь два проектных пункта. Первый: ограничить ёмкость и заставлять сторону производителя ждать, когда полно. Очередь без верхней границы становится бомбой замедленного действия в схемах, где производство обгоняет потребление: «продолжает работать», но память растёт. Блокировка Push при заполнении действует как естественное обратное давление, механически передавая перегрузку вверх по потоку. Второй: поскольку условные переменные подвержены ложным пробуждениям (пробуждение без уведомления), всегда вызывайте wait с предикатом. Предикатная форма wait внутри выполняет за вас логику «цикл, пока условие не истинно».6

5. Дисциплина блокировок — RAII и scoped_lock

Даже сократив разделяемое изменяемое состояние, часто не удаётся свести его к нулю. Для того, что осталось общим, используйте исключение, но блокировки без дисциплины лишь прячут состязание.

Сначала мыслите единицу блокировки не как «отрезок кода», а как «данные». Назначьте один мьютекс каждому набору изменяемых данных, которые хотите защитить (сделайте его private-членом, не выставляйте наружу), и берите тот же мьютекс во всяком месте, которое трогает эти данные — сломанная версия этой таблицы соответствий и есть то, чем на деле являются большинство ошибок гонки. И единственное, что можно делать, держа блокировку, — читать и писать данные, которые она защищает. Файловый ввод-вывод, сетевые вызовы и обратные вызовы (вызовы во внешний код) при удержании блокировки не только удлиняют удержание — они открывают путь, где вызываемый пытается взять другую блокировку и взаимно блокируется. Готовить вне блокировки, а внутри только подменять — базовая форма.

5.1. Писать lock()/unlock() вручную запрещено

Код, который вызывает lock() / unlock() у std::mutex напрямую, в итоге не отпускает блокировку при исключениях или раннем возврате. Всегда оставляйте захват и освобождение обёртке RAII.

Обёртка Применение
std::lock_guard Держит один мьютекс ровно на время области видимости — самая базовая форма
std::scoped_lock (C++17) Захватывает несколько мьютексов сразу. Решает проблему порядка алгоритмом избежания взаимных блокировок4
std::unique_lock Когда нужно отпустить и снова захватить посредине или передать в condition_variable::wait

Когда блокировок две или больше, смена порядка захвата в зависимости от потока — классический образец взаимной блокировки (циклическое ожидание на рис. 2 рождается именно так). Исправление — правило «каждый поток захватывает блокировки в одном порядке», но когда вы захватываете их одновременно, у C++ есть лучший ответ: отдайте несколько мьютексов std::scoped_lock вместе, и библиотека гарантирует порядок захвата без взаимных блокировок.4 В ситуациях вроде перевода между двумя объектами, где нужно «оба заблокированы», никогда не берите их по отдельности — всегда вместе.

void Transfer(Account& from, Account& to, int amount)
{
    if (&from == &to) return;                  // ничего не делать для одного и того же счёта (см. примечание ниже)
    std::scoped_lock lock(from.mtx, to.mtx);   // оба вместе; порядок решает библиотека
    from.balance -= amount;
    to.balance   += amount;
}

Проверка тождества наверху — не украшение. Если один и тот же Account передан и как from, и как to, вы передаёте один и тот же нерекурсивный мьютекс в scoped_lock дважды, что вызывает зависание или неопределённое поведение. Всегда прикрепляйте исключение того же объекта к любой функции, которая «блокирует оба».

Для данных «часто читают, редко пишут» можно использовать std::shared_mutex (C++17) как блокировку чтения/записи.12 recursive_mutex спроектирован так, чтобы «повторный захват тем же потоком не ломался», но проект, которому нужен рекурсивный захват, часто знак, что граница ответственности блокировки размылась — сначала рассмотрите пересмотр структуры.

5.2. Верная роль atomic

std::atomic даёт атомарные операции над одной переменной плюс упорядочение на основе memory_order.5 Его место — те же ситуации, что у Interlocked в издании .NET: обновление одной переменной, например счётчика или флага. Он не может держать несколько переменных согласованными вместе, поэтому для этого возвращайтесь к std::mutex.

Подмена сырого указателя (std::atomic<T*>) имеет свою ловушку. Хотя сама подмена атомарна, никто не защищает время жизни старого объекта после того, как его заменили. Если читатель загружает старый указатель непосредственно перед тем, как писатель подменяет его и делает delete, вы получаете доступ к освобождённой памяти. Если хотите в C++ проект «подменить и разделить неизменяемый объект», выберите средство, которое идёт в паре с управлением временем жизни, — подмену защищённого блокировкой std::shared_ptr<const T> или std::atomic<std::shared_ptr<T>> из C++20.

И ещё раз: volatile — не инструмент межпоточной синхронизации. Безблокировочное программирование, где вы сами задаёте memory_order, — территория экспертов, требующая и законной причины ослабить его относительно значения по умолчанию (seq_cst), и способа проверить, что вы сделали это правильно. В бизнес-приложениях либо оставляйте значение по умолчанию, либо сразу пишите через mutex.

6. Проектировать остановку — stop_token и кооперативная остановка

Первый вопрос при разборе многопоточного проекта: «как это останавливается?» А у C++ нет средств безопасно остановить поток снаружи (насколько опасен Win32 TerminateThread, подробно разобрано в издании C). Поэтому то, как поток останавливается, нужно строить средствами C++ вокруг кооперативной остановки — останавливающая сторона только выдаёт запрос; сам поток решает, когда и как закончить, в точке, которая оставляет всё в порядке; и завершение join считается «остановленным».

В C++20 у std::jthread механизм остановки встроен. Вызов request_stop() поднимает запрос остановки на std::stop_token, который получила функция потока, и цикл его опрашивает. wait у condition_variable_any может принять stop_token напрямую, поэтому «поток, ждущий появления работы», тоже можно мгновенно разбудить запросом остановки (BlockingQueue::Pop в разделе 4 имеет как раз эту форму).

class Worker {
public:
    void Start()
    {
        if (thread_.joinable())                       // Отклонить повторный Start, пока уже работает.
            throw std::logic_error("already running"); // Если присвоить вместо отклонения, новый
                                                       // поток начнёт работать, и пока он ждёт
                                                       // остановки старого, два работника
                                                       // окажутся работающими бок о бок
        thread_ = std::jthread([this](std::stop_token st) {
            try {
                while (!st.stop_requested()) {
                    if (auto item = queue_.Pop(st)) {   // просыпается и на запрос остановки
                        try {
                            Process(*item, st);          // передавать st и в работу, которая может блокироваться внутри
                        } catch (...) {
                            ReportError(std::current_exception());  // зафиксировать один сбой и продолжить
                        }
                    }
                }
            } catch (...) {
                // Последняя линия обороны на границе потока (ловит и сбои
                // в Pop или перемещении). Если исключение вырвется отсюда, std::terminate
                // уронит весь процесс, поэтому ReportError сам никогда не должен бросать
                ReportError(std::current_exception());
            }
        });
    }
    // Явный Stop не нужен:
    // деструктор Worker -> деструктор jthread -> request_stop() + join()
private:
    BlockingQueue<WorkItem> queue_{100};   // ёмкость ограничена (раздел 4)
    std::jthread thread_;
};
запрос остановкиОстанавливающая сторона- деструктор jthread или request_stopstop_tokenЦикл вычислений:опрашивает stop_requested()Ожидающий поток:condition_variable_any::wait(lock, st, pred)просыпается сразуПрибирает и возвращается самjoin завершает rendezvousтолько теперь можно назвать это остановленным

Рис. 4: Кооперативная остановка C++20. Останавливающая сторона только выдаёт запрос; сам поток решает, как закончить; завершение join считается остановленным

Ещё один пункт: try/catch внутри работника нельзя опускать. То, что jthread делает безопасным к исключениям, — join и только join: если исключение вырывается из функции потока, std::terminate валит процесс, как и у std::thread. Явно решите на границе потока, как обрабатывать сбой одной единицы работы (зафиксировать и продолжить или сообщить владельцу по каналу ошибок).

По той же причине заметьте, что в Process тоже передаётся stop_token. Если обработка одной единицы работы блокируется внутри (ожидание сети, длинное вычисление и т. д.) и эта точка не может наблюдать запрос остановки, неявный join деструктора будет вечно ждать завершения этого одного элемента. Кооперативная остановка держится только когда токен дошёл до каждого места, которое ждёт. Если работа включает внешний вызов, который нельзя прервать, поставьте тайм-аут и ограничьте, сколько может длиться одна единица.

В средах до C++17 ту же форму собирают вручную из флага остановки std::atomic<bool> плюс notify_all у condition_variable. Ключевой момент — включить проверку флага остановки в предикат условной переменной: если только поднять флаг и забыть уведомить, ожидающий поток никогда не проснётся.

7. Специфика Windows — граница с Win32 API

7.1. Выбор между стандартной библиотекой и объектами синхронизации Win32

Документация Microsoft рекомендует std::mutex / std::shared_mutex для C++-кода, где важна переносимость, и отводит объектам синхронизации Win32 место «когда нужен Win32 API ожидания» и «межпроцессная синхронизация».8

Ситуация Выбор
Обычное внутрипроцессное исключение std::mutex + RAII (по умолчанию)
Много чтений, редкие записи std::shared_mutex
Ждать несколько объектов сразу через WaitForMultipleObjects Объекты ядра Win32 вроде событий и мьютексов
Межпроцессное исключение / уведомление Именованные мьютексы, события, семафоры
Внутрипроцессная блокировка напрямую через Win32 API SRW-блокировки (CRITICAL_SECTION только когда нужна рекурсия)8

Конкретное проектирование исключения доступа к разделяемой памяти между процессами см. в «Разделяемая память: подводные камни и практические рекомендации».

7.2. Не трогайте потоки внутри DllMain

Серьёзное ограничение при написании DLL — блокировка загрузчика. DllMain вызывается, пока блокировка загрузчика удерживается, поэтому операции внутри вроде синхронизации с другим потоком, ожидания конца потока или вызова LoadLibrary вызывают взаимные блокировки или непредсказуемое поведение. Вынесите любую инициализацию, которая запускает или join’ит потоки, из DllMain в явную функцию инициализации.13

7.3. UI-поток и апартаменты COM

У настольных приложений Windows есть жёсткое ограничение, действующее независимо от языка: только поток, создавший окно или элемент управления, — UI-поток — может его трогать. Windows доставляет оконные сообщения в очередь сообщений потока, создавшего это окно, поэтому создание и манипуляция UI должны быть сосредоточены на этом потоке. Когда нужно обновить экран из рабочего потока, не трогайте напрямую — попросите UI-поток через PostMessage (асинхронно) и обработайте в оконной процедуре на стороне UI-потока. Вызов синхронной формы, SendMessage, пока UI-поток ждёт завершения этого рабочего, вызывает взаимную блокировку, где каждый ждёт другого, поэтому для уведомлений от рабочего делайте асинхронную форму значением по умолчанию. STA/MTA, где задействован COM, разобрано в «Основы COM STA/MTA — модели потоков и как избежать зависаний». Также заметьте, что в C++/CLI-коде, скомпилированном с /clr, стандартные заголовки потоков вроде <thread> и <mutex> заблокированы.14

8. Проверка и отладка — готовиться из предпосылки, что не воспроизведётся

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

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

Второе: делайте аномальные состояния наблюдаемыми, а не прячьте их. Прикрепите тайм-аут через try_lock_for у timed_mutex или wait_for у condition_variable к любой блокировке, которая никогда не должна не захватиться, и логируйте тайм-аут как аномалию — это превращает вечное зависание в обнаруживаемый сбой. Всегда логируйте исключения, пойманные в try/catch на границе потока (раздел 6). Когда в поле происходит зависание или краш, снимайте дамп, проверяйте стек каждого потока и смотрите, не образуют ли их ожидания блокировок цикл. Настройка дампов и логирования разобрана в «Проектирование Windows-приложений, сохраняющих логи и дампы при сбое».

Третье: растрясите под нагрузкой. Долгий прогон с параллелизмом больше, чем ядер, рандомизация порядка обработки и искусственные задержки — практические приёмы стресс-тестирования, которые облегчают «джекпот» гонки на машине разработки. Ошибки, исчезающие в отладочной сборке, часто охотно воспроизводятся в оптимизированной release-сборке под высокой нагрузкой.

9. Итог — контрольный список C++

Наложите проверки, специфичные для C++, на принципы, общие всем языкам: не создавать потоки напрямую, минимизировать разделяемое изменяемое состояние, взаимно однозначное соответствие блокировок и данных, кооперативная остановка.

  1. Не используется ли голый std::thread (нельзя ли jthread? Гарантирован ли join даже на пути исключения?)
  2. Не используется ли detach()?
  3. Явны ли захваты лямбд, и переживает ли любая переменная, захваченная по ссылке, поток?
  4. Можете ли вы уверенно сказать, что нигде нет ни одного несинхронизированного разделяемого изменяемого доступа (= неопределённое поведение)?
  5. Нет ли написанных вручную lock() / unlock(), и берутся ли несколько блокировок вместе через scoped_lock?
  6. Каждый ли condition_variable::wait используется с предикатом?
  7. Не используется ли volatile для общего флага (стоит ли вместо него std::atomic)?
  8. Спроектирован ли путь остановки вокруг stop_token (или атомарного флага плюс уведомление), с завершением join, подтверждающим rendezvous?
  9. Не отбрасывается ли future от std::async?
  10. Свободен ли DllMain от запуска, синхронизации или join потоков?

Многопоточный C++ — работа вплотную к обрыву неопределённого поведения, но оборотная сторона в том, что честное следование RAII и соглашениям стандартной библиотеки ставит реальную дистанцию между вами и этим обрывом. jthread, scoped_lock, предикатный wait, atomic — выбирать верные значения по умолчанию среди этих инструментов и есть в C++ сама практика принципов проектирования.

Похожие статьи

Смежные области консультирования

KomuraSoft LLC занимается разбором многопоточного проектирования C++-приложений и DLL, расследованием первопричин (анализ дампов) ошибок гонки вроде «изредка падает» или «странно ведёт себя только в release-сборке» и консультациями по переносу устаревшего потокового кода на современный C++.

Справочные ссылки

  1. ISO C++, C++ Core Guidelines - CP: Concurrency and parallelism. О том, что CP.1 (исходите из того, что ваш код будет работать как часть многопоточной программы) и CP.2 (избегайте гонок данных) изложены как открывающие правила главы о параллелизме и параллельных вычислениях; о том, что при наличии гонки данных не держится никакая гарантия; и о том, что правила проектирования параллельного кода — область удержания блокировок, использование RAII и так далее — там систематизированы.  2

  2. cppreference.com, std::jthread. О том, что jthread C++20 отличается от std::thread тем, что его деструктор автоматически вызывает request_stop() и затем join’ит; о возможности принять std::stop_token как ведущий аргумент функции потока; и о том, что это гарантирует и join, и запрос остановки даже при брошенном исключении.  2

  3. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. О том, что P0660R10 (<stop_token> и jthread) и P1135R6 (библиотека синхронизации C++20) поддерживаются с Visual Studio 2019 16.9; и о поддержке функций стандартной библиотеки C++ по версиям.  2 3

  4. Microsoft Learn, scoped_lock Class. О том, что scoped_lock C++17 захватывает один или несколько мьютексов при конструировании и отпускает их в деструкторе; о том, что несколько мьютексов, переданных вместе, захватываются алгоритмом избежания взаимных блокировок, эквивалентным std::lock; о надёжном освобождении даже при исключении; и о том, что lock_guard/unique_lock тоже вариант, когда задействован один мьютекс.  2 3

  5. Microsoft Learn, <atomic>. О том, что атомарные операции неделимы, поэтому другие потоки могут наблюдать только состояние до или после операции; о том, что на основе аргумента memory_order устанавливаются требования упорядочения видимости других атомарных операций и подавляются оптимизации компилятора, которые их нарушили бы; о том, что atomic_flag всегда безблокировочен; и о том, что этот заголовок блокируется при /clr:pure.  2

  6. Microsoft Learn, <condition_variable>. О том, что ожидание на условной переменной требует мьютекса, причём блокировка отпускается на время ожидания; о существовании ложных пробуждений — пробуждения без уведомления — поэтому ожидающая сторона должна явно перепроверять условие при возврате, а предикатная форма wait(lock, pred) выполняет этот цикл за вас; и о том, что condition_variable_any сочетается с любым типом мьютекса.  2

  7. Microsoft Learn, <future>. О том, что деструкторы future и shared_future как правило не блокируются, с единственным исключением: future (или последний shared_future), связанный с задачей, запущенной через std::async, блокируется, пока общее состояние не станет ready, если его деструктор срабатывает, пока задача ещё не закончена — поведение, явно отмеченное в стандарте.  2

  8. Microsoft Learn, About Synchronization. О рекомендациях по выбору примитивов синхронизации Win32: std::mutex / std::shared_mutex и RAII рекомендуются для C++-кода, где важна переносимость; объекты синхронизации Win32 используются, когда нужен Win32 API ожидания или межпроцессная синхронизация; для нового внутрипроцессного кода по умолчанию SRW-блокировка, CRITICAL_SECTION — когда нужен рекурсивный захват; использование Mutex для внутрипроцессной синхронизации — «частая ошибка», потому что всегда влечёт переход в ядро.  2 3

  9. cppreference.com, std::thread::~thread. О том, что деструктор std::thread вызывает std::terminate, если его вызывают, пока поток ещё joinable (ни join, ни detach не сделаны) — то есть о том, что решение join или detach должно быть принято до уничтожения объекта потока без исключений. 

  10. Microsoft Learn, Best Practices in the Parallel Patterns Library. О том, что параллелизм предпочтительно выражать на как можно более высоком уровне (внешний цикл); о том, что накладные расходы планирования fork/join могут перевесить выигрыш параллельного выполнения в параллельных циклах, где работа каждой итерации мала или несбалансирована; и о том, что эта тенденция усиливается с ростом числа процессоров. 

  11. Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. О том, что библиотека параллельных алгоритмов C++17 полна, при этом «полна» не значит, что каждый алгоритм распараллелен в каждом случае; о политике реализации распараллеливать важнейшие алгоритмы, всё же предоставляя сигнатуры с политикой выполнения для тех, что не распараллелены. 

  12. Microsoft Learn, C++ standard library header files. О том, что связанные с многопоточностью стандартные заголовки изложены как <atomic> (C++11), <mutex> (C++11), <shared_mutex> (C++14), <condition_variable> (C++11), <future> (C++11), <stop_token> / <semaphore> / <latch> / <barrier> (C++20) и <thread> (C++11). 

  13. Microsoft Learn, Dynamic-Link Library Best Practices. О том, что DllMain вызывается, пока удерживается блокировка загрузчика, накладывая серьёзные ограничения на вызываемые API; о том, что синхронизация с другим потоком внутри DllMain может взаимно блокироваться; о том, что вызов LoadLibrary или ожидание конца потока — типичные запрещённые действия; о том, что инициализацию предпочтительно откладывать как можно дальше и выносить из DllMain; и о том, что следует определить иерархию блокировок с блокировкой загрузчика наверху. 

  14. Microsoft Learn, <thread>. О том, что заголовок <thread> определяет класс thread и вспомогательные функции вроде sleep_for; о том, что этот заголовок блокируется в коде, скомпилированном с /clr; и о том, что макрос STDCPP_THREADS позволяет определить, есть ли поддержка потоков. 

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Как выбирать между std::mutex и CRITICAL_SECTION / SRW-блокировками Win32?
Для обычного C++-кода, где важна переносимость, первый выбор — std::mutex / std::shared_mutex вместе с RAII-обёртками (lock_guard / scoped_lock). К объектам синхронизации Win32 обращайтесь, когда их нужно сочетать с Win32 API ожидания вроде WaitForMultipleObjects или когда нужна межпроцессная синхронизация через именованный объект. Если внутри процесса вы вызываете Win32 API напрямую, для нового кода по умолчанию берите SRW-блокировку, а CRITICAL_SECTION — только когда тот же поток должен захватывать её рекурсивно. Использовать Win32 Mutex для внутрипроцессного исключения — классическая ошибка: это всегда переход в ядро и соответственно медленно.
Можно ли пользоваться detach() у std::thread?
Как правило, избегайте. Отсоединённый поток теряет всякое средство join, и вы теряете контроль над тем, работает ли он ещё при завершении процесса. Классическая авария: отсоединённый поток продолжает работать после уничтожения статических переменных или кучи и вызывает краш при выходе. Возможность дождаться конца потока — базовое требование проектирования потоков, поэтому берите jthread (он join'ит автоматически) или, если пользуетесь thread, структурируйте код так, чтобы всегда делать join до конца области видимости. detach допустим лишь в узкой ситуации, когда поток может разделить судьбу процесса и вы можете гарантировать, что он вообще не трогает общее состояние.
Можно ли в C++ использовать volatile для синхронизации между потоками?
Нет. volatile в C++ — квалификатор для чтений и записей, которые вы не хотите, чтобы компилятор оптимизировал — например, для отображённого в память ввода-вывода — и он не гарантирует видимость или упорядочение между потоками. Если несколько потоков обращаются к одной переменной без синхронизации, это гонка данных, а это неопределённое поведение. Для флагов и счётчиков, разделяемых между потоками, используйте std::atomic, а когда нужно защитить несколько переменных вместе — std::mutex. std::atomic даёт и атомарность операции, и упорядочение на основе memory_order.
std::async выглядит удобно, но есть ли ловушки?
Главная ловушка — деструктор future. future (или последний shared_future), связанный с задачей, запущенной через std::async, блокируется до завершения, если его деструктор срабатывает, пока задача ещё не закончена. Если вы отбрасываете возвращённый future, не удерживая его, это становится эквивалентом синхронного выполнения на месте — авария, когда вы хотели асинхронности, а получили последовательность. Кроме того, если не указать политику запуска, действительно ли работа пойдёт в отдельном потоке, оставлено на усмотрение реализации. Если пользуетесь, явно управляйте временем жизни future и указывайте std::launch::async везде, где нужно гарантировать параллельное выполнение.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог