Может ли num++ быть атомарным для 'int num'?
в общем, для int num
,num++
(или ++num
), как операция чтения-изменения-записи, является не атомные. Но я часто вижу компиляторы, например GCC, сгенерируйте для него следующий код (попробуйте здесь):
начиная со строки 5, которая соответствует num++
Это одна инструкция, Можно ли сделать вывод, что num++
атомарный в этом случае?
и если да, то это значит что так сгенерировано num++
может использоваться в параллельных (многопоточных) сценариях без какой-либо опасности гонки данных (т. е. нам не нужно делать это, например,std::atomic<int>
и ввести связанные с этим расходы, так как это в любом случае атомной)?
обновление
обратите внимание, что этот вопрос не ли прирастить и atomic (это не так, и это было и является начальной строкой вопроса). Это ли она можете будет в конкретных сценариях, т. е. Можно ли в некоторых случаях использовать природу одной инструкции, чтобы избежать накладных расходов lock
префикс. И, поскольку принятый ответ упоминается в разделе об однопроцессорных машинах, а также этот ответ, разговор в его комментариях и других поясняют,он может (хотя и не с C или C++).
13 ответов:
это абсолютно то, что C++ определяет как гонку данных, которая вызывает неопределенное поведение, даже если один компилятор случайно создал код, который сделал то, что вы надеялись на какой-то целевой машине. Вы должны использовать
std::atomic
для получения надежных результатов, но вы можете использовать его сmemory_order_relaxed
если вы не заботитесь о дозаказе. Смотрите ниже для некоторых примеров кода и вывода asm с помощьюfetch_add
.
но сначала, ассемблер часть вопроса:
так как num++ это одна инструкция (
add dword [num], 1
), можем ли мы заключить, что num++ является атомарным в этом случае?инструкции назначения памяти (кроме чистых хранилищ) являются операциями чтения-изменения-записи, которые выполняются в несколько внутренних шагов. Ни один архитектурный регистр не изменяется, но процессор должен удерживать данные внутри, пока он отправляет их через свой АЛУ. Фактический файл регистра-это только небольшая часть хранилища данных внутри даже самого простого процессора, с защелки держат выходы из одной стадии в качестве входных данных для другой этап и т. д., прием.
операции с памятью от других процессоров могут стать глобально видимыми между загрузкой и хранением. То есть два потока работают
add dword [num], 1
в цикле будет наступать на магазины друг друга. (См.@Маргарет для хорошей диаграммы). После 40K приращений от каждого из двух потоков счетчик мог бы увеличиться только на ~60k (а не 80k) на реальном многоядерном x86 аппаратура.
"атомно", от греческого слова, означающего неделимый, означает, что ни один наблюдатель не может посмотреть операция в виде отдельных шагов. Происходящее физически / электрически мгновенно для всех битов одновременно-это только один способ достичь этого для загрузки или хранения, но это даже невозможно для операции ALU. я пошел в гораздо более подробно о чистых нагрузок и чистых магазинов в моем ответе на атомарность на x86, в то время как этот ответ фокусируется на read-modify-write.
The
lock
префикс может применяться ко многим инструкциям чтения-изменения-записи (назначение памяти), чтобы сделать всю операцию атомарной по отношению ко всем возможным наблюдателям в системе (другие ядра и устройства DMA, а не осциллограф, подключенный к выводам процессора). Вот почему она существует. (См. также это Q & A).так
lock add dword [num], 1
и атомный. Ядро процессора, выполняющее эту инструкцию, будет удерживать строку кэша закрепленной в измененном состоянии в своем частном кэше L1 с момента, когда загрузка считывает данные из кэша, до тех пор, пока хранилище не зафиксирует свой результат обратно в кэш. Это предотвращает любой другой кэш в системе от копирования строки кэша в любой момент от загрузки до хранения, в соответствии с правилами протокол когерентности кэша MESI (или его версии MOESI / MESIF, используемые многоядерными процессорами AMD / Intel, соответственно.) Таким образом, операции с другими ядрами, по-видимому, происходят либо до, либо после, а не во время.без
lock
префикс, другое ядро может взять на себя ответственность за строку кэша и изменить ее после нашей загрузки, но до нашего магазина, так что другой магазин станет глобально видимым между нашей загрузкой и магазином. Несколько других ответов ошибаются и утверждают, что безlock
вы получите конфликтующие копии одной и той же строки кэша. Это никогда не может произойти в системе с когерентные кэши.(если a
lock
инструкция ed работает на памяти, которая охватывает две строки кэша, требуется гораздо больше работы, чтобы убедиться, что изменения в обеих частях объекта остаются атомарными, поскольку они распространяются на всех наблюдателей, поэтому ни один наблюдатель не может видеть разрыв. ЦП, возможно, придется заблокировать всю шину памяти, пока данные не попадут в память. Не смещайте свои атомарные переменные!)отметим, что
lock
префикс также превращает инструкцию в полный барьер памяти (например MFENCE), останавливая все время выполнения переупорядочивания и, таким образом, давая последовательную последовательность. (См.отличный пост в блоге Джеффа Прешинга. Его другие сообщения все отлично, тоже, и ясно объяснить много хороших вещей о Программирование без блокировки, от x86 и других деталей оборудования до правил C++.)
на однопроцессорной машине или в однопоточном процессе, одно RMW инструкция на самом деле и атомно-без
lock
префикс. Единственный способ для другого кода получить доступ к общей переменной-это для ЦП выполнить переключение контекста, которое не может произойти в середине инструкции. Так что равнинаdec dword [num]
может синхронизироваться между однопоточной программой и ее обработчиками сигналов или в многопоточной программе, работающей на одноядерной машине. Смотрите вторая половина моего ответа на другой вопрос, и комментарии под ним, где я объясните это более подробно.
вернуться к C++:
это совершенно фиктивно использовать
num++
не сообщая компилятору, что вам нужно скомпилировать его в одну реализацию read-modify-write:;; 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 tag wiki для многих полезных ссылок (включая руководства Intel x86 ISA, которые свободно доступны в формате PDF)).
не путайте целевую модель памяти (x86) с моделью памяти C++
переупорядочивание во время компиляции разрешено. Другая часть того, что вы получаете с std::atomic-это контроль над переупорядочением времени компиляции, чтобы убедиться, что ваш
num++
становится глобально видна только после какой-то другой операции.классический пример: сохранение некоторых данных в буфер для другого потока, чтобы посмотреть, а затем установка флага. Несмотря на то, что x86 получает магазины loads/release бесплатно, вам все равно нужно сказать компилятору не переупорядочивать с помощью
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 Ходжи.)определение гонок данных по неатомным переменным как неопределенное поведение-это то, что позволяет компилятору по-прежнему поднимать нагрузки и поглощать хранилища из циклов и многие другие оптимизации для памяти, на которые могут ссылаться несколько потоков. (См.этот блог LLVM подробнее о том, как UB включает оптимизацию компилятора.)
как я уже говорил, x86
lock
префикс это полный барьер памяти, так что с помощьюnum.fetch_add(1, std::memory_order_relaxed);
создает один и тот же код на x86 какnum++
(по умолчанию используется последовательная согласованность), но она может быть гораздо более эффективной на других архитектурах (например, ARM). Даже на x86 relaxed позволяет больше переупорядочивать время компиляции.это то, что GCC на самом деле делает на x86, для нескольких функций, которые работают на
std::atomic
глобальная переменная.см. исходный код + язык ассемблера, отформатированный красиво на godbolt compiler explorer. Вы можете выбрать другое целевые архитектуры, включая ARM, MIPS и PowerPC, чтобы узнать, какой код языка ассемблера вы получаете от atomics для этих целей.
#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 строго упорядочен в целом, но допускается переупорядочение загрузки хранилища. Наличие буфера хранилища имеет важное значение для хорошей производительности на конвейерном процессоре вне порядка. Память Поймали с поличным показывает последствия не с помощью MFENCE, с реальным кодом, чтобы показать переупорядочение происходит на реальном оборудовании.
Re: обсуждение в комментариях к ответу @Richard Hodges о компиляторы слияния std:: atomic
num++; num-=2;
операции в одинnum--;
- инструкции:отдельный вопрос и ответ на этот же вопрос:почему компиляторы не объединяют избыточные std:: atomic пишет?, где мой ответ повторяет многое из того, что я написал ниже.
текущие компиляторы на самом деле не делают этого (пока), но не потому, что им это не разрешено. C++ WG21 / P0062R1: когда компиляторы должны оптимизировать атомику? обсуждается ожидание, что многие программисты имеют, что компиляторы не будут делать "удивительные" оптимизации, и что стандарт может сделать, чтобы дать программистам контроль. N4455 рассматривается много примеров из вещей, которые можно оптимизировать, в том числе и этот. Он указывает, что встраивание и постоянное распространение могут вводить такие вещи, как
fetch_or(0)
который может превратиться просто вload()
(но все еще имеет семантику приобретения и выпуска), даже когда исходный источник не имел никаких явно избыточных атомарных операций.истинные причины, по которым компиляторы не делают этого (пока): (1) никто не написал сложный код, который позволил бы компилятору сделать это безопасно (никогда не получая это неправильно), и (2) это потенциально нарушает принцип наименьшего сюрприза. Код без блокировки достаточно сложно написать правильно в первую очередь. Так что не будьте случайны в использовании атомного оружия: они не дешевы и не оптимизируют много. Это не всегда легко легко избежать избыточных атомарных операций с
std::shared_ptr<T>
, хотя, поскольку нет неатомной версии этого (хотя один из ответов здесь дает простой способ определенияshared_ptr_unsynchronized<T>
для ССЗ.)
возвращаясь к
num++; num-=2;
компиляция, как если бы это былоnum--
: Компиляторы разрешено сделать это, если толькоnum
иvolatile std::atomic<int>
. Если переупорядочение возможно, правило as-if позволяет компилятору решить во время компиляции, что оно всегда так бывает. Ничто не гарантирует, что наблюдатель может видеть промежуточные значения (num++
результат).т. е. если заказ, где ничего не будет видно во всем мире между этими операциями совместим с требованиями к заказу источника (в соответствии с правилами C++ для абстрактной машины, а не целевой архитектуры) компилятор может выдавать один
lock dec dword [num]
вместоlock inc dword [num]
/lock sub dword [num], 2
.
num++; num--
не может исчезнуть, потому что он все еще синхронизируется с отношениями с другими потоками, которые смотрят наnum
, и это как acquire-load, так и release-store, который запрещает переупорядочивание других операций в этом нитка. Для x86 это может быть скомпилировано в MFENCE, а не вlock add dword [num], 0
(т. е.num += 0
).как говорится в PR0062, более агрессивное слияние несмежных атомарных операций во время компиляции может быть плохим (например, счетчик прогресса обновляется только один раз в конце вместо каждой итерации), но он также может помочь производительности без недостатков (например, пропуск atomic inc / dec счетчиков ref, когда копия
shared_ptr
создается и уничтожается, если компилятор может доказать, что другойshared_ptr
объект существует в течение всего срока службы временного.)даже
num++; num--
слияние может повредить справедливости реализации блокировки, когда один поток разблокирует и повторно блокирует сразу. Если он никогда не будет выпущен в asm, даже механизмы аппаратного арбитража не дадут другому потоку возможности захватить блокировку в этот момент.
с текущим gcc6. 2 и clang3. 9, вы все еще получаете отдельный
lock
операции ed даже сmemory_order_relaxed
в наиболее очевидно случае оптимизируется. ( godbolt compiler explorer так что вы можете увидеть, если последние версии разные.)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
...а теперь давайте включим оптимизацию:
f(): rep ret
хорошо, давайте дадим ему шанс:
void f(int& num) { num = 0; num++; --num; num += 6; num -=5; --num; }
результат:
f(int&): mov DWORD PTR [rdi], 0 ret
другой наблюдающий поток (даже игнорируя задержки синхронизации кэша) не имеет возможности наблюдать отдельные изменения.
сравнить с:
#include <atomic> void f(std::atomic<int>& num) { num = 0; num++; --num; num += 6; num -=5; --num; }
где результат:
f(std::atomic<int>&): mov DWORD PTR [rdi], 0 mfence lock add DWORD PTR [rdi], 1 lock sub DWORD PTR [rdi], 1 lock add DWORD PTR [rdi], 6 lock sub DWORD PTR [rdi], 5 lock sub DWORD PTR [rdi], 1 ret
теперь каждая модификация: -
- наблюдаемый в другом потоке, и
- уважительно относится к подобным модификациям, происходящим в других потоках.
атомарность не только на уровне команд, она включает в себя весь конвейер от процессора, через кэш, в память и обратно.
дополнительная информация
относительно эффекта оптимизации обновлений
std::atomic
s.стандарт c++ имеет правило "как бы", по которому компилятору разрешается переупорядочивать код, и даже перепишите код при условии, что результат имеет точно такой же наблюдаемый эффекты (включая побочные эффекты), как если бы он просто выполнил ваш код.
правило as-if является консервативным, особенно с участием атомной энергетики.
считаем:
void incdec(int& num) { ++num; --num; }
поскольку нет мьютексных блокировок, атомарных или любых других конструкций, которые влияют на межпоточное секвенирование, я бы сказал, что компилятор может переписать эту функцию как NOP, например:
void incdec(int&) { // nada }
это связано с тем, что в модели памяти c++ нет возможности другого потока, наблюдающего результат приращения. Было бы, конечно, по-другому, если
num
былvolatile
(может повлиять на поведение оборудования). Но в этом случае эта функция будет единственной функцией, модифицирующей эту память (в противном случае программа плохо сформирована).однако, это другая игра в мяч:
void incdec(std::atomic<int>& num) { ++num; --num; }
num
является атомарным. Изменения в нем должны быть заметным для других потоков, которые наблюдают. Изменения, которые сами эти потоки делают (например, установка значения 100 между приращением и уменьшением), будут иметь очень далеко идущие последствия для конечного значения num.вот демо:
#include <thread> #include <atomic> int main() { for (int iter = 0 ; iter < 20 ; ++iter) { std::atomic<int> num = { 0 }; std::thread t1([&] { for (int i = 0 ; i < 10000000 ; ++i) { ++num; --num; } }); std::thread t2([&] { for (int i = 0 ; i < 10000000 ; ++i) { num = 100; } }); t2.join(); t1.join(); std::cout << num << std::endl; } }
пример вывода:
99 99 99 99 99 100 99 99 100 100 100 100 99 99 100 99 99 100 100 99
без многих осложнений инструкция как
add DWORD PTR [rbp-4], 1
очень CISC-стиль.он выполняет три операции: загружает операнд из памяти, увеличивает его, сохраняет операнд обратно в память.
Во время этих операций ЦП получает и освобождает шину дважды, между ними любой другой агент может получить ее тоже, и это нарушает атомарность.AGENT 1 AGENT 2 load X inc C load X inc C store X store X
X увеличивается только один раз.
добавить инструкцию не атомные. Он ссылается на память, и два ядра процессора могут иметь различный локальный кэш этой памяти.
IIRC атомарный вариант инструкции add называется xadd замок
поскольку строка 5, которая соответствует num++, является одной инструкцией, можем ли мы заключить, что num++ является атомарным в этом случае?
опасно делать выводы на основе" обратного инжиниринга " генерируемой сборки. Например, вы, похоже, скомпилировали свой код с отключенной оптимизацией, иначе компилятор выбросил бы эту переменную или загрузил 1 непосредственно в нее без вызова
operator++
. Потому что сгенерированная сборка может значительно измениться, на основе флагов оптимизации, целевого процессора и т. д. ваш вывод основан на песке.кроме того, ваша идея о том, что одна инструкция по сборке означает, что операция является атомной, также неверна. Это
add
не будет атомарным на многопроцессорных системах, даже на архитектуре x86.
даже если ваш компилятор всегда выдавал это как атомарную операцию, доступ к
num
из любого другого потока одновременно будет составлять гонку данных в соответствии со стандартами C++11 и C++14, и программа будет иметь неопределенное поведение.но это еще хуже. Во-первых, как уже упоминалось, инструкция, генерируемая компилятором при увеличении переменной, может зависеть от уровня оптимизации. Во-вторых, компилятор может изменить порядок другое доступ к памяти вокруг
++num
Еслиnum
не является атомарным, напримерint main() { std::unique_ptr<std::vector<int>> vec; int ready = 0; std::thread t{[&] { while (!ready); // use "vec" here }); vec.reset(new std::vector<int>()); ++ready; t.join(); }
даже если предположить оптимистично, что
++ready
является "атомарным", и что компилятор генерирует цикл проверки по мере необходимости (как я уже сказал, это UB, и поэтому компилятор может удалить его, заменить его бесконечным циклом и т. д.), компилятор может по-прежнему перемещать назначение указателя, или даже хуже инициализацииvector
до точки, после операции увеличения, вызывая хаос в новом нитка. На практике я бы не удивился, если бы оптимизирующий компилятор удалилready
переменная и цикл проверки полностью, так как это не влияет на наблюдаемое поведение в соответствии с правилами языка (в отличие от ваших частных надежд).на самом деле, на прошлогодней конференции C++ я слышал от два разработчики компиляторов, что они очень охотно реализуют оптимизации, которые делают наивно написанные многопоточные программы плохо себя вести, пока язык правила позволяют это, если даже незначительное улучшение производительности наблюдается в правильно написанных программах.
наконец, даже если вы не заботились о переносимости, и ваш компилятор был волшебно хорош, процессор, который вы используете, очень вероятно, имеет суперскалярный тип CISC и будет разбивать инструкции на микро-ops, переупорядочивать и / или спекулятивно выполнять их, в степени, ограниченной только синхронизацией примитивов, таких как (на Intel)
LOCK
префикс или заборы памяти, в порядке для максимизации операций в секунду.короче говоря, естественными обязанностями потокобезопасного программирования являются:
- ваша обязанность-написать код, который имеет четко определенное поведение в соответствии с языковыми правилами (и, в частности, модель стандартной памяти языка).
- ваш компилятор обязан генерировать машинный код, который имеет такое же четко определенное (наблюдаемое) поведение в модели памяти целевой архитектуры.
- ваш ЦП обязан выполнить этот код так, чтобы наблюдаемое поведение было совместимо с моделью памяти его собственной архитектуры.
если вы хотите сделать это по-своему, это может просто работать в некоторых случаях, но поймите, что гарантия недействительна, и вы будете нести полную ответственность за любые нежелательного результатов. : -)
PS: правильно написанный пример:
int main() { std::unique_ptr<std::vector<int>> vec; std::atomic<int> ready{0}; // NOTE the use of the std::atomic template std::thread t{[&] { while (!ready); // use "vec" here }); vec.reset(new std::vector<int>()); ++ready; t.join(); }
это безопасно, так:
- проверки
ready
не может быть оптимизирован в соответствии с языковыми правилами.- The
++ready
происходит-перед чек, который видитready
не ноль, и другие операции не могут быть переупорядочены вокруг этих операций. Это потому что++ready
и чек последовательно последовательное, который является еще одним термином, описанным в модели памяти C++, и который запрещает это конкретное переупорядочение. Поэтому компилятор не должен переупорядочивать инструкции, а также должен сообщать процессору что он не должен, например, откладывать запись вvec
после приращенияready
. последовательно последовательное самая сильная гарантия относительно Атомик в стандарте языка. Меньшие (и теоретически более дешевые) гарантии доступны, например, с помощью других методовstd::atomic<T>
, но они определенно предназначены только для экспертов и не могут быть оптимизированы разработчиками компиляторов, потому что они редко используются.
на одноядерной машине x86,
add
инструкция обычно будет атомарной по отношению к другому коду на CPU1. Прерывание не может разделить одну инструкцию посередине.выполнение вне порядка требуется для сохранения иллюзии выполнения инструкций по одному за раз в пределах одного ядра, поэтому любая инструкция, запущенная на одном и том же процессоре, будет либо полностью выполнена до, либо полностью после добавления.
современный x86 системы Multi-сердечника, поэтому однопроцессорный особый случай не распространяется.
если вы нацелены на небольшой встроенный ПК и не планируете перемещать код ни на что другое, можно использовать атомарную природу инструкции "добавить". С другой стороны, платформы, где операции по своей сути являются атомарными, становятся все более и более дефицитными.
(это не поможет вам, если вы пишете на C++, хотя. Компиляторы не имеют возможности требовать
num++
для компиляции в a memory-destination add or xadd без alock
префикс. Они могли бы выбрать, чтобы загрузитьnum
в регистр и хранить результат приращения с отдельной инструкцией, и, вероятно, сделать это, если вы используете результат.)
Сноска 1:
lock
префикс существовал даже на оригинальном 8086, потому что устройства ввода / вывода работают одновременно с процессором; драйверы на одноядерной системе нужныlock add
атомарно увеличить значение в памяти устройства, если устройство также может измените его, или в отношении доступа DMA.
еще в тот день, когда компьютеры x86 имели один процессор, использование одной инструкции гарантировало, что прерывания не будут разделять чтение/изменение/запись, и если память не будет использоваться в качестве буфера DMA, она была атомарной на самом деле (и C++ не упоминал потоки в стандарте, поэтому это не были адреса).
когда было редко иметь двухъядерный (Pentium Pro) на рабочем столе клиента, я эффективно использовал это, чтобы избежать префикса блокировки на одноядерной машине и повысить производительность.
сегодня это помогло бы только против нескольких потоков, которые были настроены на одно и то же сродство ЦП, поэтому потоки, о которых вы беспокоитесь, вступят в игру только через временной срез, истекающий и запускающий другой поток на том же ЦП (ядро). Это нереально.
с современными процессорами x86 / x64, одна инструкция разбивается на несколько micro ops и, кроме того, чтение и запись памяти буферизуется. Так что разные потоки работают различные процессоры не только увидят это как неатомное, но и могут увидеть противоречивые результаты относительно того, что он читает из памяти и что он предполагает, что другие потоки прочитали до этого момента времени: вам нужно добавить защита памяти для восстановления нормального поведения.
нет. https://www.youtube.com/watch?v=31g0YE61PLQ (Это просто ссылка на сцену" нет " из "офиса")
согласны ли вы, что это было бы возможным выходом для программы:
пример вывода:
100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100
если это так, то компилятор может сделать это только возможный вывод для программы, в зависимости от того, каким образом компилятор хочет. т. е. основной (), который просто выпускает 100s.
это правило" как-будто".
и независимо от вывода, вы можете думать о синхронизации потоков одинаково - если поток A делает
num++; num--;
и поток B читаетnum
неоднократно, то возможным допустимым чередованием является то, что поток B никогда не читает междуnum++
иnum--
. Поскольку это чередование допустимо, компилятор может сделать это только возможно чередование. И просто удалите incr / decr полностью.есть некоторые интересные последствия здесь:
while (working()) progress++; // atomic, global
(т. е. Представьте, что какой-то другой поток обновляет интерфейс индикатора выполнения на основе
progress
)может ли компилятор превратить это в:
int local = 0; while (working()) local++; progress += local;
вероятно, это действительно так. Но, вероятно, не то, на что надеялся программист: - (
комитет все еще работает над этим материалом. В настоящее время он "работает", потому что компиляторы не оптимизируют Атомикс. Но это меняется.
и даже если
progress
было также изменчиво, это было бы по-прежнему действительны:int local = 0; while (working()) local++; while (local--) progress++;
: -/
да, но...
Atomic-это не то, что вы хотели сказать. Ты, наверное, спрашиваешь не то.
инкремент конечно atomic. Если хранилище не выровнено (а поскольку вы оставили выравнивание для компилятора, это не так), оно обязательно выравнивается в пределах одной строки кэша. За исключением специальных инструкций потоковой передачи без кэширования, каждая запись проходит через кэш. Заполните строки кэш-памяти осуществляется атомарно читать и написано, Никогда ничего другого.
Конечно, данные меньшего размера, чем cacheline, также записываются атомарно (поскольку окружающая строка кэша).Это потокобезопасным?
это другой вопрос, и есть по крайней мере две веские причины, чтобы ответить с определенным "нет!".
во-первых, существует вероятность того, что у другого ядра может быть копия этой строки кэша в L1 (L2 и выше обычно разделяются, но L1 обычно на ядро!), и одновременно изменяет это значение. Конечно, это происходит и атомарно, но теперь у вас есть два "правильных" (правильно, атомарно, модифицированных) значения-какое из них действительно правильное сейчас?
Процессор, конечно, как-то разберется. Но результат может быть не таким, как вы ожидаете.во-вторых, есть упорядочение памяти, или адрес по-другому происходит-перед гарантиями. Самое главное в атомарных инструкциях не столько то, что они atomic. Это заказ.
У вас есть возможность обеспечить гарантию того, что все, что происходит с памятью, реализуется в некотором гарантированном, четко определенном порядке, где у вас есть гарантия "произошло раньше". Этот заказ может быть как" расслабленным " (читай как: нет вообще), так и строгим, как вам нужно.
например, вы можете установить указатель на блок данных (например, результаты некоторых вычислений), а затем атомарно релиз флаг "данные готовы". Теперь, кто бы ни был приобретает этот флаг приведет к мысли, что указатель действителен. И действительно, это будет всегда быть действительным указателем, никогда ничего другого. Это потому, что запись в указатель произошла-до атомной операции.
что выход одного компилятора на определенной архитектуре процессора с отключенной оптимизацией (поскольку gcc даже не компилирует
++
доadd
при оптимизации в быстром и грязном примере), похоже, подразумевает, что увеличение этого способа является атомарным, не означает, что это соответствует стандарту (вы вызовете неопределенное поведение при попытке доступаnum
в потоке), и ошибается в любом случае, потому чтоadd
и не атомная в x86.обратите внимание, что атомика (с помощью
lock
префикс инструкции) относительно тяжелы на x86 (посмотреть этот ответ), но все же заметно меньше, чем мьютекс, что не очень уместно в этом случае использования.следующие результаты взяты из Clang++ 3.8 при компиляции с
-Os
.увеличение int по ссылке, "обычный" способ:
void inc(int& x) { ++x; }
это компилируется в :
inc(int&): incl (%rdi) retq
увеличение int, передаваемого по ссылке, атомарный путь:
#include <atomic> void inc(std::atomic<int>& x) { ++x; }
этот пример, который не намного сложнее, чем обычный способ, просто получает
lock
префикс, добавляемый кincl
инструкция - но осторожно, как уже говорилось ранее это не дешевые. Просто потому, что сборка выглядит короткой, не означает, что это быстро.inc(std::atomic<int>&): lock incl (%rdi) retq
когда ваш компилятор использует только одну инструкцию для инкремента, а ваша машина однопоточна, ваш код безопасен. ^^
попробуйте скомпилировать тот же код на машине без x86, и вы быстро увидите очень разные результаты сборки.
причина
num++
появляется быть атомарным-это потому, что на машинах x86 увеличение 32-разрядного целого числа фактически является атомарным (при условии, что извлечение памяти не происходит). Но это не гарантируется стандартом c++, и это вряд ли будет иметь место на машине, которая не использует набор инструкций x86. Таким образом, этот код не является кросс-платформенным безопасным состязание.у вас также нет сильной гарантии, что этот код безопасен от условий гонки даже на архитектуре x86, потому что x86 не настраивает загрузку и хранение в памяти, если специально не указано это сделать. Поэтому, если несколько потоков попытались обновить эту переменную одновременно, они могут в конечном итоге увеличить кэшированные (устаревшие) значения
причина, значит, в том, что у нас
std::atomic<int>
и так далее, так что, когда вы работаете с архитектурой, где атомарность базовых вычислений не гарантируется, у вас есть механизм, который заставит компилятор генерировать атомарный код.