Concurrency: Atomic и volatile в модели памяти С++ 11

Глобальная переменная распределяется между двумя одновременными потоками на двух разных ядрах. Потоки записываются и считываются из переменных. Для атомной переменной может ли один поток читать устаревшее значение? Каждое ядро ​​может иметь значение общей переменной в своем кеше, и когда один поток записывает свою копию в кеш, другой поток на другом ядре может считывать устаревшее значение из собственного кеша. Или компилятор действительно сильно упорядочивает память для чтения последнего значения из другого кеша? Стандартная библиотека С++ 11 поддерживает std:: atomic. Как это отличается от ключевого слова volatile? Как волатильные и атомные типы будут вести себя по-разному в приведенном выше сценарии?

Ответ 1

Во-первых, volatile не подразумевает атомный доступ. Он предназначен для таких операций, как отображение ввода-вывода с памятью и обработка сигналов. volatile совершенно не требуется при использовании с std::atomic, и если ваши документы платформы не имеют отношения иначе, volatile не имеет отношения к атомному доступу или упорядочению памяти между потоками.

Если у вас есть глобальная переменная, которая разделяется между потоками, например:

std::atomic<int> ai;

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

В отсутствие какой-либо дополнительной синхронизации, если один поток записывает значение в ai, то нет ничего, что гарантирует, что другой поток увидит значение в любой заданный период времени. В стандарте указывается, что он должен быть видимым "в течение разумного периода времени", но любой доступ к нему может вернуть устаревшее значение.

Упорядочение памяти по умолчанию std::memory_order_seq_cst обеспечивает единый глобальный общий порядок для всех операций std::memory_order_seq_cst для всех переменных. Это не означает, что вы не можете получить устаревшие значения, но это означает, что значение, которое вы получаете, определяет и определяется тем, где в этом общем порядке ваша операция лежит.

Если у вас есть две общие переменные x и y, изначально нулевые и имеют один поток, напишите 1 на x, а другой напишите 2 на y, тогда третий поток, который читает оба, может видеть либо ( 0,0), (1,0), (0,2) или (1,2), поскольку между операциями не существует ограничения на упорядочение, и, следовательно, операции могут отображаться в любом порядке в глобальном порядке.

Если обе записи относятся к одному и тому же потоку, который делает x=1 до y=2, а поток чтения читает y до x, тогда (0,2) больше не является допустимым вариантом, так как чтение y==2 означает, что более ранняя запись в x видна. Остальные 3 пары (0,0), (1,0) и (1,2) все еще возможны, в зависимости от того, как 2 читает чередование с записью 2.

Если вы используете другие порядки памяти, такие как std::memory_order_relaxed или std::memory_order_acquire, тогда ограничения еще более расслабляются, и одно глобальное упорядочение больше не применяется. Нити даже не обязательно должны согласовывать порядок двух магазинов для разделения переменных, если дополнительной синхронизации не существует.

Единственный способ гарантировать, что у вас есть "последнее" значение, - использовать операцию чтения-изменения-записи, такую ​​как exchange(), compare_exchange_strong() или fetch_add(). Операции чтения-изменения-записи имеют дополнительное ограничение, что они всегда работают с "последним" значением, поэтому последовательность операций ai.fetch_add(1) серией потоков возвращает последовательность значений без дубликатов или пробелов. В отсутствие дополнительных ограничений, по-прежнему нет гарантии, какие потоки будут видеть, какие значения.

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

Ответ 2

volatile и атомарные операции имеют разный фон и были введены с другим намерением.

volatile даты с прошлого и главным образом разработаны, чтобы предотвратить оптимизацию компилятора при доступе к вводу-выводу памяти. Современные компиляторы, как правило, не более чем подавляют оптимизацию для volatile, хотя на некоторых машинах этого недостаточно даже для ввода-вывода с отображением в памяти. За исключением особого случая обработчиков сигналов и последовательностей setjmp, longjmp и getjmp (где стандарт C, а в случае сигналов - стандарт Posix дает дополнительные гарантии), его следует считать бесполезным на современном компьютере, где без специальные дополнительные инструкции (ограждения или барьеры памяти), аппаратное обеспечение может изменить порядок или даже подавить определенные доступы. Поскольку вы не должны использовать setjmp et al. в C++ это более или менее оставляет обработчики сигналов, а в многопоточной среде, по крайней мере, под Unix, есть и лучшие решения для них. И, возможно, отображение ввода-вывода в память, если вы работаете над кодом ядра и можете гарантировать, что компилятор сгенерирует все, что нужно для рассматриваемой платформы. (Согласно стандарту, volatile доступ - это наблюдаемое поведение, которое должен соблюдать компилятор. Но компилятор получает возможность определить, что подразумевается под "доступом", и большинство, похоже, определяет его как "машинная инструкция загрузки или сохранения была выполнена". Что на современном процессоре даже не означает, что на шине обязательно есть цикл чтения или записи, а тем более в том порядке, который вы ожидаете.)

С учетом этой ситуации в стандарт C++ добавлен атомарный доступ, который обеспечивает определенное количество гарантий для всех потоков; в частности, код, сгенерированный вокруг атомарного доступа, будет содержать необходимые дополнительные инструкции, чтобы предотвратить переупорядочивание доступа аппаратными средствами и гарантировать, что доступ распространяется до глобальной памяти, разделяемой между ядрами на многоядерной машине. (В какой-то момент в процессе стандартизации Microsoft предложила добавить эту семантику к volatile, и я думаю, что некоторые из их C++ компиляторов делают. Однако после обсуждения проблем в комитете общий консенсус - включая представителя Microsoft - был что лучше оставить volatile с его оригинальным значением и определить атомарные типы.) Или просто использовать примитивы системного уровня, такие как мьютексы, которые выполняют любые инструкции, необходимые в их коде. (Они должны. Вы не можете реализовать мьютекс без каких-либо гарантий относительно порядка обращений к памяти.)

Ответ 3

Летучие и атомные служат для разных целей.

Летучие: Информирует компилятор, чтобы избежать оптимизации. Это ключевое слово используется для переменных, которые будут неожиданно изменяться. Таким образом, его можно использовать для представления регистров состояния оборудования, переменных ISR, переменных, общих в многопоточном приложении.

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

Ответ 4

Вот основной краткий обзор того, что есть 2 вещи:

1) Клавиша volatile:
 Сообщает компилятору, что это значение может измениться в любой момент, и поэтому оно НЕ должно кэшировать его в регистре. Посмотрите старое ключевое слово "register" в C. "Volatile" - это в основном оператор "-" для "регистрации" "+". Современные компиляторы теперь выполняют оптимизацию, которую "register" используется для явного запроса по умолчанию, поэтому вы видите только "volatile". Использование летучего определителя гарантирует, что ваша обработка никогда не использует устаревшее значение, но не более того.

2) Atomic:
Атомные операции изменяют данные за один такт, так что любой другой поток не может получить доступ к данным в середине такого обновления. Обычно они ограничены любыми инструкциями по сборке часов, поддерживаемыми аппаратным обеспечением; такие вещи, как ++, - и замена 2 указателей. Обратите внимание, что это ничего не говорит о ORDER, что разные потоки будут запускать атомарные инструкции, только они никогда не будут работать параллельно. Вот почему у вас есть все эти дополнительные возможности для принудительного заказа.