Это абсолютно то, что C ++ определяет как гонку данных, которая вызывает неопределенное поведение, даже если один компилятор произвел код, который сделал то, что вы надеялись на какой-то целевой машине. Вы должны использовать std::atomic
для надежных результатов, но вы можете использовать его сmemory_order_relaxed
если вы не заботитесь о переупорядочении. Ниже приведен пример использования кода и вывода asm fetch_add
.
Но сначала вопрос языка ассемблера:
Поскольку num ++ - это одна инструкция (add dword [num], 1
), можем ли мы заключить, что num ++ является атомарным в этом случае?
Инструкции назначения памяти (кроме чистых хранилищ) являются операциями чтения-изменения-записи, которые выполняются в несколько внутренних шагов . Ни один архитектурный регистр не изменен, но ЦПУ должен хранить данные внутри, пока он отправляет их через свой АЛУ . Фактический регистровый файл - это лишь небольшая часть хранилища данных даже в самом простом ЦП, с защелками, содержащими выходы одной ступени в качестве входных данных для другой ступени и т. Д. И т. Д.
Операции с памятью из других процессоров могут стать глобально видимыми между загрузкой и хранением. Т.е. два потока, работающие add dword [num], 1
в цикле, будут наступать на хранилища друг друга. (См . Ответ @ Маргарет для хорошей диаграммы). После увеличения на 40 Кбайт от каждого из двух потоков счетчик мог бы увеличиться только на ~ 60 Кб (не 80 Кб) на реальном многоядерном оборудовании x86.
«Атомный», от греческого слова, означающего неделимый, означает, что ни один наблюдатель не может видеть операцию как отдельные шаги. Одновременное физическое / электрическое мгновение для всех битов - это всего лишь один из способов достижения этого для нагрузки или хранилища, но это даже невозможно для операции ALU. В своем ответе на Atomicity на x86 я подробно рассказал о чистых загрузках и чистых хранилищах, в то время как этот ответ сфокусирован на чтении-изменении-записи.
lock
Префикс может быть применен ко многим чтение-модификация-запись (назначения памяти) инструкции , чтобы вся операция атомных по отношению ко всем возможным наблюдателям в системе (других ядер и устройств DMA, а не осциллограф подключен к выводам процессора). Вот почему он существует. (Смотри также этот Q & A ).
Так lock add dword [num], 1
это атомное . Ядро ЦП, выполняющее эту инструкцию, будет сохранять строку кэша в состоянии Modified в своем частном кэше L1 с момента, когда нагрузка считывает данные из кэша, до тех пор, пока хранилище не отправит свой результат обратно в кэш. Это препятствует тому, чтобы любой другой кеш в системе имел копию строки кеша в любой точке от загрузки к хранилищу, согласно правилам протокола когерентности кеша MESI (или его версиями MOESI / MESIF, используемыми многоядерными AMD / Процессоры Intel соответственно). Таким образом, операции с другими ядрами происходят либо до, либо после, а не во время.
Без lock
префикса другое ядро могло бы взять на себя ответственность за строку кэша и изменить ее после нашей загрузки, но до нашего хранилища, чтобы другое хранилище стало глобально видимым между нашей загрузкой и хранилищем. Несколько других ответов ошибаются и утверждают, что безlock
вы получите конфликтующие копии одной и той же строки кэша. Это никогда не может происходить в системе с последовательным кэшем.
(Если lock
инструкция ed работает с памятью, занимающей две строки кэша, требуется гораздо больше работы, чтобы убедиться, что изменения в обеих частях объекта остаются атомарными, поскольку они распространяются на всех наблюдателей, поэтому ни один наблюдатель не может увидеть разрыв. Процессор может необходимо заблокировать всю шину памяти, пока данные не попадут в память. Не выравнивайте атомарные переменные!)
Обратите внимание, что lock
префикс также превращает инструкцию в полный барьер памяти (например, MFENCE ), останавливая все переупорядочения во время выполнения и, таким образом, обеспечивая последовательную согласованность. (См . Отличный пост в блоге Джеффа Прешинга . Его остальные посты тоже превосходны и ясно объясняют много хороших вещей о программировании без блокировок , от x86 и других деталей оборудования до правил C ++.)
На однопроцессорной машине или в однопоточном процессе одна инструкция RMW фактически является атомарной без lock
префикса. Единственный способ получить доступ к общей переменной для другого кода - это переключение контекста процессором, что не может произойти в середине инструкции. Таким образом, равнина dec dword [num]
может синхронизироваться между однопоточной программой и ее обработчиками сигналов, или в многопоточной программе, работающей на одноядерном компьютере. Смотрите вторую половину моего ответа на другой вопрос и комментарии под ним, где я объясню это более подробно.
Вернуться к C ++:
Использовать num++
не нужно, не сообщая компилятору о том, что он нужен для компиляции в одну реализацию чтения-изменения-записи:
;; Valid compiler output for num++
mov eax, [num]
inc eax
mov [num], eax
Это очень вероятно, если вы используете значение num
позже: компилятор сохранит его в регистре после приращения. Так что даже если вы проверите, как num++
компилируется сам по себе, изменение окружающего кода может повлиять на него.
(Если значение не требуется позже, inc dword [num]
предпочтительнее; современные x86-процессоры будут выполнять инструкцию RMW назначения памяти по крайней мере так же эффективно, как и три отдельные инструкции. Интересный факт: на gcc -O3 -m32 -mtune=i586
самом деле это произойдет , потому что суперскалярный конвейер (Pentium) P5 не Не декодируйте сложные инструкции для нескольких простых микроопераций, как это делают P6 и более поздние микроархитектуры. См. руководство по таблицам команд / микроархитектуры Agner Fog. получения дополнительной информации см. по , а такжеx86 пометьте вики многими полезными ссылками (включая руководства Intel по архитектуре x86 ISA, которые свободно доступны в формате PDF).
Не путайте целевую модель памяти (x86) с моделью памяти C ++
Переупорядочение во время компиляции разрешено . Другая часть того, что вы получаете с помощью std :: atomic - это управление переупорядочением во время компиляции, чтобы убедиться, что вашnum++
становится глобально видимым только после какой-то другой операции.
Классический пример: сохранение некоторых данных в буфере для просмотра другим потоком, а затем установка флага. Несмотря на то, что x86 получает загрузку / освобождение хранилищ бесплатно, вы все равно должны указать компилятору не изменять порядок использования flag.store(1, std::memory_order_release);
.
Вы можете ожидать, что этот код будет синхронизироваться с другими потоками:
// flag is just a plain int global, not std::atomic<int>.
flag--; // This isn't a real lock, but pretend it's somehow meaningful.
modify_a_data_structure(&foo); // doesn't look at flag, and the compilers knows this. (Assume it can see the function def). Otherwise the usual don't-break-single-threaded-code rules come into play!
flag++;
Но это не так. Компилятор может свободно перемещаться flag++
по вызову функции (если он встроен в функцию или знает, что не смотрит flag
). Тогда он может полностью оптимизировать модификацию, потому что flag
не является четным volatile
. (И нет, C ++ volatile
не является полезной заменой std :: atomic. Std :: atomic заставляет компилятор предполагать, что значения в памяти могут быть изменены асинхронно аналогично volatile
, но это гораздо больше, чем это. Кроме того, volatile std::atomic<int> foo
это не как std::atomic<int> foo
и в случае с @Richard Hodges.)
Определение гонок данных для неатомарных переменных как неопределенного поведения - это то, что позволяет компилятору по-прежнему поднимать загрузки и приемники хранилищ из циклов, а также многие другие оптимизации памяти, на которые могут ссылаться несколько потоков. (См. Этот блог LLVM для получения дополнительной информации о том, как UB обеспечивает оптимизацию компилятора.)
Как я уже упоминал, префикс x86lock
является полным барьером памяти, поэтому при использовании num.fetch_add(1, std::memory_order_relaxed);
генерируется тот же код на x86, что и num++
(по умолчанию используется последовательная согласованность), но он может быть гораздо более эффективным на других архитектурах (например, ARM). Даже на x86, relaxed позволяет больше переупорядочения во время компиляции.
Это то, что GCC делает на x86 для нескольких функций, работающих с std::atomic
глобальной переменной.
Смотрите исходный + ассемблерный код, отформатированный в проводнике компилятора Godbolt . Вы можете выбрать другие целевые архитектуры, в том числе ARM, MIPS и PowerPC, чтобы увидеть, какой код ассемблера вы получаете из атомарного кода для этих целей.
#include <atomic>
std::atomic<int> num;
void inc_relaxed() {
num.fetch_add(1, std::memory_order_relaxed);
}
int load_num() { return num; } // Even seq_cst loads are free on x86
void store_num(int val){ num = val; }
void store_num_release(int val){
num.store(val, std::memory_order_release);
}
// Can the compiler collapse multiple atomic operations into one? No, it can't.
# g++ 6.2 -O3, targeting x86-64 System V calling convention. (First argument in edi/rdi)
inc_relaxed():
lock add DWORD PTR num[rip], 1 #### Even relaxed RMWs need a lock. There's no way to request just a single-instruction RMW with no lock, for synchronizing between a program and signal handler for example. :/ There is atomic_signal_fence for ordering, but nothing for RMW.
ret
inc_seq_cst():
lock add DWORD PTR num[rip], 1
ret
load_num():
mov eax, DWORD PTR num[rip]
ret
store_num(int):
mov DWORD PTR num[rip], edi
mfence ##### seq_cst stores need an mfence
ret
store_num_release(int):
mov DWORD PTR num[rip], edi
ret ##### Release and weaker doesn't.
store_num_relaxed(int):
mov DWORD PTR num[rip], edi
ret
Обратите внимание на то, что MFENCE (полный барьер) необходим после хранения последовательной консистенции. x86 строго упорядочен, но переупорядочение StoreLoad разрешено. Наличие буфера хранилища важно для хорошей производительности на конвейерном процессоре с неработоспособностью. Джефф Preshing в Изменение порядка памяти Оказавшись в законе показывает последствия не используя MFENCE с реальным кодом , чтобы показать изменение порядка происходит на реальном оборудовании.
Re: обсуждение в комментариях ответа @Richard Hodges о компиляторах, объединяющих num++; num-=2;
операции std :: atomic в одну num--;
инструкцию :
Отдельные вопросы и ответы по этой же теме: почему компиляторы не объединяют избыточные записи std :: atomic? где мой ответ повторяет многое из того, что я написал ниже.
Текущие компиляторы на самом деле этого не делают (пока), но не потому, что им это запрещено. C ++ WG21 / P0062R1: Когда компиляторы должны оптимизировать атомарность? обсуждается ожидание того, что многие программисты считают, что компиляторы не будут проводить «удивительные» оптимизации, и что стандарт может сделать, чтобы дать программистам контроль. N4455 обсуждает множество примеров вещей, которые можно оптимизировать, включая этот. Это указывает на то, что встраивание и постоянное распространение могут привести к тому, что такие вещи fetch_or(0)
могут превратиться в просто load()
(но все еще имеет семантику получения и выпуска), даже когда исходный источник не имел явно избыточных атомарных операций.
Реальные причины, по которым компиляторы этого не делают (пока): (1) никто не написал сложный код, который позволил бы компилятору делать это безопасно (безо всяких ошибок), и (2) это потенциально нарушает принцип наименьшего сюрприз . Код без блокировки достаточно сложен, чтобы правильно писать в первую очередь. Так что не будьте внимательны при использовании атомного оружия: оно не дешевое и мало оптимизирует. Однако не всегда легко избежать избыточных атомарных операций std::shared_ptr<T>
, поскольку не существует неатомарной версии (хотя один из ответов здесь дает простой способ определить shared_ptr_unsynchronized<T>
для gcc).
Возвращаясь к num++; num-=2;
компиляции, как если бы это было так num--
: компиляторам разрешено делать это, если num
это не так volatile std::atomic<int>
. Если переупорядочение возможно, правило «как будто» позволяет компилятору решить во время компиляции, что это всегда происходит таким образом. Ничто не гарантирует, что наблюдатель мог видеть промежуточные значения ( num++
результат).
Т.е. если порядок, в котором ничего не становится глобально видимым между этими операциями, совместим с требованиями к упорядочению источника (в соответствии с правилами C ++ для абстрактной машины, а не целевой архитектуры), компилятор может выдавать единицу lock dec dword [num]
вместо lock inc dword [num]
/ lock sub dword [num], 2
.
num++; num--
не может исчезнуть, потому что у него все еще есть отношение Synchronizes With с другими потоками, которые смотрят num
, и это и load-load, и release-store, которые запрещают переупорядочение других операций в этом потоке. Для x86 это может быть в состоянии скомпилировать в MFENCE, вместо lock add dword [num], 0
(то есть num += 0
).
Как обсуждалось в PR0062 , более агрессивное объединение несмежных атомарных операций во время компиляции может быть плохим (например, счетчик хода выполнения обновляется только один раз в конце вместо каждой итерации), но это также может помочь повысить производительность без недостатков (например, пропуская atomic inc / dec of ref считает, когда копия a shared_ptr
создается и уничтожается, если компилятор может доказать, что другой shared_ptr
объект существует в течение всей продолжительности жизни временного.)
Даже num++; num--
слияние может нарушить справедливость реализации блокировки, когда один поток сразу разблокируется и повторно блокируется. Если он никогда не будет выпущен в asm, даже аппаратные механизмы арбитража не дадут другому потоку возможности захватить блокировку в этой точке.
В текущих версиях gcc6.2 и clang3.9 вы по-прежнему получаете отдельные lock
операции ed даже memory_order_relaxed
в наиболее очевидном оптимизируемом случае. ( Проводник компилятора Godbolt, чтобы вы могли увидеть, отличаются ли последние версии.)
void multiple_ops_relaxed(std::atomic<unsigned int>& num) {
num.fetch_add( 1, std::memory_order_relaxed);
num.fetch_add(-1, std::memory_order_relaxed);
num.fetch_add( 6, std::memory_order_relaxed);
num.fetch_add(-5, std::memory_order_relaxed);
//num.fetch_add(-1, std::memory_order_relaxed);
}
multiple_ops_relaxed(std::atomic<unsigned int>&):
lock add DWORD PTR [rdi], 1
lock sub DWORD PTR [rdi], 1
lock add DWORD PTR [rdi], 6
lock sub DWORD PTR [rdi], 5
ret
add
это атомно?