Представьте, что два потока одновременно обращаются к обычной переменной, причём один из них иногда записывает значение. Достаточно ли того, что аппаратная запись этого типа выполняется одной инструкцией, чтобы избежать неопределённого поведения?
Нет. Одноинструкционная или аппаратно-атомарная запись не делает доступ к обычной переменной безопасным с точки зрения модели памяти C++. Если конфликтующие доступы неатомарны, хотя бы один из них является записью, и между ними нет отношения happens-before, возникает гонка данных, а поведение программы становится неопределённым.
Модель памяти появилась в стандарте C++11, чтобы формально описать взаимодействие потоков, компиляторов и процессоров. До этого многопоточное поведение C++ было недостаточно определено стандартом, поэтому нельзя было надёжно рассуждать о видимости обычных переменных и допустимых оптимизациях.
Модель разделяет обычные доступы к памяти и специальные средства синхронизации: атомики, мьютексы, операции создания и завершения потоков. Она позволяет компилятору оптимизировать код, но одновременно задаёт правила, при которых такие оптимизации не нарушают межпоточную корректность.
Аппарат может выполнить запись, например, в int одной машинной инструкцией. Однако это гарантирует лишь отдельное свойство конкретной архитектуры — обычно целостность отдельного доступа, но не синхронизацию потоков и не порядок наблюдения операций.
Если один поток изменяет обычный объект, а другой одновременно его читает без синхронизации, стандарт C++ не обязан сохранять интуитивное поведение. Возможны устаревшее значение, неожиданные оптимизации, перестановка операций и другие последствия неопределённого поведения.
Для гонки данных нужны одновременно три условия:
Наличие гонки данных — это не просто «поток может прочитать старое значение». Это undefined behavior: компилятор вправе рассуждать так, будто такой конфликт в корректной программе невозможен, и выполнять преобразования, которые кажутся неожиданными при наивном анализе машинных инструкций.
Аппаратная атомарность и стандартная атомарность — разные понятия. Аппаратная атомарность может означать, что значение не разорвётся на части при чтении, тогда как std::atomic дополнительно задаёт требования к межпоточному порядку, видимости и допустимым memory order.
Безопасные варианты зависят от задачи:
std::atomic<T> подходит для независимого атомарного состояния и предоставляет формально определённые memory order;join, условную переменную или другой механизм синхронизации может создать нужное отношение happens-before.Сам факт последующего join не исправляет гонку, которая произошла до него. join упорядочивает действия завершившегося потока перед действиями потока, успешно выполнившего join, но не превращает одновременные обращения к объекту в безопасные.
Минимальный пример проблемы:
Вызов join здесь корректно дожидается завершения потоков, но не предотвращает ситуацию, когда чтение и запись выполняются одновременно. Для устранения гонки value нужно сделать атомарной либо защищать оба доступа одним мьютексом; простая замена типа должна соответствовать реальной задаче, поскольку атомик не делает автоматически атомарной более крупную структуру или последовательность операций.
В сервисе один поток обновлял обычное поле состояния подключения, а другой периодически читал его для выбора маршрута. Разработчик возражал, что поле имеет тип bool, запись занимает одну инструкцию, а чтение «не может увидеть половину значения».
Рассматривались три варианта. Оставить обычный bool было дёшево, но приводило к гонке данных. Добавить мьютекс было универсально и позволяло позже защищать связанные поля, однако частые короткие чтения создавали лишние блокировки. Заменить поле на std::atomic<bool> было проще и дешевле по синхронизации, но этого было бы недостаточно, если бы состояние состояло из нескольких взаимосвязанных полей.
Выбрали атомарный флаг, поскольку требовалось публиковать только независимое состояние подключения. Для нескольких полей применили бы мьютекс или единый атомарный объект с подходящей схемой публикации. В результате исчезло неопределённое поведение, а решение осталось соответствующим фактической структуре состояния.
Нет. Гонка данных определяется правилами языка C++, а не числом машинных инструкций. Даже если процессор неделимо выполняет загрузку или сохранение, доступ через обычный объект остаётся неатомарным в терминах стандарта. Для межпоточной корректности нужен std::atomic либо синхронизация, создающая happens-before.
volatile гонку между потоками?Нет. volatile запрещает некоторые оптимизации, связанные с наблюдаемыми обращениями к объекту, но не предоставляет атомарность, межпоточную видимость или порядок памяти. Он применяется для других задач, например взаимодействия с memory-mapped I/O, а не как средство синхронизации потоков.
Нет, если чтение и запись могут пересекаться во времени и между ними нет синхронизации. Однократность записи не устраняет конфликт доступа. Безопасность появится, например, если запись завершена до запуска читающего потока, если читающий поток делает join после записи в другом корректно упорядоченном сценарии, либо если публикация выполнена через атомик, мьютекс или другой механизм синхронизации.