В некоторых ответах / комментариях предлагается спать в писателе. Это не полезно; забить на строку кэша, меняя ее как можно чаще - это то, что вы хотите. (И что вы получаете с volatile
назначениями и чтениями.) Назначение будет порвано, когда запрос общего ресурса MESI для строки кэша поступит в ядро устройства записи между фиксацией двух половин хранилища из буфера хранилища в кэш L1d.
Если вы спите, вы долго ждете, не создавая окна для этого. Спящий между половинками сделает его еще более легким для обнаружения, но вы не сможете сделать это, если не используете отдельный memcpy
для записи половин 64-битного целого или чего-то еще.
Разрыв между чтениями в считывателе также возможен, даже если записи атоми c. Это может быть менее вероятно, но на практике все еще бывает достаточно. Современные процессоры x86 могут выполнять две загрузки за такт (Intel начиная с Sandybridge, AMD начиная с K8). Я тестировал с атомными c 64-битными хранилищами, но разбил 32-битные загрузки на Skylake, и разрывы все еще достаточно часты, чтобы извергать строки текста в терминале. Таким образом, ЦПУ не удалось запустить все в режиме блокировки с соответствующими парами чтений, всегда выполняющимися в одном и том же тактовом цикле. Таким образом, есть окно для считывателя, делающего его строку кэша недействительной между парой нагрузок. (Однако все ожидающие загрузки с отсутствием кэша, пока строка кэша принадлежит ядру модуля записи, вероятно, завершают все сразу, когда появляется строка кэша. И общее число доступных буферов загрузки является четным числом в существующих микроархитектурах.)
Как вы обнаружили, ваши тестовые значения имели одинаковую верхнюю половину 0
, поэтому это сделало невозможным наблюдение каких-либо разрывов; изменялась только 32-битная выровненная младшая половина, и она изменялась атомарно, потому что ваш компилятор гарантирует как минимум 4-байтовое выравнивание для uint64_t, а x86 гарантирует, что 4-байтовые выровненные загрузки / хранилища имеют атомы c.
0
и -1ULL
являются очевидным выбором. Я использовал то же самое в тестовом примере для этого G CC C11 _Atomi c ошибка для 64-битной структуры.
Для вашего случая я бы сделал это , read()
и write()
являются именами системных вызовов POSIX, поэтому я выбрал другое.
#include <cstdint>
volatile uint64_t sharedValue = 0; // initializer = one of the 2 values!
void writer() {
for (;;) {
sharedValue = 0;
sharedValue = -1ULL; // unrolling is vastly simpler than an if
}
}
void reader() {
for (;;) {
uint64_t val = sharedValue;
uint32_t low = val, high = val>>32;
if (low != high) {
std::cout << "Tearing! Value: " << std::hex << val << '\n';
}
}
}
MSV C 19.24 -O2 компилирует программу записи с использованием movlpd
64-битного хранилища для = 0, но два отдельных 32-разрядных хранилища -1
для = -1
. (И считыватель для двух отдельных 32-битных загрузок). G CC использует в записывающем устройстве в общей сложности четыре магазина mov dword ptr [mem], imm32
, как и следовало ожидать. ( Исследователь компилятора Godbolt )
Терминология : это всегда состояние гонки (даже с атомарностью вы этого не делаете знаю, какое из двух значений вы собираетесь получить). С std::atomic<>
у вас будет только то состояние гонки сорта сада, без неопределенного поведения.
Вопрос в том, действительно ли вы видите разрыв с неопределенной поведением гонки данных на volatile
объект, на определенной c реализации C ++ / наборе параметров компиляции, для конкретной c платформы. Data race UB - это технический термин, имеющий более конкретное значение c, чем "условие гонки" . Я изменил сообщение об ошибке, чтобы сообщить об одном признаке, который мы проверяем. Обратите внимание, что гонка данных UB на объекте, отличном от volatile
, может иметь более странные эффекты, такие как размещение нагрузки или сохранение вне циклов, или даже создание дополнительных операций чтения, приводящих к коду, который считает, что одно чтение было одновременно истинным и ложным время. (https://lwn.net/Articles/793253/)
Я удалил 2 избыточных cout
сброса : один из std::endl
и один из std::flush
. cout является буферизованным по умолчанию или полностью буферизованным, если записываете в файл, что нормально. И '\n'
столь же переносим, как и std::endl
, что касается концов линии DOS; текст против двоичного режима потока обрабатывает это. endl по-прежнему просто \n
.
Я упростила вашу проверку на разрыв, проверив, что high_half == low_half . Тогда компилятору просто нужно выдать один cmp / j cc вместо двух сравнений с расширенной точностью, чтобы увидеть, равно ли это значение 0 или -1. Мы знаем, что нет вероятного способа, чтобы ложные негативы, такие как high = low = 0xff00ff00
, происходили на x86 (или любом другом стандартном ISA с любым здравомыслящим компилятором).
Так что я думаю, что на x86 нет необходимости использовать std :: atomi c для 32-битных типов данных?
Неправильно .
Ручная прокрутка с volatile int
может не дает вам операций Atom c RMW (без встроенного ассемблера или специальных функций, таких как Windows InterlockedIncrement
или GNU C встроенный __atomic_fetch_add
), и не может дать вам никаких гарантий заказа в отношении. другой код. (Освободить / приобрести семантику)
Когда использовать volatile с многопоточностью? - почти никогда.
Свертывание собственной атомики с помощью volatile
- это все еще возможно и де-факто поддерживается многими основными компиляторами (например, ядро Linux все еще делает это, наряду со встроенным asm). Реальные компиляторы действительно определяют поведение гонок данных на volatile
объектах. Но обычно это плохая идея, когда есть портативный и гарантированный безопасный способ. Просто используйте std::atomic<T>
с std::memory_order_relaxed
, чтобы получить asm, который так же эффективен, как то, что вы могли бы получить с volatile
(для случаев, когда volatile
работает), но с гарантиями безопасности и корректности из стандарта ISO C ++.
atomic<T>
также позволяет вам спросить реализацию, может ли данный тип быть дешевым атомом c или нет, с C ++ 17 std::atomic<T>::is_always_lock_free
или более старой функцией-членом. (На практике реализации C ++ 11 решили не позволять некоторым, но не всем экземплярам любого данного атома c быть свободными от блокировки на основе выравнивания или чего-то еще; вместо этого они просто дают атому c требуемые выравнивания, если таковые имеются. C ++ 17 сделал постоянную константу для каждого типа вместо функции-члена для каждого объекта, чтобы проверить свободу блокировки).
std::atomic
также может дать дешевую атомарность без блокировки для типов шире, чем нормальный регистр . например, на ARM, используя ARMv6 strd
/ ldrd
для хранения / загрузки пары регистров.
На 32-битном x86 хороший компилятор может реализовать std::atomic<uint64_t>
, используя SSE2 movq
для выполнения Atomi c 64-битные загрузки и сохранения без возврата к механизму non-lock_free (таблица блокировок). На практике G CC и clang9 используют movq
для atomic<uint64_t>
load / store . clang8.0 и более ранние версии, к сожалению, lock cmpxchg8b
. MSV C использует lock cmpxchg8b
еще более неэффективно. Измените определение sharedVariable в ссылке Godbolt, чтобы увидеть его. (Или если вы используете одно из значений по умолчанию seq_cst и memory_order_relaxed
в l oop, MSV C по какой-то причине вызывает вспомогательную функцию ?store@?$_Atomic_storage@_K$07@std@@QAEX_KW4memory_order@2@@Z
для одного из них. Но когда оба хранилища имеют одинаковый порядок, он встраивает блокировку cmpxchg8b с гораздо более грубыми циклами, чем clang8.0) Обратите внимание, что этот неэффективный код MSV C предназначен для случая, когда volatile
не был атомарным; в случаях, когда это так, atomic<T>
с mo_relaxed
тоже хорошо компилируется.
Обычно вы не можете получить этот широко-атомный c код-ген из volatile
. Хотя G CC действительно использует movq для вашей функции записи if () bool (см. Более раннюю ссылку на проводник компилятора Godbolt), потому что он не может видеть переменную или что-то еще. Это также зависит от того, какие значения вы используете. С 0 и -1 он использует отдельные 32-битные хранилища, но с 0 и 0x0f0f0f0f0f0f0f0fULL
вы получаете movq для пригодного для использования шаблона. (Я использовал это, чтобы убедиться, что вы все еще можете получить разрыв только со стороны чтения, вместо того, чтобы писать что-нибудь вручную.) Моя простая развернутая версия компилируется для использования простых mov dword [mem], imm32
хранилищ с G CC. Это хороший пример нулевой гарантии того, как volatile
действительно компилируется на этом уровне детализации.
atomic<uint64_t>
также гарантирует 8-байтовое выравнивание для объекта atomi c, даже если обычный uint64_t
может быть выровнен только на 4 байта.
В ISO C ++ гонка данных на объекте volatile
по-прежнему не определена. (За исключением volatile sig_atomic_t
гонок с обработчиком сигнала.)
«Гонка данных» - это каждый раз, когда происходит два несинхронизированных доступа, и они не оба считываются. ISO C ++ допускает возможность запуска на машинах с аппаратным определением гонки или чем-то подобным; на практике ни одна из основных систем не делает этого, поэтому результат просто разрывается, если летучий объект не «естественно атоми c».
ISO C ++ также теоретически позволяет работать на машинах, которые не имеют связного разделяемая память и требует ручного сброса после хранения atomi c, но на практике это не реально. Реальные реализации не такие, AFAIK. Системы с ядрами, которые имеют некогерентную разделяемую память (например, некоторые ARM SoC с ядрами DSP + ядра микроконтроллера) не запускают std :: thread через эти ядра.
См. Также Почему целочисленное назначение включено естественно выровненная переменная atomi c на x86?
Это все еще UB, даже если вы не наблюдаете разрывы на практике, хотя, как я сказал, настоящие компиляторы де-факто определить поведение volatile.
Эксперименты Skylake, чтобы попытаться обнаружить объединение буфера хранилища
Интересно, может ли объединение хранилища в буфере хранилища создать атоми c 64- передача битов в кэш L1d из двух отдельных 32-битных хранилищ. (Пока никаких полезных результатов, оставляя это здесь, на случай, если кто-то заинтересован или хочет его использовать.)
Я использовал встроенную для читателя GNU C __atomi c, поэтому, если магазины также закончились Будучи атомом c мы бы не увидели разрывов.
void reader() {
for (;;) {
uint64_t val = __atomic_load_n(&sharedValue, __ATOMIC_ACQUIRE);
uint32_t low = val, high = val>>32;
if (low != high) {
std::cout << "Tearing! Value: " << std::hex << val << '\n';
}
}
}
Это была одна попытка заставить микроархитектуру сгруппировать магазины.
void writer() {
volatile int separator; // in a different cache line, has to commit separately
for (;;) {
sharedValue = 0;
_mm_mfence();
separator = 1234;
_mm_mfence();
sharedValue = -1ULL; // unrolling is vastly simpler than an if
_mm_mfence();
separator = 1234;
_mm_mfence();
}
}
Я все еще вижу разрывы с этим. (mfence
в Skylake с обновленным микрокодом аналогичен lfence
и блокирует неиспользуемый exe c, а также очищает буфер хранилища. Поэтому более поздние хранилища даже не должны входить в буфер хранилища до того, как последующие покинут Это может быть проблемой, потому что нам нужно время для слияния, а не только для фиксации 32-битного хранилища, как только оно «завершится», когда магазин выйдет из эксплуатации). Оцените разрывов и посмотрите, реже ли это с чем-либо, потому что разрывов вообще достаточно, чтобы спамить окно терминала с текстом на машине с частотой 4 ГГц.