Может ли num ++ быть атомарным для ‘int num’?

В общем случае для int num num++ (или ++num ), как операция чтения-изменения-записи, не является атомарным . Но я часто вижу, что компиляторы, например GCC , генерируют для него следующий код ( попробуйте здесь ):

Введите описание изображения здесь

Поскольку строка 5, которая соответствует num++ является одной инструкцией, можем ли мы заключить, что num++ является атомарным в этом случае?

И если да, значит ли это, что сгенерированный num++ может использоваться в параллельных (многопоточных) сценариях без какой-либо опасности расов данных (т. Е. Нам не нужно это делать, например, std::atomic и налагать связанные затраты, так как это атомный)?

ОБНОВИТЬ

Обратите внимание, что этот вопрос заключается не в том, является ли приращение атомарным (это не так, и это была и есть начальная строка вопроса). Это может быть в конкретных сценариях, т. Е. Можно ли в некоторых случаях эксплуатировать один способ обучения, чтобы избежать накладных расходов на префикс lock . И, поскольку принятый ответ упоминает в разделе об однопроцессорных машинах, а также этот ответ , разговор в его комментариях и других объясняет, он может (хотя и не с C или C ++).

Это абсолютно то, что C ++ определяет как гонку данных, которая вызывает неопределенное поведение, даже если один из компиляторов создал код, который сделал то, что вы надеялись на какой-либо целевой машине. Вам нужно использовать std::atomic для получения надежных результатов, но вы можете использовать его с memory_order_relaxed если вы не заботитесь о переупорядочении. Ниже приведен пример кода и выхода asm с использованием fetch_add .


Но сначала часть ассемблера вопроса:

Поскольку num ++ – это одна команда ( add dword [num], 1 ), можем ли мы заключить, что num ++ является атомарным в этом случае?

Инструкции назначения памяти (кроме чистых хранилищ) – операции чтения-изменения-записи, которые выполняются несколькими внутренними шагами . Архитектурный регистр не изменен, но ЦП должен хранить данные внутри себя, пока он отправляет его через ALU . Фактический файл регистра – это лишь небольшая часть хранилища данных внутри даже самого простейшего процессора, причем защелки содержат выходы одного этапа в качестве входов для другого этапа и т. Д. И т. Д.

Операции с памятью других ЦП могут стать глобально видимыми между загрузкой и хранением. Т.е. два streamа, выполняющих add dword [num], 1 в цикле, будут наступать на магазины друг друга. (См . Ответ @ Маргарет за хорошую диаграмму). После 40k шагов от каждого из двух streamов счетчик мог бы только подняться на ~ 60k (не 80k) на реальном многоядерном оборудовании x86.


«Атомная», от греческого слова, означающего неделимое, означает, что никакой наблюдатель не может видеть операцию как отдельные шаги. Случайное физическое / электрическое мгновение для всех бит одновременно является лишь одним из способов достижения этого для загрузки или хранения, но это невозможно даже для работы ALU. В своем ответе Atomity на x86 я подробно рассказал о чистых загрузках и чистых хранилищах, в то время как этот ответ фокусируется на read-modify-write.

Префикс lock может применяться ко многим инструкциям чтения-изменения-записи (назначения памяти), чтобы сделать всю операцию атомой относительно всех возможных наблюдателей в системе (другие ядра и устройства DMA, а не осциллограф, подключенный к выводам CPU) , Вот почему он существует. (См. Также этот вопрос и ответы ).

Поэтому lock add dword [num], 1 является атомарным . Ядро центрального процессора, выполняющее эту инструкцию, будет удерживать строку кэша в модифицированном состоянии в своем приватном кэше L1, когда загрузка считывает данные из кэша, пока хранилище не вернет результат в кеш. Это препятствует тому, чтобы любой другой кэш в системе не имел копию строки кэша в любой точке от загрузки до хранилища в соответствии с правилами протокола согласованности кеша MESI (или версии MOESI / MESIF, используемые многоядерным процессором AMD / Процессоры Intel, соответственно). Таким образом, операции других ядер, как представляется, происходят до или после, а не во время.

Без префикса lock другое kernel ​​может взять на себя управление линией кэша и изменить его после нашей загрузки, но перед нашим магазином, чтобы другой магазин стал глобально видимым между нашей загрузкой и хранилищем. Несколько других ответов ошибочны и утверждают, что без lock вы получите конфликтующие копии одной и той же строки кэша. Это никогда не может произойти в системе с когерентными кэшами.

(Если команда 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 later: компилятор сохранит его в реальном времени в регистре после приращения. Поэтому, даже если вы проверите, как компилируется num++ самостоятельно, изменение окружающего кода может повлиять на него.

(Если значение не требуется позже, inc dword [num] , современные процессоры x86 будут выполнять инструкцию RMW по назначению памяти, по крайней мере, так же эффективно, как и с использованием трех отдельных инструкций. Fun fact: gcc -O3 -m32 -mtune=i586 действительно испустит это , потому что суперскалярный конвейер (Pentium) P5 не расшифровал сложные инструкции для нескольких простых микроопераций, как это делают P6 и более поздние микроархитектуры. Дополнительную информацию см. в инструктивных таблицах / микроархитектуре Agner Fog , а тег x86 wiki для многих полезных ссылок (в том числе руководства Intel по x86 ISA, которые свободно доступны в формате PDF)).


Не путайте целевую модель памяти (x86) с моделью памяти C ++

Разрешено переупорядочение времени компиляции . Другая часть того, что вы получаете с помощью std :: atomic, – это управление переупорядочением времени компиляции, чтобы убедиться, что ваш num++ становится глобально видимым только после некоторой другой операции.

Классический пример: хранение некоторых данных в буфере для другого streamа, чтобы посмотреть, а затем установить флаг. Несмотря на то, что x86 действительно бесплатно загружает магазины загрузки / выпуска, вам все равно придется сообщать компилятору, чтобы он не переупорядочивался, используя flag.store(1, std::memory_order_release); ,

Возможно, вы ожидаете, что этот код будет синхронизироваться с другими streamами:

 // flag is just a plain int global, not std::atomic. 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 foo не совпадает с std::atomic foo , как обсуждалось с @Richard Hodges.)

Определение расчётов данных по неатомным переменным как Undefined Behavior – это то, что позволяет компилятору по-прежнему поднимать нагрузки и убирать магазины из циклов и многие другие оптимизации для памяти, к которым могут иметь отношение несколько streamов. (См. Этот блог LLVM для получения дополнительной информации о том, как UB обеспечивает оптимизацию компилятора.)


Как я уже упоминал, префикс lock x86 является полным барьером памяти, поэтому используйте num.fetch_add(1, std::memory_order_relaxed); генерирует тот же код на x86 как num++ (по умолчанию это последовательная согласованность), но он может быть намного более эффективным для других архитектур (например, ARM). Даже на x86, relaxed позволяет больше переупорядочивать во время компиляции.

Это то, что GCC на самом деле делает для x86, для нескольких функций, которые работают с глобальной переменной std::atomic .

См. Исходный код + код ассемблера, хорошо отформатированный в проводнике компилятора Godbolt . Вы можете выбрать другие целевые архитектуры, включая ARM, MIPS и PowerPC, чтобы узнать, какой код языка ассемблера вы получаете от атомики для этих целей.

 #include  std::atomic 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. Наличие буфера хранилища имеет важное значение для хорошей производительности на конвейерном процессоре вне порядка. Реорганизация памяти Джеффа Прешинга, представленная в законе, показывает последствия не использования MFENCE, с реальным кодом, чтобы показать переупорядочение на реальном оборудовании.


Re: обсуждение в комментариях к ответу @Richard Hodges о компиляторах, объединяющих std :: atomic num++; num-=2; num++; num-=2; операции в одно num--; инструкция :

Отдельные вопросы и ответы по этому же вопросу: Почему компиляторы не объединяют избыточные std :: atomic write? , где мой ответ повторяет много того, что я написал ниже.

Текущие компиляторы фактически не делают этого (пока), но не потому, что им не разрешено. C ++ WG21 / P0062R1: Когда компиляторы оптимизируют атомику? обсуждается ожидание того, что многие программисты не могут сделать «удивительные» оптимизации, и что стандарт может сделать, чтобы дать возможность программистам управлять. N4455 обсуждает множество примеров вещей, которые могут быть оптимизированы, включая этот. Он указывает, что вложение и постоянное распространение могут вводить такие вещи, как fetch_or(0) которые могут превращаться в просто load() (но все же имеют семантику получения и выпуска), даже если исходный источник не имел явно избыточные атомные операции.

Настоящие разработчики причин не делают этого (пока): (1) никто не написал сложный код, который позволил бы компилятору сделать это безопасно (без каких-либо ошибок), и (2) он потенциально нарушает принцип наименьшего удивление . Код без блокировки достаточно прост, чтобы правильно писать. Поэтому не используйте случайное использование атомного оружия: они не дешевы и не оптимизируют. Не всегда легко избежать избыточных атомных операций с помощью std::shared_ptr , хотя, поскольку нет неатомной версии (хотя один из ответов здесь дает простой способ определить shared_ptr_unsynchronized для gcc ).


Возrotation к num++; num-=2; num++; num-=2; компиляция, как если бы это было num-- :: Составителям разрешено делать это, если num является volatile std::atomic . Если переупорядочение возможно, правило as-if позволяет компилятору во время компиляции решать, что это всегда происходит именно так. Ничто не гарантирует, что наблюдатель мог видеть промежуточные значения (результат num++ ).

Т.е. если порядок, в котором ничто не становится глобально видимым между этими операциями, совместимо с требованиями упорядочения источника (в соответствии с правилами C ++ для абстрактной машины, а не целевой архитектурой), компилятор может испускать один lock dec dword [num] вместо lock inc dword [num] / lock sub dword [num], 2 .

num++; num-- num++; num-- не может исчезнуть, потому что у него все еще есть синхронизация с отношениями с другими streamами, которые смотрят на num , и это как загрузка, так и релиз-хранилище, которая запрещает переупорядочение других операций в этом streamе. Для x86 это может скомпилировать MFENCE вместо lock add dword [num], 0 (т.е. num += 0 ).

Как обсуждалось в PR0062 , более агрессивное слияние несмежных атомных операндов во время компиляции может быть плохим (например, счетчик прогресса только обновляется один раз в конце, а не каждую итерацию), но он также может помочь производительности без нижних уровней (например, пропускать atomic inc / dec ref ref, когда копия shared_ptr создается и уничтожается, если компилятор может доказать, что существует еще один объект shared_ptr для всего срока службы временного.)

Даже num++; num-- num++; num-- merging может num++; num-- ущерб справедливости реализации блокировки, когда один stream сразу разблокирует и снова блокирует. Если он никогда не будет выпущен в asm, даже аппаратные механизмы арбитража не дадут другому streamу возможность захватить блокировку в этот момент.


С текущими gcc6.2 и clang3.9 вы по-прежнему получаете отдельные операции lock даже с memory_order_relaxed в наиболее явно оптимизируемом случае. ( Godbolt compiler explorer, чтобы вы могли видеть, отличаются ли последние версии.)

 void multiple_ops_relaxed(std::atomic& 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&): 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 

другой наблюдательный stream (даже игнорируя задержки синхронизации синхронизации) не имеет возможности наблюдать за отдельными изменениями.

по сравнению с:

 #include  void f(std::atomic& num) { num = 0; num++; --num; num += 6; num -=5; --num; } 

где результат:

 f(std::atomic&): 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 

Теперь каждая модификация: –

  1. наблюдаемые в другом streamе, и
  2. почти одинаковые модификации, происходящие в других streamах.

атомарность – это не только уровень инструкций, но и весь конвейер от процессора, через кеши, память и обратно.

Дополнительная информация

Что касается эффекта оптимизации обновлений std::atomic s.

Стандарт c ++ имеет правило «как будто», которым разрешено компилятору переупорядочить код, и даже переписать код при условии, что результат имеет те же самые наблюдаемые эффекты (включая побочные эффекты), как если бы он просто выполнил ваши код.

Правило as-if является консервативным, в частности, с использованием атомистики.

рассматривать:

 void incdec(int& num) { ++num; --num; } 

Поскольку нет блокировок мьютексов, атоматики или каких-либо других конструкций, которые влияют на секвенирование между streamами, я бы сказал, что компилятор может свободно переписать эту функцию как NOP, например:

 void incdec(int&) { // nada } 

Это связано с тем, что в модели памяти c ++ нет возможности для другого streamа, наблюдающего результат приращения. Разумеется, было бы иначе, если num был volatile (может повлиять на поведение аппаратного обеспечения). Но в этом случае эта функция будет единственной функцией, изменяющей эту память (иначе программа будет плохо сформирована).

Однако это отличная игра в мяч:

 void incdec(std::atomic& num) { ++num; --num; } 

num – атомный. Изменения в ней должны наблюдаться для других streamов, которые просматриваются. Изменения самих этих streamов (например, установка значения до 100 между приращением и декрементом) будут иметь очень далеко идущие последствия для возможного значения num.

Вот демонстрация:

 #include  #include  int main() { for (int iter = 0 ; iter < 20 ; ++iter) { std::atomic 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 увеличивается только один раз.

Инструкция add не является атомарной. Он ссылается на память, а на двух процессорных ядрах может быть разный локальный кеш этой памяти.

IIRC атомный вариант команды add называется lock xadd

Поскольку строка 5, которая соответствует num ++, является одной инструкцией, можем ли мы заключить, что num ++ является атомарным в этом случае?

Опираясь на сборку «обратного инжиниринга», опасно делать выводы. Например, вы, похоже, скомпилировали свой код с отключенной оптимизацией, иначе компилятор бы выбросил эту переменную или загрузил 1 прямо в нее без вызова operator++ . Поскольку сгенерированная assembly может значительно измениться, на основе флагов оптимизации, целевого ЦП и т. Д., Ваш вывод основан на песке.

Кроме того, ваша идея о том, что одна инструкция по сборке означает, что операция является атомарной, также неверна. Это add не будет атомарным в многопроцессорных системах, даже в архитектуре x86.

Даже если ваш компилятор всегда испускал это как атомную операцию, одновременное обращение к num из любого другого streamа будет представлять собой гонку данных в соответствии со стандартами C ++ 11 и C ++ 14, и программа будет иметь неопределенное поведение.

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

 int main() { std::unique_ptr> vec; int ready = 0; std::thread t{[&] { while (!ready); // use "vec" here }); vec.reset(new std::vector()); ++ready; t.join(); } 

Даже если мы с оптимизмом предположим, что ++ready «атомарно» и что компилятор генерирует цикл проверки по мере необходимости (как я уже сказал, это UB, и поэтому компилятор может свободно его удалять, заменять его бесконечным циклом и т. Д. ), компилятор может по-прежнему перемещать назначение указателя или, что еще хуже, инициализацию vector до точки после операции инкремента, вызывая хаос в новом streamе. На практике я не был бы удивлен, если оптимизирующий компилятор полностью удалит ready переменную и цикл проверки, поскольку это не влияет на наблюдаемое поведение в соответствии с языковыми правилами (в отличие от ваших частных надежд).

Фактически, на прошлогодней конференции Meeting C ++, я слышал от двух разработчиков компилятора, что они с радостью реализуют оптимизации, которые делают наивно написанные многопоточные программы неправильными, если позволяют языковые правила, если даже небольшое улучшение производительности наблюдается в правильно написанных программах.

Наконец, даже если вы не заботитесь о переносимости, а ваш компилятор был волшебным, процессор, который вы используете, скорее всего, является суперскалярным типом CISC и будет разбивать инструкции на микрооперации, переупорядочивать и / или спекулятивно исполнять их, в объеме, ограниченном только синхронизацией примитивов, таких как (на Intel) префикс LOCK или заграждения памяти, чтобы максимизировать операции в секунду.

Короче говоря, естественными обязанностями streamобезопасного программирования являются:

  1. Ваша обязанность – писать код, который имеет четко определенное поведение по языковым правилам (и, в частности, языковой стандартной модели памяти).
  2. Обязанность вашего компилятора – генерировать машинный код, который имеет то же хорошо определенное (наблюдаемое) поведение в модели памяти целевой архитектуры.
  3. Обязанность вашего процессора – выполнить этот код, чтобы наблюдаемое поведение совместимо с моделью памяти собственной архитектуры.

Если вы хотите сделать это по-своему, это может просто работать в некоторых случаях, но поймите, что гарантия недействительна, и вы будете нести полную ответственность за любые нежелательные результаты. 🙂

PS: Правильно написанный пример:

 int main() { std::unique_ptr> vec; std::atomic ready{0}; // NOTE the use of the std::atomic template std::thread t{[&] { while (!ready); // use "vec" here }); vec.reset(new std::vector()); ++ready; t.join(); } 

Это безопасно, потому что:

  1. Проверка ready не может быть оптимизирована в соответствии с языковыми правилами.
  2. С ++ready – перед проверкой, которая видит ready а не ноль, и другие операции не могут быть переупорядочены вокруг этих операций. Это связано с тем, что ++ready и проверка последовательно согласованы , что является еще одним термином, описанным в модели памяти C ++, и это запрещает это конкретное переупорядочение. Поэтому компилятор не должен изменять порядок инструкций, а также должен сказать CPU, что он не должен, например, откладывать запись в vec после приращения ready . Последовательное согласование является самой сильной гарантией атомарности в языковом стандарте. Доступны меньшие (и теоретически более дешевые) гарантии, например, с помощью других методов std::atomic , но они определенно предназначены только для экспертов и не могут быть оптимизированы разработчиками компилятора, потому что они редко используются.

On a single-core x86 machine, an add instruction will generally be atomic with respect to other code on the CPU 1 . An interrupt can’t split a single instruction down the middle.

Out-of-order execution is required to preserve the illusion of instructions executing one at a time in order within a single core, so any instruction running on the same CPU will either happen completely before or completely after the add.

Modern x86 systems are multi-core, so the uniprocessor special case doesn’t apply.

If one is targeting a small embedded PC and has no plans to move the code to anything else, the atomic nature of the “add” instruction could be exploited. On the other hand, platforms where operations are inherently atomic are becoming more and more scarce.

(This doesn’t help you if you’re writing in C++, though. Compilers don’t have an option to require num++ to compile to a memory-destination add or xadd without a lock prefix. They could choose to load num into a register and store the increment result with a separate instruction, and will likely do that if you use the result.)


Footnote 1: The lock prefix existed even on original 8086 because I/O devices operate concurrently with the CPU; drivers on a single-core system need lock add to atomically increment a value in device memory if the device can also modify it, or with respect to DMA access.

Back in the day when x86 computers had one CPU, the use of a single instruction ensured that interrupts would not split the read/modify/write and if the memory would not be used as a DMA buffer too, it was atomic in fact (and C++ did not mention threads in the standard so this wasn’t addresses).

When it was rare to have a dual core (Pentium Pro) on a customer desktop, I effectively used this to avoid the LOCK prefix on a single core machine and improve performance.

Today, it would only help against multiple threads that were all set to the same CPU affinity, so the threads you are worried about would only come into play via time slice expiring and running the other thread on the same CPU (core). That is not realistic.

With modern x86/x64 processors, the single instruction is broken up into several micro ops and furthermore the memory reading and writing is buffered. So different threads running on different CPUs will not only see this as non-atomic but may see inconsistent results concerning what it reads from memory and what it assumes other threads have read to that point in time: you need to add memory fenses to restore sane behavior.

No. https://www.youtube.com/watch?v=31g0YE61PLQ (That’s just a link to the “No” scene from “The Office”)

Do you agree that this would be a possible output for the program:

sample output:

 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 

If so, then the compiler is free to make that the only possible output for the program, in whichever way the compiler wants. ie a main() that just puts out 100s.

This is the “as-if” rule.

And regardless of output, you can think of thread synchronization the same way – if thread A does num++; num--; and thread B reads num repeatedly, then a possible valid interleaving is that thread B never reads between num++ and num-- . Since that interleaving is valid, the compiler is free to make that the only possible interleaving. And just remove the incr/decr entirely.

There are some interesting implications here:

 while (working()) progress++; // atomic, global 

(ie imagine some other thread updates a progress bar UI based on progress )

Can the compiler turn this into:

 int local = 0; while (working()) local++; progress += local; 

probably that is valid. But probably not what the programmer was hoping for 🙁

The committee is still working on this stuff. Currently it “works” because compilers don’t optimize atomics much. But that is changing.

And even if progress was also volatile, this would still be valid:

 int local = 0; while (working()) local++; while (local--) progress++; 

:-/

Yes, but…

Atomic is not what you meant to say. You’re probably asking the wrong thing.

The increment is certainly atomic . Unless the storage is misaligned (and since you left alignment to the compiler, it is not), it is necessarily aligned within a single cache line. Short of special non-caching streaming instructions, each and every write goes through the cache. Complete cache lines are being atomically read and written, never anything different.
Smaller-than-cacheline data is, of course, also written atomically (since the surrounding cache line is).

Is it thread-safe?

This is a different question, and there are at least two good reasons to answer with a definite “No!” ,

First, there is the possibility that another core might have a copy of that cache line in L1 (L2 and upwards is usually shared, but L1 is normally per-core!), and concurrently modifies that value. Of course that happens atomically, too, but now you have two “correct” (correctly, atomically, modified) values — which one is the truly correct one now?
The CPU will sort it out somehow, of course. But the result may not be what you expect.

Second, there is memory ordering, or worded differently happens-before guarantees. The most important thing about atomic instructions is not so much that they are atomic . It’s ordering.

You have the possibility of enforcing a guarantee that everything that happens memory-wise is realized in some guaranteed, well-defined order where you have a “happened before” guarantee. This ordering may be as “relaxed” (read as: none at all) or as strict as you need.

For example, you can set a pointer to some block of data (say, the results of some calculation) and then atomically release the “data is ready” flag. Now, whoever acquires this flag will be led into thinking that the pointer is valid. And indeed, it will always be a valid pointer, never anything different. That’s because the write to the pointer happened-before the atomic operation.

That a single compiler’s output, on a specific CPU architecture, with optimizations disabled (since gcc doesn’t even compile ++ to add when optimizing in a quick&dirty example ), seems to imply incrementing this way is atomic doesn’t mean this is standard-compliant (you would cause undefined behavior when trying to access num in a thread), and is wrong anyways, because add is not atomic in x86.

Note that atomics (using the lock instruction prefix) are relatively heavy on x86 ( see this relevant answer ), but still remarkably less than a mutex, which isn’t very appropriate in this use-case.

Following results are taken from clang++ 3.8 when compiling with -Os .

Incrementing an int by reference, the “regular” way :

 void inc(int& x) { ++x; } 

This compiles into :

 inc(int&): incl (%rdi) retq 

Incrementing an int passed by reference, the atomic way :

 #include  void inc(std::atomic& x) { ++x; } 

This example, which is not much more complex than the regular way, just gets the lock prefix added to the incl instruction – but caution, as previously stated this is not cheap. Just because assembly looks short doesn’t mean it’s fast.

 inc(std::atomic&): lock incl (%rdi) retq 

When your compiler uses only a single instruction for the increment and your machine is single-threaded, your code is safe. ^^

Try compiling the same code on a non-x86 machine, and you’ll quickly see very different assembly results.

The reason num++ appears to be atomic is because on x86 machines, incrementing a 32-bit integer is, in fact, atomic (assuming no memory retrieval takes place). But this is neither guaranteed by the c++ standard, nor is it likely to be the case on a machine that doesn’t use the x86 instruction set. So this code is not cross-platform safe from race conditions.

You also don’t have a strong guarantee that this code is safe from Race Conditions even on an x86 architecture, because x86 doesn’t set up loads and stores to memory unless specifically instructed to do so. So if multiple threads tried to update this variable simultaneously, they may end up incrementing cached (outdated) values

The reason, then, that we have std::atomic and so on is so that when you’re working with an architecture where the atomicity of basic computations is not guaranteed, you have a mechanism that will force the compiler to generate atomic code.

  • WPF загружает анимацию в отдельный stream пользовательского интерфейса? (С #)
  • Является ли NodeJS действительно однопоточным?
  • Выполнение задачи в фоновом режиме в приложении WPF
  • Как сделать блокировку с несколькими чтениями / одиночной записью из более простых примитивов синхронизации?
  • Java Thread Мусор собран или нет
  • Насколько дорого стоит инструкция блокировки?
  • оператор присваивания '=' атомный?
  • Использование SynchronizationContext для отправки событий обратно в пользовательский интерфейс для WinForms или WPF
  • Давайте будем гением компьютера.