В чём разница между mutex и атомарными переменными (std::atomic) в C++?

«В чём разница между mutex и атомарными переменными (std::atomic) в C++?» — вопрос из категории Многопоточность, который задают на 25% собеседований C/C++ Разработчик. Ниже — развёрнутый ответ с разбором ключевых моментов.

Ответ

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

std::atomic<T> — это шаблонный тип, обеспечивающий безблокирующую (lock-free или wait-free) синхронизацию для отдельных операций над переменной. Атомарные операции гарантированно выполняются как единое целое с точки зрения других потоков.

Ключевые отличия:

  1. Гранулярность: Mutex защищает произвольный блок кода (критическую секцию). std::atomic защищает доступ к одной конкретной переменной.
  2. Производительность: Атомарные операции, как правило, значительно быстрее, так как реализуются с помощью атомарных инструкций процессора (например, LOCK CMPXCHG на x86) и не требуют переключения контекста. Mutex involves system calls and context switches when contention occurs.
  3. Применимость: Mutex можно использовать для синхронизации доступа к сложным структурам данных или для выполнения последовательности операций (транзакции). std::atomic подходит для простых операций: чтение/запись, инкремент, сравнение с обменом (CAS).

Пример: потокобезопасный счётчик.

С использованием std::mutex (избыточно для одной операции):

#include <mutex>

class CounterMutex {
    int value = 0;
    mutable std::mutex mtx;
public:
    void increment() {
        std::lock_guard<std::mutex> lock(mtx); // Блокировка
        ++value; // Неатомарная операция под защитой мьютекса
    } // Автоматическая разблокировка
    int get() const {
        std::lock_guard<std::mutex> lock(mtx);
        return value;
    }
};

С использованием std::atomic (оптимально):

#include <atomic>

class CounterAtomic {
    std::atomic<int> value{0};
public:
    void increment() {
        // Атомарная операция fetch_add. Никаких блокировок.
        value.fetch_add(1, std::memory_order_relaxed);
    }
    int get() const {
        // Атомарное чтение.
        return value.load(std::memory_order_acquire);
    }
};

Пример, где mutex необходим, а atomic недостаточен:

// Нужно атомарно увеличить два счётчика.
// С atomic это две отдельные операции, между которыми другой поток может вмешаться.
std::atomic<int> cnt1{0}, cnt2{0};
// НЕПРАВИЛЬНО: состояние (cnt1, cnt2) может быть несогласованным для внешнего наблюдателя.
cnt1++;
cnt2++; 

// ПРАВИЛЬНО: используем mutex для атомарности составной операции.
std::mutex mtx;
mtx.lock();
cnt1++;
cnt2++;
mtx.unlock();

Правило выбора: Используйте std::atomic для простых операций над одиночными переменными (флаги, счётчики, указатели). Используйте std::mutex (или другие мьютексы) когда нужно защитить доступ к нескольким переменным одновременно или выполнить последовательность операций как единое целое.