Перейти к содержанию

Атомарные операции и модель памяти

Начало урока

Атомарные операции и модель памяти

Как разделять данные между потоками без блокировок и делать это правильно

Главная идея: Когда два потока обращаются к одной переменной и хотя бы один из них пишет, обычное чтение/запись образует гонку данных, а гонка данных в C++ - это неопределённое поведение, а не просто неверное значение. std::atomic<T> даёт операции, которые неделимы (ни один поток не увидит наполовину записанное значение) и, что не менее важно, позволяет управлять порядком, в котором записи одного потока становятся видимыми другому. Тема о обеих половинах: об атомарности и о модели памяти, которая определяет слово "видимо".
К концу вы сможете:
  • точно сформулировать, что такое гонка данных и почему это неопределённое поведение;
  • использовать std::atomic<T> - load, store, exchange, fetch_add, compare_exchange;
  • объяснить отношение happens-before и то, как оно делает запись видимой;
  • на уровне интуиции рассуждать о моделях порядка seq_cst, acquire/release и relaxed.

1. Гонка данных - это неопределённое поведение

Гонка данных возникает, когда два потока обращаются к одной ячейке памяти одновременно, хотя бы одно обращение является записью, и эти обращения не упорядочены никакой синхронизацией. Стандарт C++ говорит, что программа с гонкой данных имеет неопределённое поведение (UB). Это сильнее, чем "можно прочитать устаревшее значение": компилятору и процессору разрешено считать, что гонок не бывает, поэтому гонка может дать "рваное" значение, потерянное обновление или оптимизацию кода способом, лишённым последовательного смысла.

int counter = 0;                 // обычный int, общий

void worker() {
    for (int i = 0; i < 100000; ++i)
        ++counter;               // чтение-изменение-запись, НЕ атомарно -> ГОНКА
}
// Два потока, выполняющих worker(), гоняются за counter.
// Итоговое значение непредсказуемо, а программа - UB.

++counter выглядит как один шаг, но на деле это три: загрузка, прибавление единицы, запись. Два потока могут оба загрузить одно значение, оба прибавить единицу и оба записать - так два инкремента превращаются в один. Оставить counter обычным int без защиты - это не просто риск неверной суммы; это гонка, а гонки - это UB.

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

Продолжите этот урок на YourAIZone

В полном уроке — объяснение целиком, разобранные примеры, частые ошибки, тесты с мгновенной проверкой и ИИ-репетитор. Начните сразу как гость или создайте аккаунт, чтобы сохранять прогресс.

В полном уроке

  • Объяснение целиком
  • Разобранные примеры
  • Частые ошибки и как их избежать
  • Тестовые вопросы: 15
  • ИИ-репетитор, который отвечает на вопросы по теме
  • Отслеживание прогресса для ученика, родителей и учителя