Практические приёмы многопоточности в C++: как RAII и jthread убирают аварии из конструкции

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

История изменений (1 обновлений, последнее 31 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Русский текст переписан по текущему навыку технического перевода как полный перевод японского оригинала.
Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.22175869)

Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.

Го Комура (2026). Практические приёмы многопоточности в C++: как RAII и jthread убирают аварии из конструкции. KomuraSoft LLC. https://comcomponent.com/ru/blog/multithreading-best-practices-cpp/

DOI (зарегистрированный архив)
10.5281/zenodo.22175869
DOI (последняя зарегистрированная версия)
10.5281/zenodo.22175870

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

Эта статья — версия для 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
  • Ожидание пишите предикатной формой wait у condition_variable. У условных переменных бывают ложные пробуждения (поток просыпается без уведомления), поэтому wait без предиката — рассадник ошибок.6
  • Базовая форма остановки — jthread + stop_token (C++20). В более ранних средах кооперативную остановку собирают вручную из std::atomic<bool> и условной переменной. Принудительное завершение потока в мире C++ лучше считать несуществующим.3
  • Прежде чем пользоваться std::async, знайте, что деструктор future может блокироваться. Отбросьте возвращаемое значение — получите тот же эффект, что у последовательного выполнения.7
  • Объекты синхронизации Win32 нужны только чтобы стыковаться с Win32 API ожидания и для работы между процессами. Во всех остальных случаях писать против стандартной библиотеки выгоднее и для переносимости, и для сопровождения.8

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 27, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

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уже сделан joinstd::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> запрещает только изменение через этот конкретный указатель. Если где-то ещё живёт неконстантный псевдоним или переписывается член mutable, гонка остаётся — поэтому проектируйте и это, вплоть до «после окончания конструирования отпустить неконстантную ссылку и никому больше не писать». Простое решение «когда нужно изменение, построить новый объект и подменить, а не мутировать на месте» снимает один кусок изменяемого состояния, который иначе пришлось бы охранять (об управлении временем жизни самой подмены см. предостережение в разделе 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 при заполнении действует как естественное обратное давление (backpressure), механически передавая перегрузку вверх по потоку. Второй: поскольку у условных переменных бывают ложные пробуждения (пробуждение без уведомления), всегда вызывайте 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_stop)stop_tokenЦикл вычислений:опрашивает stop_requested()Ожидающий поток:condition_variable_any::wait(lock, st, pred)просыпается сразуПрибирает и возвращается самjoin завершаетсятолько теперь можно сказать «остановлен»

Рис. 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?
  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: для C++-кода, где важна переносимость, рекомендуются std::mutex / std::shared_mutex и RAII; объекты синхронизации 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

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

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