Сервис считает статистику в четырёх потоках, и раз в неделю сумма в мониторинге не сходится с числом обработанных запросов. Цикл остановки крутится вечно, хотя флаг давно выставлен, и мониторинг ругается на зависший поток. Рядом лежит код, где один мьютекс на весь кэш выстраивает шестнадцать потоков в очередь и съедает половину бюджета задержки. Всё это про одно и то же: синхронизация в C++ это выбор между скучным и хрупким, и оба края бьют больно. Ниже разбор жизненного цикла потоков, модели памяти, гонок и атомарных операций, и честный ответ на вопрос, когда действительно нужен уход от мьютексов.

Цена одного мьютекса под нагрузкой и когда она выходит на первый план

Мьютекс в Linux построен поверх futex. Быстрый путь это одна атомарная инструкция сравнения с обменом в памяти самого объекта: если никто не спорит, захват и освобождение укладываются в десятки наносекунд и не выходят из пользовательского режима. Медленный путь начинается при конфликте: проигравший поток уходит в системный вызов и засыпает в ядре, а пробуждение измеряется микросекундами. На этой арифметике держится простое правило: один мьютекс без конфликтов почти бесплатен, мьютекс под конфликтом стоит на порядок дороже защищаемого кода.

Отсюда растут типичные болячки подхода "мьютекс для всего". Первый случай: глобальный кэш под одной блокировкой, шестнадцать потоков выстраиваются в очередь, и половина бюджета задержки уходит на ожидание. Второй: захват блокировки в горячем цикле, где тело цикла дешевле самого захвата. Третий: спин на флаге, когда поток в цикле ждёт обычный bool и сжигает процессор, потому что компилятор имеет право вынести чтение за пределы цикла.

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

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

Жизненный цикл потока C++ от запуска до join с terminate в деструкторе

Класс потока и модель памяти пришли в C++ со стандартом 2011 года, и с тех пор у объекта потока есть три состояния и жёсткое правило прощания:

std::thread t(worker, 42);
t.join();

Правило первое: у объекта есть состояние joinable, и деструктор потока, который ещё не закрыли через join или detach, вызывает std::terminate. Это не исключение, которое можно поймать рядом, процесс умирает целиком, без раскручивания стека и без попытки что-то сохранить:

{
    std::thread t(worker);   // no join here
}                           // std::terminate, process dies

Правило второе: detach это не освобождение от обязательств, а отказ от контроля. Отсоединённый поток продолжает жить, а ссылки и указатели на локальные переменные создавшей его функции становятся висячими: функция вернулась, стек перезаписан, поток читает мусор. Типичный сценарий: detach в конструкторе объекта, который умирает раньше потока. Правило третье: со стандарта C++20 есть std::jthread, который делает join сам в деструкторе и поддерживает кооперативную отмену через stop_token. Для нового кода это выбор по умолчанию, потому что он прощает забытый join.

Число аппаратных потоков подсказывает статическая функция hardware_concurrency, а решение о пуле потоков лучше принимать до первых строчек кода, а не после первого инцидента.

Состояние гонки как неопределённое поведение, а не случайный сбой

Стандарт формулирует жёстко: если два обращения к одной ячейке памяти из разных потоков конфликтуют, хотя бы одно из них запись, и ни одно не упорядочено отношением happens-before, программа имеет неопределённое поведение. Не "иногда читается старое значение", а полное отсутствие требований, со всеми оптимизациями в комплекте:

int value = 0;
bool ready = false;   // data race

void producer() {
    value = 42;
    ready = true;
}

void consumer() {
    while (!ready) { }
    int local = value;   // may read zero
}

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

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

Гонки коварны инкубационным периодом. Код годами работает на одной машине с одним компилятором, потом переезжает на новое железо, обновляется GCC, включаются агрессивные оптимизации, и оценка порядка чтений меняется. Сбой приходит не после правки самого кода, а после изменения условий вокруг него, и в отчёте об инциденте автор последнего коммита выглядит невиновным.

Ловит такое ThreadSanitizer. Сборка с -fsanitize=thread и -g замедляет программу в 5-15 раз и занимает впятеро-вдесятеро больше памяти, зато печатает обе трассы гонки с точностью до строк исходника. Инструмент находит конфликт, который ещё ни разу не выстрелил, и именно поэтому гонки ловят им в непрерывной интеграции, а не по факту инцидента.

Модель памяти C++ на живом примере публикации значения между потоками

Правильная версия публикации выглядит так:

int value = 0;
std::atomic<bool> ready{false};

void producer() {
    value = 42;
    ready.store(true, std::memory_order_release);
}

void consumer() {
    while (!ready.load(std::memory_order_acquire)) { }
    int local = value;   // guaranteed 42
}

Смысл пары release и acquire в том, что запись флага тянет за собой все записи, сделанные до неё, а удачное чтение флага открывает доступ ко всему опубликованному. Стало быть, значение 42 видно потребителю гарантированно, и это обещание стандарта, а не любезность процессора.

Порядков памяти несколько, и у каждого своя цена. seq_cst, порядок по умолчанию, даёт самую сильную гарантию, единый глобальный порядок всех атомарных операций, и оплачивается дороже прочих: на x86 каждая такая запись сопровождается полной блокировкой памяти. acquire и release достаточны для публикации. relaxed годится для счётчиков, где важен сам атомарный факт увеличения, а не порядок относительно остальной памяти:

std::atomic<uint64_t> hits{0};

void on_hit() {
    hits.fetch_add(1, std::memory_order_relaxed);
}

Есть в стандарте и порядок consume, задуманный для зависимостей по указателю, но на практике он так и не взлетел: компиляторы исполняют его как acquire, и в свежих редакциях стандарта применять его прямо не рекомендуется. Для чтения опубликованного указателя хватает acquire.

Слабые порядки не самоцель: каждый уход от seq_cst обязан быть объясним словами, какая именно гарантия нужна этому месту. Классическая ловлина этой дороге двойная проверка перед созданием объекта: без acquire на чтении она публикует наполовину построенный объект, и стреляет такое редко, на слабой архитектуре, в самый неудачный вечер.

Ложное разделение кэш-линий и выравнивание счётчиков по 64 байта

Атомарность не спасает от соседства. Типичная кэш-линия процессора это 64 байта, и если два потока на разных ядрах пишут в соседние поля одной структуры, линия постоянно мигрирует между ядрами и инвалидируется. Это называется ложным разделением, и каждое атомарное увеличение без единого конфликта начинает стоить сотни тактов на синхронизацию линии. В профиле выглядит странно: атомарный счётчик тормозит, хотя споров за него нет.

struct alignas(64) Counter {
    std::atomic<uint64_t> value{0};
};

Counter counters[8];   // each element on its own line

Выравнивание через alignas(64) разводит элементы массива по разным линиям, и счётчики разных потоков перестают спорить. На ядрах Apple линия шире и составляет 128 байт, там выравнивать надо с запасом. Симптом ловится командой perf c2c: она показывает линии, за которые дерутся ядра, с точностью до адреса.

Lock-free соблазн, проблема ABA и честная цена кода без блокировок

Основа кода без блокировок это compare_exchange: проверить, что значение не поменялось, и поменять его за одну атомарную операцию. Неудачная попытка повторяется в цикле:

std::atomic<Node*> head{nullptr};

void push(Node* n) {
    Node* cur = head.load(std::memory_order_relaxed);
    do {
        n->next = cur;
    } while (!head.compare_exchange_weak(cur, n,
             std::memory_order_release,
             std::memory_order_relaxed));
}

Даже в этих строках есть тонкость: compare_exchange_weak имеет право провалиться ложно, поэтому живёт в цикле, а не как одиночная проверка. Дальше начинается то, ради чего lock-free заслужил репутацию. Проблема ABA: поток помнит указатель на узел A, приостанавливается, другой поток успевает снять A, освободить память и выделить на том же месте новый узел B. Первый поток просыпается, видит тот же адрес и решает, что ничего не менялось. Проверка по значению проходит, список уже другой. Решения известны: тег с номером версии в свободных битах указателя, hazard-указатели, откладывающие повторное использование памяти, и схемы эпох. Каждый механизм это глава руководства, а не абзац.

Есть и скромная деталь: std::atomic над большим типом, например структурой в сотню байт, тихо превращается в обыкновенную блокировку внутри. Метод is_lock_free честно отвечает, какой ценой реализована конкретная переменная. Вывод раздела простой: код без блокировок оправдан там, где мьютекс измеримо бьёт по задержке, обычно это внутренности пулов памяти и очередей сообщений. В остальном попытка обойтись без мьютексов меняет понятную задержку на непонятную корректность.

Практический выбор между мьютексом и атомарной переменной для рабочего кода

Для нескольких блокировок есть scoped_lock из C++17, который захватывает их сразу и снимает классический дедлок из-за разного порядка захвата. Ожидание события строится на condition_variable, и вызывать wait надо с предикатом: пробуждения бывают ложными, а уведомление, отправленное до входа в ожидание, теряется безвозвратно, предикатор прикрывает оба случая. Эти два примитива закрывают добрую половину бытовой многопоточности.

Порядок выбора примитива для конкретного места кода выглядит так:

  1. по умолчанию брать мьютекс вместе с condition_variable и scoped_lock, это покрывает почти все задачи синхронизации без хрупких ручных гарантий;
  2. измерить код под реальной нагрузкой профилировщиком и убедиться, что блокировка действительно видна в отчёте, прежде чем что-то переписывать;
  3. горячие счётчики, флаги остановки и одиночные указатели заменять на атомарные переменные с минимально достаточным порядком памяти;
  4. независимые переменные разных потоков разводить по разным кэш-линиям через выравнивание и паддинг;
  5. сложные структуры вроде очередей и хэш-таблиц брать готовыми и проверенными, а не писать свою очередь на compare_exchange за один вечер;
  6. любой код с ручной синхронизацией прогонять под ThreadSanitizer в непрерывной интеграции наравне с обычными тестами.

За этой лестницей стоит простая мысль: мьютекс это не стыд, а атомики не награда за смелость. Модель памяти существует не для усложнения жизни, а чтобы у каждого acquire была причина. Если корректность места нельзя объяснить коллеге в двух предложениях, это место некорректно, и рано или поздно это докажет дежурный в три часа ночи.