Многопоточное программирование в C++ традиционно ассоциируется с мьютексами, condition variables и потенциальными проблемами вроде deadlocks и race conditions. Однако современные стандарты C++ (начиная с C++11 и далее) предоставляют инструменты для написания высокопроизводительного многопоточного кода без классических блокировок. В этой статье рассмотрим продвинутые техники: lock-free программирование, атомарные операции и различные модели упорядочивания памяти.
Зачем нужен lock-free код?
Lock-free структуры данных позволяют нескольким потокам работать с общими данными без использования мьютексов. Основные преимущества:
Масштабируемость: отсутствие блокировок означает отсутствие конкуренции за захват lock'а
Отсутствие deadlocks: нет блокировок — нет взаимных блокировок
Гарантия прогресса: хотя бы один поток всегда продвигается вперед
Однако lock-free код сложнее проектировать и отлаживать. Применяйте его только после профилирования и выявления узких мест производительности.
Атомарные операции и std::atomic
Фундамент lock-free программирования — атомарные операции. В C++11 появился тип std::atomic, который обеспечивает:
Атомарность чтения и записи
Гарантии от переупорядочивания операций компилятором и процессором
Базовый пример с атомарным счетчиком
#include <atomic>
#include <thread>
#include <iostream>
std::atomic<int> counter{0};
void incrementCounter() {
for (int i = 0; i < 100000; ++i) {
// Атомарное увеличение без использования мьютексов
counter.fetch_add(1, std::memory_order_relaxed);
}
}
int main() {
std::thread t1(incrementCounter);
std::thread t2(incrementCounter);
t1.join();
t2.join();
std::cout << "Итоговое значение: " << counter.load() << std::endl;
// Вывод: 200000 (гарантированно корректный результат)
return 0;
}
Почему это работает: операция fetch_add выполняется атомарно на уровне процессора, используя специальные инструкции (например, LOCK XADD на x86).
Memory Ordering: тонкая настройка синхронизации
C++ предоставляет шесть уровней упорядочивания памяти для атомарных операций:
memory_order_relaxed — минимальные гарантии
memory_order_consume — упорядочивание зависимых загрузок
memory_order_acquire — барьер для операций чтения
memory_order_release — барьер для операций записи
memory_order_acq_rel — комбинация acquire и release
memory_order_seq_cst — последовательная консистентность (по умолчанию)
Relaxed Ordering: максимальная производительность
#include <atomic>
#include <thread>
std::atomic<int> x{0};
std::atomic<int> y{0};
void writeValues() {
x.store(1, std::memory_order_relaxed);
y.store(1, std::memory_order_relaxed);
}
void readValues() {
// ❌ ОШИБКА: порядок записей не гарантирован!
// Поток может увидеть y=1, но x=0
while (y.load(std::memory_order_relaxed) != 1) {
// Busy wait
}
// Не гарантируется, что x == 1 в этой точке!
int value_x = x.load(std::memory_order_relaxed);
}
Когда использовать relaxed: только для независимых операций, например, счетчиков без зависимостей.
Release-Acquire: синхронизация между потоками
#include <atomic>
#include <thread>
#include <cassert>
std::atomic<bool> ready{false};
int data = 0;
void producer() {
data = 42; // (1) Обычная запись
// Release гарантирует, что все операции до него видны другим потокам
ready.store(true, std::memory_order_release); // (2)
}
void consumer() {
// Acquire гарантирует, что все операции после release видны
while (!ready.load(std::memory_order_acquire)) { // (3)
// Busy wait
}
// (4) Гарантируется: data == 42
assert(data == 42); // Всегда выполнится корректно
}
int main() {
std::thread t1(producer);
std::thread t2(consumer);
t1.join();
t2.join();
return 0;
}
Как это работает:
memory_order_releaseв producer создает "барьер": все операции доstoreне могут быть переупорядочены после негоmemory_order_acquireв consumer создает барьер: все операции послеloadне могут быть переупорядочены до негоЭто создает отношение "happens-before" между потоками
Sequential Consistency: самые строгие гарантии
#include <atomic>
#include <thread>
std::atomic<bool> x{false};
std::atomic<bool> y{false};
std::atomic<int> z{0};
void write_x() {
x.store(true, std::memory_order_seq_cst);
}
void write_y() {
y.store(true, std::memory_order_seq_cst);
}
void read_xy() {
while (!x.load(std::memory_order_seq_cst)) { }
if (y.load(std::memory_order_seq_cst)) {
++z;
}
}
void read_yx() {
while (!y.load(std::memory_order_seq_cst)) { }
if (x.load(std::memory_order_seq_cst)) {
++z;
}
}
int main() {
std::thread t1(write_x);
std::thread t2(write_y);
std::thread t3(read_xy);
std::thread t4(read_yx);
t1.join(); t2.join(); t3.join(); t4.join();
// z гарантированно будет > 0
// С seq_cst существует глобальный порядок всех операций
return 0;
}
Когда использовать seq_cst: когда нужна максимальная простота рассуждения о коде, либо для отладки. Это самый медленный режим.
Lock-Free Stack: практический пример
Рассмотрим классический пример lock-free структуры — стек с операциями push и pop.
#include <atomic>
#include <memory>
template<typename T>
class LockFreeStack {
private:
struct Node {
T data;
Node* next;
Node(const T& value) : data(value), next(nullptr) {}
};
std::atomic<Node*> head;
public:
LockFreeStack() : head(nullptr) {}
void push(const T& value) {
Node* newNode = new Node(value);
// Оптимистичный цикл retry
newNode->next = head.load(std::memory_order_relaxed);
// CAS (Compare-And-Swap): атомарно проверяем и обновляем
while (!head.compare_exchange_weak(
newNode->next, // Ожидаемое значение
newNode, // Новое значение
std::memory_order_release, // Успех
std::memory_order_relaxed // Неудача
)) {
// Если CAS провалился, newNode->next был автоматически
// обновлен текущим значением head, повторяем попытку
}
}
bool pop(T& result) {
Node* oldHead = head.load(std::memory_order_relaxed);
do {
if (oldHead == nullptr) {
return false; // Стек пуст
}
// Пытаемся атомарно переместить head на следующий элемент
} while (!head.compare_exchange_weak(
oldHead,
oldHead->next,
std::memory_order_acquire,
std::memory_order_relaxed
));
result = oldHead->data;
// ⚠️ ПРОБЛЕМА: освобождение памяти небезопасно!
// Другой поток может все еще читать oldHead
// delete oldHead; // Потенциальная ошибка use-after-free
return true;
}
~LockFreeStack() {
T dummy;
while (pop(dummy)) { }
}
};
Типичные ошибки и Best Practices
❌ Ошибка 1: Проблема ABA
// Поток 1 читает head = A
Node* oldHead = head.load(); // oldHead = A
// Поток 2: pop(A), pop(B), push(A) - теперь head снова = A
// Но это ДРУГОЙ объект A!
// Поток 1: CAS успешен, хотя head изменялся!
head.compare_exchange_weak(oldHead, oldHead->next); // ⚠️ Проблема ABA
✅ Решение: использовать tagged pointers или hazard pointers для управления памятью.
❌ Ошибка 2: Неправильный memory order
/ ❌ НЕПРАВИЛЬНО: использование relaxed для CAS без синхронизации
void push_wrong(const T& value) {
Node* newNode = new Node(value);
newNode->next = head.load(std::memory_order_relaxed);
while (!head.compare_exchange_weak(
newNode->next,
newNode,
std::memory_order_relaxed, // ❌ Опасно!
std::memory_order_relaxed
)) { }
}
// ✅ ПРАВИЛЬНО: release при успехе для синхронизации
void push_correct(const T& value) {
Node* newNode = new Node(value);
newNode->next = head.load(std::memory_order_relaxed);
while (!head.compare_exchange_weak(
newNode->next,
newNode,
std::memory_order_release, // ✅ Синхронизирует с другими потоками
std::memory_order_relaxed
)) { }
}
❌ Ошибка 3: Race condition при освобождении памяти
bool pop_unsafe(T& result) {
Node* oldHead = head.load();
while (oldHead && !head.compare_exchange_weak(oldHead, oldHead->next)) { }
if (oldHead) {
result = oldHead->data;
delete oldHead; // ❌ Другой поток может читать oldHead!
return true;
}
return false;
}
✅ Решение: использовать техники безопасного управления памятью:
Reference counting с
std::shared_ptrHazard pointers
Epoch-based reclamation
Отложенное удаление (RCU)
Lock-Free Queue: Producer-Consumer Pattern
SPSC (Single-Producer Single-Consumer) очередь — оптимальный случай для lock-free алгоритмов.
#include <atomic>
#include <array>
template<typename T, size_t Size>
class SPSCQueue {
private:
std::array<T, Size> buffer;
std::atomic<size_t> writePos{0};
std::atomic<size_t> readPos{0};
public:
bool push(const T& value) {
size_t currentWrite = writePos.load(std::memory_order_relaxed);
size_t nextWrite = (currentWrite + 1) % Size;
// Проверка на полноту очереди
if (nextWrite == readPos.load(std::memory_order_acquire)) {
return false; // Очередь полна
}
buffer[currentWrite] = value;
// Release гарантирует, что запись в buffer видна consumer'у
writePos.store(nextWrite, std::memory_order_release);
return true;
}
bool pop(T& result) {
size_t currentRead = readPos.load(std::memory_order_relaxed);
// Проверка на пустоту очереди
if (currentRead == writePos.load(std::memory_order_acquire)) {
return false; // Очередь пуста
}
result = buffer[currentRead];
// Release синхронизирует с producer
readPos.store((currentRead + 1) % Size, std::memory_order_release);
return true;
}
};
Почему это эффективно:
memory_order_relaxedдля локальных операций (минимальные издержки)memory_order_acquireпри чтении позиции другого потокаmemory_order_releaseпри публикации изменений
Производительность: когда lock-free оправдан?
#include <atomic>
#include <mutex>
#include <chrono>
#include <iostream>
// Версия с мьютексом
class CounterWithMutex {
int value = 0;
std::mutex mtx;
public:
void increment() {
std::lock_guard<std::mutex> lock(mtx);
++value;
}
int get() {
std::lock_guard<std::mutex> lock(mtx);
return value;
}
};
// Lock-free версия
class CounterLockFree {
std::atomic<int> value{0};
public:
void increment() {
value.fetch_add(1, std::memory_order_relaxed);
}
int get() {
return value.load(std::memory_order_relaxed);
}
};
// Бенчмарк
template<typename Counter>
void benchmark(const std::string& name) {
Counter counter;
auto start = std::chrono::high_resolution_clock::now();
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back([&counter]() {
for (int j = 0; j < 1000000; ++j) {
counter.increment();
}
});
}
for (auto& t : threads) {
t.join();
}
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout << name << ": " << duration.count() << " ms" << std::endl;
}
int main() {
benchmark<CounterWithMutex>("Mutex"); // ~150-300 ms
benchmark<CounterLockFree>("Lock-free"); // ~50-100 ms
return 0;
}
Результаты показывают: lock-free код может быть в 2-3 раза быстрее при высокой конкуренции.
Практические рекомендации
1. Начинайте с профилирования
// ✅ Правильный подход
// 1. Реализуйте с мьютексами
// 2. Профилируйте (perf, Valgrind, Intel VTune)
// 3. Оптимизируйте узкие места lock-free техниками
2. Используйте существующие библиотеки
#include <boost/lockfree/queue.hpp>
// Вместо написания своей очереди
boost::lockfree::queue<int> queue(128);
// Producer
queue.push(42);
// Consumer
int value;
if (queue.pop(value)) {
// Обработка value
}
3. Документируйте memory ordering
// ✅ Хорошо: явные комментарии о синхронизации
void publish_data() {
compute_data(); // (1)
ready.store(true, std::memory_order_release); // (2)
// Release: гарантирует видимость (1) после acquire в reader
}
void consume_data() {
while (!ready.load(std::memory_order_acquire)) { } // (3)
// Acquire: синхронизируется с (2), видит результаты (1)
process_data(); // (4)
}
4. Тестируйте с Thread Sanitizer
# Компиляция с ThreadSanitizer
g++ -fsanitize=thread -g program.cpp -o program
# Запуск
./program
ThreadSanitizer обнаруживает:
Data races
Использование неинициализированных атомарных переменных
Неправильное использование memory order
Заключение
Lock-free программирование в C++ — мощный инструмент для создания высокопроизводительных многопоточных приложений. Ключевые выводы:
Атомарные операции (
std::atomic) — фундамент lock-free кодаMemory ordering позволяет точно контролировать синхронизацию и производительность
Compare-And-Swap (CAS) — основная операция для lock-free структур данных
Типичные ошибки: ABA проблема, неправильный memory order, небезопасное управление памятью
Best practice: профилируйте перед оптимизацией, используйте проверенные библиотеки, тщательно документируйте
Lock-free код сложен, но при правильном применении дает значительный прирост производительности в высоконагруженных системах.
Полезные ресурсы:
"C++ Concurrency in Action" (Anthony Williams)
cppreference.com: std::atomic, std::memory_order
Herb Sutter: "atomic<> Weapons" (CppCon talks)
Boost.Lockfree documentation
Комментарии (5)

Dinisoid
06.11.2025 16:34Похоже люди минусуют, но не обьясняют причины, оставлю вам конструктивные улучшения:
1. У вас микс из понятий, статья читается тяжело, многое упущено из обьяснения (можно было оставить ссылки на полезные материалы для ознакомления)2.Запутывающее определение для Memory Ordering, Memory Ordering не является барьером. Само понятие барьера обозначает совершенно другое, существует два вида барьеров.
На уровне CPU - барьеры MFENCE, LFENCE, SFENCE, открываете AMD64 Architecture Programmer's Manual Volume 3: General Purpose and System Programming Instructions (PUB) (24594), находите mfence на 237 странице, lfence на 231 странице, sfence на 338 странице. В ARM архитектуре это инструкции dmb, dsb, isb
https://developer.arm.com/documentation/dui0802/b/A32-and-T32-Instructions/DMB--DSB--and-ISB?lang=enНа уровне компиляции барьеры нужны для предотвращения оптимизаций компилятора по доступу к памяти, например _ReadWriteBarrier у MSVC или asm volatile("": : :"memory") в GCC и Clang. Пример кода который вы можете исследовать и понять, что такое барьер на уровне компиляции прилагаю ниже.
// Компилируем с O3, смотрим в сгенерированный ассемблерный код. #include <cstdint> #include <cstdio> uint8_t a[256] = {}; __attribute__((noinline)) void f() { for (int i = 0; i < 256; ++i) { asm volatile("" ::: "memory"); // <--- a[i]++; } } int main() { f(); uint8_t sum = 0; for (int i = 0; i < 256; ++i) sum += a[i]; printf("sum = %d\n", sum); }3. Следующее вводит читателя в заблуждение: "Когда использовать seq_cst: когда нужна максимальная простота рассуждения о коде, либо для отладки. Это самый медленный режим."
Правильнее: "каждый поток видит все атомарные операции с seq_cst в одном и том же порядке".
Если вы используете seq_cst для "отладки" это скорее значит, что ваш код запутан и вы сами не можете понять как он работает, либо фундаментально не понимаете для чего нужен Memory Ordering.
4. Вот это утверждение на мой взгляд неверное: "Memory ordering позволяет точно контролировать синхронизацию и производительность"Правильнее было бы: "Ограничивает видимость операций и переупорядочивание между потоками, что в свою очередь влияет на производительность."

cear Автор
06.11.2025 16:34Согласен со всеми четырьмя пунктами: 1. Структура и переходы можно улучшить. 2. Memory ordering ≠ барьер — это критическое различие, спасибо за примеры ARM/AMD. 3. seq_cst не инструмент отладки — это фундаментальная гарантия. 4. "Ограничивает" — правильный глагол, не "контролирует".

ZirakZigil
06.11.2025 16:34Минусуют потому, что тут нейронка чувствуется что в статье, что в комментах автора.
Mingun
Различных вариантов комбинации memory_order 6x6 = 36, а рассмотрено почему-то всего 3. Непонятно, зачем было делать это ордеры параметрами методов, когда они, похоже, к типу должны быть привязаны?
cear Автор
Спасибо за замечание. Вы правы, что существует 36 возможных комбинаций. Однако в статье я сознательно сосредоточился на трёх наиболее практичных сценариях для введения в тему. Валидных и полезных комбинаций значительно меньше, так как многие нарушают семантику операций (например, acquire на store). Для полного справочника действительно нужен отдельный материал. Согласен, что вопрос о привязке к типам — интересный дизайн-вопрос, но текущий API предоставляет гибкость, которая часто необходима.