Программирование C++МногопоточностьРазработчик C++ многопоточных систем

Представьте, что два потока одновременно обращаются к обычной переменной, причём один из них иногда записыв...

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

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

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

Исторический контекст

Модель памяти появилась в стандарте C++11, чтобы формально описать взаимодействие потоков, компиляторов и процессоров. До этого многопоточное поведение C++ было недостаточно определено стандартом, поэтому нельзя было надёжно рассуждать о видимости обычных переменных и допустимых оптимизациях.

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

Постановка проблемы

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

Если один поток изменяет обычный объект, а другой одновременно его читает без синхронизации, стандарт C++ не обязан сохранять интуитивное поведение. Возможны устаревшее значение, неожиданные оптимизации, перестановка операций и другие последствия неопределённого поведения.

Подробное решение

Для гонки данных нужны одновременно три условия:

  1. два потока выполняют конфликтующие действия над одним объектом;
  2. хотя бы одно действие является записью;
  3. действия не являются атомарными и не упорядочены отношением happens-before.

Наличие гонки данных — это не просто «поток может прочитать старое значение». Это undefined behavior: компилятор вправе рассуждать так, будто такой конфликт в корректной программе невозможен, и выполнять преобразования, которые кажутся неожиданными при наивном анализе машинных инструкций.

Аппаратная атомарность и стандартная атомарность — разные понятия. Аппаратная атомарность может означать, что значение не разорвётся на части при чтении, тогда как std::atomic дополнительно задаёт требования к межпоточному порядку, видимости и допустимым memory order.

Безопасные варианты зависят от задачи:

  • std::atomic<T> подходит для независимого атомарного состояния и предоставляет формально определённые memory order;
  • мьютекс защищает составное состояние и последовательность нескольких операций, но добавляет блокировки и риск взаимной блокировки;
  • передача данных через создание потока, join, условную переменную или другой механизм синхронизации может создать нужное отношение happens-before.

Сам факт последующего join не исправляет гонку, которая произошла до него. join упорядочивает действия завершившегося потока перед действиями потока, успешно выполнившего join, но не превращает одновременные обращения к объекту в безопасные.

Минимальный пример проблемы:

#include <thread> int value = 0; void writer() { value = 42; } void reader() { int copy = value; } int main() { std::thread a(writer); std::thread b(reader); a.join(); b.join(); }

Вызов join здесь корректно дожидается завершения потоков, но не предотвращает ситуацию, когда чтение и запись выполняются одновременно. Для устранения гонки value нужно сделать атомарной либо защищать оба доступа одним мьютексом; простая замена типа должна соответствовать реальной задаче, поскольку атомик не делает автоматически атомарной более крупную структуру или последовательность операций.

Ситуация из практики

В сервисе один поток обновлял обычное поле состояния подключения, а другой периодически читал его для выбора маршрута. Разработчик возражал, что поле имеет тип bool, запись занимает одну инструкцию, а чтение «не может увидеть половину значения».

Рассматривались три варианта. Оставить обычный bool было дёшево, но приводило к гонке данных. Добавить мьютекс было универсально и позволяло позже защищать связанные поля, однако частые короткие чтения создавали лишние блокировки. Заменить поле на std::atomic<bool> было проще и дешевле по синхронизации, но этого было бы недостаточно, если бы состояние состояло из нескольких взаимосвязанных полей.

Выбрали атомарный флаг, поскольку требовалось публиковать только независимое состояние подключения. Для нескольких полей применили бы мьютекс или единый атомарный объект с подходящей схемой публикации. В результате исчезло неопределённое поведение, а решение осталось соответствующим фактической структуре состояния.

Что кандидаты часто упускают

  1. Всегда ли атомарность аппаратной инструкции означает отсутствие гонки данных?

Нет. Гонка данных определяется правилами языка C++, а не числом машинных инструкций. Даже если процессор неделимо выполняет загрузку или сохранение, доступ через обычный объект остаётся неатомарным в терминах стандарта. Для межпоточной корректности нужен std::atomic либо синхронизация, создающая happens-before.

  1. Устраняет ли volatile гонку между потоками?

Нет. volatile запрещает некоторые оптимизации, связанные с наблюдаемыми обращениями к объекту, но не предоставляет атомарность, межпоточную видимость или порядок памяти. Он применяется для других задач, например взаимодействия с memory-mapped I/O, а не как средство синхронизации потоков.

  1. Достаточно ли читать обычную переменную, если другой поток записывает в неё только один раз?

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