В общем случае for int num
, num++
(или ++num
) как операция чтения-изменения-записи не является атомарной . Но я часто вижу, как компиляторы, например GCC , генерируют для него следующий код ( попробуйте здесь ):
Так как строка 5, которая соответствует num++
одной инструкции, можем ли мы сделать вывод, что num++
это атомарный в этом случае она ?
И если это так, значит ли это, что сгенерированный num++
может быть использован в параллельных (многопоточных) сценариях без какой-либо опасности гонок данных (т.е. нам не нужно, например, делать это,std::atomic<int>
и налагать связанные с этим расходы, так как это атомно в любом случае)?
ОБНОВИТЬ
Обратите внимание , что этот вопрос не является ли приращение является атомным (это не так и что было и есть открытие линия вопроса). Дело в том, может ли это быть в определенных сценариях, т. Е. Может ли в некоторых случаях использоваться природа с одной инструкцией, чтобы избежать издержек lock
префикса. И, как говорится в принятом ответе в разделе об однопроцессорных машинах, а также в этом ответе , объяснение разговора в его комментариях и других, он может (хотя и не с C или C ++).
add
это атомно?std::atomic<int>
.add
инструкции другое ядро может украсть этот адрес памяти из кеша этого ядра и изменить его. На процессоре x86add
инструкция должна иметьlock
префикс, если адрес должен быть заблокирован в кеше на время операции.Ответы:
Это абсолютно то, что C ++ определяет как гонку данных, которая вызывает неопределенное поведение, даже если один компилятор произвел код, который сделал то, что вы надеялись на какой-то целевой машине. Вы должны использовать
std::atomic
для надежных результатов, но вы можете использовать его сmemory_order_relaxed
если вы не заботитесь о переупорядочении. Ниже приведен пример использования кода и вывода asmfetch_add
.Но сначала вопрос языка ассемблера:
Инструкции назначения памяти (кроме чистых хранилищ) являются операциями чтения-изменения-записи, которые выполняются в несколько внутренних шагов . Ни один архитектурный регистр не изменен, но ЦПУ должен хранить данные внутри, пока он отправляет их через свой АЛУ . Фактический регистровый файл - это лишь небольшая часть хранилища данных даже в самом простом ЦП, с защелками, содержащими выходы одной ступени в качестве входных данных для другой ступени и т. Д. И т. Д.
Операции с памятью из других процессоров могут стать глобально видимыми между загрузкой и хранением. Т.е. два потока, работающие
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++
не нужно, не сообщая компилятору о том, что он нужен для компиляции в одну реализацию чтения-изменения-записи:Это очень вероятно, если вы используете значение
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++
по вызову функции (если он встроен в функцию или знает, что не смотритflag
). Тогда он может полностью оптимизировать модификацию, потому чтоflag
не является четнымvolatile
. (И нет, C ++volatile
не является полезной заменой std :: atomic. Std :: atomic заставляет компилятор предполагать, что значения в памяти могут быть изменены асинхронно аналогичноvolatile
, но это гораздо больше, чем это. Кроме того,volatile std::atomic<int> foo
это не какstd::atomic<int> foo
и в случае с @Richard Hodges.)Определение гонок данных для неатомарных переменных как неопределенного поведения - это то, что позволяет компилятору по-прежнему поднимать загрузки и приемники хранилищ из циклов, а также многие другие оптимизации памяти, на которые могут ссылаться несколько потоков. (См. Этот блог LLVM для получения дополнительной информации о том, как UB обеспечивает оптимизацию компилятора.)
Как я уже упоминал, префикс x86
lock
является полным барьером памяти, поэтому при использованииnum.fetch_add(1, std::memory_order_relaxed);
генерируется тот же код на x86, что иnum++
(по умолчанию используется последовательная согласованность), но он может быть гораздо более эффективным на других архитектурах (например, ARM). Даже на x86, relaxed позволяет больше переупорядочения во время компиляции.Это то, что GCC делает на x86 для нескольких функций, работающих с
std::atomic
глобальной переменной.Смотрите исходный + ассемблерный код, отформатированный в проводнике компилятора Godbolt . Вы можете выбрать другие целевые архитектуры, в том числе ARM, MIPS и PowerPC, чтобы увидеть, какой код ассемблера вы получаете из атомарного кода для этих целей.
Обратите внимание на то, что 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, чтобы вы могли увидеть, отличаются ли последние версии.)источник
mov eax, 1
xadd [num], eax
(без префикса блокировки) реализацию постинкрементаnum++
, но это не то, что делают компиляторы.... а теперь давайте включим оптимизацию:
Хорошо, давайте дадим ему шанс:
результат:
другой поток наблюдения (даже игнорируя задержки синхронизации кэша) не имеет возможности наблюдать за отдельными изменениями.
сравнить с:
где результат:
Теперь каждая модификация:
атомарность не только на уровне команд, она включает в себя весь конвейер от процессора, через кеш, к памяти и обратно.
Дальнейшая информация
По поводу эффекта оптимизации обновлений
std::atomic
с.Стандарт C ++ имеет правило «как будто», согласно которому компилятору разрешается переупорядочивать код и даже переписывать код при условии, что результат имеет точно такие же наблюдаемые эффекты (включая побочные эффекты), как если бы он просто выполнил ваш код.
Правило «как будто» является консервативным, особенно с участием атомщиков.
рассматривать:
Поскольку нет блокировок мьютекса, атомик или любых других конструкций, которые влияют на межпотоковое секвенирование, я бы сказал, что компилятор может переписать эту функцию как NOP, например:
Это связано с тем, что в модели памяти c ++ нет возможности для другого потока, наблюдающего результат приращения. Было бы, конечно , иначе , если бы
num
былоvolatile
(может повлиять на аппаратное поведение). Но в этом случае эта функция будет единственной функцией, изменяющей эту память (в противном случае программа будет некорректной).Тем не менее, это другая игра с мячом:
num
это атом. Изменения в нем должны быть заметны для других потоков, которые смотрят. Изменения, которые сами эти потоки вносят (например, устанавливают значение 100 между приращением и уменьшением), будут иметь далеко идущие последствия для конечного значения num.Вот демо:
образец вывода:
источник
add dword [rdi], 1
является атомарным (без префикса). Загрузка атомарна, а хранилище атомарно, но ничто не мешает другому потоку изменить данные между загрузкой и хранилищем. Таким образом, магазин может наступить на модификацию, сделанную другим потоком. См. Jfdube.wordpress.com/2011/11/30/understanding-atomic-operations . Кроме того, статьи Джеффа Прешинга без блокировок очень хороши , и он упоминает основную проблему RMW в этой вводной статье.lock
num++
иnum--
. Если вы можете найти раздел в стандарте, который требует этого, он решит это. Я почти уверен, что требуется только то, чтобы ни один наблюдатель не мог увидеть неправильный переупорядочение, которое не требует выхода. Поэтому я думаю, что это просто вопрос качества реализации.Без особых сложностей такая инструкция
add DWORD PTR [rbp-4], 1
очень в стиле CISC.Он выполняет три операции: загружает операнд из памяти, увеличивает его, сохраняет операнд в памяти.
Во время этих операций центральный процессор получает и освобождает шину дважды, между любыми другими агентами тоже может получить ее, и это нарушает атомарность.
Х увеличивается только один раз.
источник
Инструкция добавления не является атомарной. Он ссылается на память, и два ядра процессора могут иметь различный локальный кеш этой памяти.
IIRC атомарный вариант инструкции add называется lock xadd
источник
lock xadd
реализует C ++ std :: atomicfetch_add
, возвращая старое значение. Если вам это не нужно, компилятор будет использовать обычные инструкции назначения памяти сlock
префиксом.lock add
илиlock inc
.add [mem], 1
все равно не будет атомарным на SMP-машине без кеша, смотрите мои комментарии к другим ответам.Опасно делать выводы на основе «обратной инженерии» созданной сборки. Например, вы, кажется, скомпилировали свой код с отключенной оптимизацией, иначе компилятор выбросил бы эту переменную или загрузил бы 1 непосредственно в нее, не вызывая
operator++
. Поскольку сгенерированная сборка может значительно измениться в зависимости от флагов оптимизации, целевого процессора и т. Д., Ваш вывод основан на песке.Кроме того, ваша идея, что одна инструкция по сборке означает, что операция является атомарной, также неверна. Это
add
не будет атомарным в многопроцессорных системах, даже в архитектуре x86.источник
Даже если ваш компилятор всегда выдавал это как атомарную операцию, одновременный доступ
num
из любого другого потока будет представлять собой гонку данных в соответствии со стандартами C ++ 11 и C ++ 14, и программа будет иметь неопределенное поведение.Но это хуже, чем это. Во-первых, как уже упоминалось, инструкция, генерируемая компилятором при увеличении переменной, может зависеть от уровня оптимизации. Во-вторых, компилятор может переупорядочить другие обращения к памяти,
++num
еслиnum
он не атомарный, напримерДаже если мы с оптимизмом предположим, что
++ready
это «атомарно», и что компилятор генерирует цикл проверки по мере необходимости (как я уже сказал, это UB, и поэтому компилятор свободен удалить его, заменить его бесконечным циклом и т. Д.), Компилятор может по-прежнему перемещать назначение указателя или, что еще хуже, инициализацию переменной вvector
точку после операции увеличения, вызывая хаос в новом потоке. На практике я совсем не удивлюсь, если оптимизирующий компилятор полностью удалитready
переменную и цикл проверки, поскольку это не влияет на наблюдаемое поведение в соответствии с правилами языка (в отличие от ваших личных надежд).Фактически, на прошлогодней конференции Meeting C ++ я слышал от двух разработчиков компиляторов, что они с радостью реализуют оптимизацию, которая делает наивно написанные многопоточные программы некорректными, если это позволяют языковые правила, даже если наблюдается незначительное улучшение производительности. в правильно написанных программах.
Наконец, даже если вы не заботитесь о переносимости, и ваш компилятор был волшебно хорош, используемый вами процессор, скорее всего, имеет суперскалярный тип CISC и будет разбивать инструкции на микрооперации, переупорядочивать и / или умело выполнять их, в той степени, которая ограничена только синхронизацией примитивов, таких как (на Intel)
LOCK
префикс или память, чтобы максимизировать количество операций в секунду.Короче говоря, естественными обязанностями многопоточного программирования являются:
Если вы хотите сделать это по-своему, в некоторых случаях это может сработать, но вы понимаете, что гарантия недействительна, и вы будете нести полную ответственность за любые нежелательные результаты. :-)
PS: правильно написанный пример:
Это безопасно, потому что:
ready
не может быть оптимизирована в соответствии с правилами языка.++ready
происходит до», которая видитready
не равной нулю, и другие операции не могут быть переупорядочены вокруг этих операций. Это потому, что++ready
и проверка последовательно согласована , что является еще одним термином, описанным в модели памяти C ++, и которое запрещает это конкретное переупорядочение. Следовательно, компилятор не должен переупорядочивать инструкции, а также должен сообщать ЦПУ, что он не должен, например, откладывать записьvec
в после увеличенияready
. Последовательно последовательный является самой сильной гарантией атомности в языковом стандарте. Меньшие (и теоретически более дешевые) гарантии доступны, например, с помощью других методовstd::atomic<T>
, но это определенно только для экспертов, и разработчики компиляторов могут не очень оптимизировать его, потому что они используются редко.источник
ready
, он, вероятно, скомпилируетсяwhile (!ready);
во что-то более похожееif(!ready) { while(true); }
. Upvoted: ключевая часть std :: atomic меняет семантику для принятия асинхронной модификации в любой точке. Наличие UB обычно - это то, что позволяет компиляторам поднимать нагрузки и выводить хранилища из циклов.На одноядерном компьютере с архитектурой x86
add
инструкция обычно будет атомарной по отношению к другому коду на процессоре 1 . Прерывание не может разбить одну инструкцию посередине.Выполнение не по порядку требуется для сохранения иллюзии выполнения команд по одному в одном ядре, поэтому любая команда, выполняющаяся на одном и том же процессоре, будет происходить полностью до или полностью после добавления.
Современные системы x86 являются многоядерными, поэтому однопроцессорный особый случай не применяется.
Если кто-то нацелен на небольшой встроенный ПК и не планирует переносить код на что-либо еще, можно использовать атомарную природу инструкции «добавить». С другой стороны, платформы, где операции по сути являются атомарными, становятся все более и более редкими.
(Это не поможет вам , если вы пишете в C ++, хотя. Составители не имеет возможность требовать ,
num++
чтобы собрать в памяти назначение оного или XADD без сlock
приставкой. Они могут выбрать для нагрузкиnum
в регистр и магазин результат приращения с отдельной инструкцией, и, скорее всего, это будет сделано, если вы используете результат.)Сноска 1:
lock
Префикс существовал даже на оригинальном 8086, потому что устройства ввода-вывода работают одновременно с процессором; драйверы в одноядерной системе должныlock add
атомарно увеличивать значение в памяти устройства, если устройство также может изменить его, или в отношении доступа к DMA.источник
В те времена, когда на компьютерах x86 был один ЦП, использование одной инструкции гарантировало, что прерывания не будут разбивать чтение / изменение / запись, и если память не будет использоваться и в качестве буфера DMA, то она была атомарной на самом деле (и C ++ не упомянул потоки в стандарте, поэтому это не рассматривалось).
Когда было редко иметь двухпроцессорный процессор (например, Pentium Pro с двумя сокетами) на рабочем столе клиента, я эффективно использовал его, чтобы избежать префикса LOCK на одноядерном компьютере и повысить производительность.
Сегодня это помогло бы только нескольким потокам, для которых все настроены на одно и то же соответствие процессору, поэтому потоки, о которых вы беспокоитесь, вступят в игру только через истечение интервала времени и запуск другого потока на том же процессоре (ядре). Это нереально.
В современных процессорах x86 / x64 отдельная инструкция разбита на несколько микроопераций, и, кроме того, буферизуется чтение и запись в память. Таким образом, разные потоки, работающие на разных процессорах, не только увидят это как неатомарное, но и могут увидеть противоречивые результаты относительно того, что он читает из памяти, и что он предполагает, что другие потоки прочитали к этому моменту времени: вам нужно добавить ограничения памяти для восстановления нормального состояния. поведение.
источник
a = 1; b = a;
чтобы правильно загрузить только что сохраненную вами 1.Нет. Https://www.youtube.com/watch?v=31g0YE61PLQ (это просто ссылка на сцену «Нет» из «Офиса»)
Согласны ли вы с тем, что это будет возможным выходом для программы:
образец вывода:
Если это так, то компилятор может сделать это единственно возможным выходным сигналом для программы, в зависимости от того, чего хочет компилятор. то есть main (), который просто выдает 100s.
Это правило «как будто».
И независимо от вывода, вы можете думать о синхронизации потоков одинаково - если поток A делает
num++; num--;
и поток B читаетnum
многократно, то возможное допустимое перемежение состоит в том, что поток B никогда не читает междуnum++
иnum--
. Поскольку это перемежение допустимо, компилятор может сделать это единственным возможным чередованием. И просто полностью удалите incr / decr.Здесь есть несколько интересных последствий:
(т.е. представьте, что какой-то другой поток обновляет пользовательский интерфейс индикатора выполнения на основе
progress
)Может ли компилятор превратить это в:
вероятно, это действительно. Но, вероятно, не то, на что надеялся программист :-(
Комитет все еще работает над этим. В настоящее время это «работает», потому что компиляторы не сильно оптимизируют атомику. Но это меняется.
И даже если бы он
progress
был также волатильным, он все равно действовал бы:: - /
источник
volatile
атомных объектов, когда это не нарушает другие правила. Два документа для обсуждения стандартов обсуждают именно это (ссылки в комментарии Ричарда ), один из которых использует один и тот же пример счетчика прогресса. Так что это проблема качества реализации, пока C ++ не стандартизирует способы ее предотвращения.lock
в каждую операцию. Или какая-то комбинация компилятор + однопроцессор, в которой ни один из них не переупорядочивал (т. Е. «Добрые старые времена») - все атомарно. Но какой в этом смысл? Вы не можете действительно полагаться на это. Если вы не знаете, для какой системы вы пишете. (Даже тогда лучше было бы, чтобы atomic <int> не добавлял лишних операций в этой системе. Поэтому вы все равно должны написать стандартный код ...)And just remove the incr/decr entirely.
это не совсем правильно. Это все еще операция захвата и выпускаnum
. На x86num++;num--
можно было просто скомпилировать MFENCE, но точно ничего. (Если только анализ всей программы компилятора не может доказать, что ничего не синхронизируется с этой модификацией num, и что не имеет значения, если некоторые хранилища до этого откладываются до последующих загрузок после этого.) Например, если это было -lock-right-сразу сценарий использования, у вас все еще есть два отдельных критических раздела (возможно, с использованием mo_relaxed), а не один большой.Да, но...
Атомная не то, что вы хотели сказать. Вы, вероятно, спрашиваете не то.
Прирост, безусловно, атомарный . Если хранилище не выровнено (и поскольку вы оставили выравнивание для компилятора, это не так), оно обязательно выровняется в пределах одной строки кэша. Если не считать специальных потоковых инструкций без кэширования, каждая запись проходит через кэш. Полные строки кэша читаются и пишутся атомарно, и никогда ничего другого.
Данные меньшего размера, чем в кэше, конечно же, также пишутся атомарно (поскольку окружающая строка кэша).
Это потокобезопасно?
Это другой вопрос, и есть по крайней мере две веские причины, чтобы ответить с определенным «Нет!» ,
Во-первых, существует вероятность того, что другое ядро может иметь копию этой строки кэша в L1 (L2 и выше обычно совместно используются, но L1 обычно для каждого ядра!), И одновременно изменяет это значение. Конечно, это тоже происходит атомарно, но теперь у вас есть два «правильных» (правильно, атомарно, модифицированных) значения - какое из них является действительно правильным сейчас?
Конечно, процессор это как-то разберут. Но результат может оказаться не таким, как вы ожидаете.
Во-вторых, память упорядочена, или иначе сформулировано - до гарантии. Самое важное в атомарных инструкциях не столько, что они атомарные . Это заказ.
У вас есть возможность обеспечить гарантию того, что все, что происходит с памятью, реализовано в каком-то гарантированном, четко определенном порядке, в котором у вас есть гарантия «произошло раньше». Этот порядок может быть таким «расслабленным» (читай как: вообще ничего) или настолько строгим, насколько вам нужно.
Например, вы можете установить указатель на какой - то блок данных (например, результаты некоторых вычислений) , а затем атомарно освободить «Данные готов» флаг. Теперь тот, кто приобретет этот флаг, будет думать, что указатель действителен. И действительно, это всегда будет действительный указатель, никогда ничего другого. Это потому, что запись в указатель произошла до атомарной операции.
источник
То, что вывод одного компилятора на конкретной архитектуре ЦП с отключенными оптимизациями (поскольку gcc даже не компилируется
++
сadd
оптимизацией в быстром и грязном примере ), кажется, подразумевает, что увеличение этого способа является атомарным, не означает, что оно соответствует стандартам вы бы вызвали неопределенное поведение при попытке доступаnum
в потоке), и в любом случае это неправильно, потому что неadd
является атомарным в x86.Обратите внимание, что атомарность (с использованием
lock
префикса инструкции) относительно тяжелая для x86 ( см. Соответствующий ответ ), но все же значительно меньше, чем мьютекс, что не очень уместно в этом случае использования.Следующие результаты взяты из clang ++ 3.8 при компиляции с
-Os
.Инкремент int по ссылке «обычным» способом:
Это компилируется в:
Инкремент int, передаваемый по ссылке, атомарным способом:
В этом примере, который не намного сложнее обычного, просто
lock
добавляется префикс кincl
инструкции, но будьте осторожны, как уже говорилось ранее, это не дешево. То, что сборка выглядит короткой, не означает, что она быстрая.источник
Когда ваш компилятор использует только одну инструкцию для приращения, а ваша машина однопоточна, ваш код безопасен. ^^
источник
Попробуйте скомпилировать один и тот же код на компьютере, отличном от x86, и вы быстро увидите очень разные результаты сборки.
Причина
num++
, по-видимому, атомарная, потому что на компьютерах с архитектурой x86 увеличение 32-разрядного целого числа фактически является атомарным (при условии, что не происходит извлечение памяти). Но это не гарантируется стандартом c ++ и, скорее всего, не имеет места на машине, которая не использует набор команд x86. Таким образом, этот код не защищен от кроссплатформенности.У вас также нет строгой гарантии того, что этот код защищен от условий гонки даже на архитектуре x86, поскольку x86 не устанавливает нагрузки и не сохраняет их в памяти, если это не указано специально. Таким образом, если несколько потоков попытались обновить эту переменную одновременно, они могут в конечном итоге увеличить кэшированные (устаревшие) значения
Следовательно, причина того, что у нас есть
std::atomic<int>
и так далее, заключается в том, что когда вы работаете с архитектурой, в которой атомарность базовых вычислений не гарантируется, у вас есть механизм, который заставит компилятор генерировать атомарный код.источник
add
гарантированно атомарен? Я не удивлюсь, если приращения регистра будут атомарными, но это вряд ли полезно; чтобы сделать приращение регистра видимым для другого потока, он должен находиться в памяти, что потребует дополнительных инструкций для его загрузки и сохранения, удаляя атомарность. Я понимаю, что именно поэтомуlock
префикс существует для инструкций; единственный полезный элементadd
применяется к разыменованной памяти и используетlock
префикс, чтобы гарантировать, что строка кэша заблокирована на время операции .add
является атомарным, но я ясно дал понять, что это не означает, что код безопасен по состоянию гонки, потому что изменения не становятся глобально видимыми сразу.