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

Enter image description here

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

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

ОБНОВЛЕНИЕ

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

162
Lmn 8 Сен 2016 в 17:39

9 ответов

Лучший ответ

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


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

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

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

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


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

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

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

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

(Если инструкция lock ed работает с памятью, занимающей две строки кэша, требуется гораздо больше работы, чтобы убедиться, что изменения в обеих частях объекта остаются атомарными, поскольку они распространяются на всех наблюдателей, поэтому ни один наблюдатель не может видеть Разрыв. ЦП, возможно, придется заблокировать всю шину памяти, пока данные не попадут в память. Не переключайте атомные переменные!)

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


На однопроцессорной машине или в однопоточном процессе один Инструкция RMW на самом деле является атомарной без префикса lock. Единственный способ для другого кода получить доступ к общей переменной - это для ЦП переключение контекста, чего не может произойти в середине инструкции. Таким образом, простой dec dword [num] может синхронизироваться между однопоточной программой и ее обработчиками сигналов или в многопоточной программе, работающей на одноядерной машине. См. вторую половину моего ответа на другой вопрос и комментарии под ним, где я объясняю это более подробно.


Вернемся к C ++:

Совершенно бессмысленно использовать num++, не сообщая компилятору, что он вам нужен для компиляции в единственную реализацию чтения-изменения-записи:

;; Valid compiler output for num++
mov   eax, [num]
inc   eax
mov   [num], eax

Это очень вероятно, если вы используете значение num позже: компилятор сохранит его в регистре после увеличения. Так что даже если вы проверите, как num++ компилируется сам по себе, изменение окружающего кода может повлиять на него.

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


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

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

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

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

// flag is just a plain int global, not std::atomic<int>.
flag--;       // This isn't a real lock, but pretend it's somehow meaningful.
modify_a_data_structure(&foo);    // doesn't look at flag, and the compilers knows this.  (Assume it can see the function def).  Otherwise the usual don't-break-single-threaded-code rules come into play!
flag++;

Но этого не произойдет. Компилятор может свободно перемещать flag++ по вызову функции (если он встраивает функцию или знает, что не смотрит на flag). Затем он может полностью оптимизировать модификацию, потому что flag даже не volatile. (И нет, C ++ volatile не является полезной заменой std :: atomic. Std :: atomic действительно заставляет компилятор предполагать, что значения в памяти могут быть изменены асинхронно, как volatile, но есть гораздо больше Кроме того, volatile std::atomic<int> foo не то же самое, что std::atomic<int> foo, как обсуждалось с @Richard Hodges.)

Определение гонок данных для неатомарных переменных как Undefined Behavior - это то, что позволяет компилятору по-прежнему поднимать нагрузки и опускать хранилища из циклов, а также многие другие оптимизации для памяти, на которые могут ссылаться несколько потоков. (Подробнее см. этот блог 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 в целом строго заказывается, но изменение порядка StoreLoad разрешено. Наличие буфера хранилища необходимо для хорошей производительности конвейерного ЦП, работающего вне очереди. Джефф Прешинг переупорядочение памяти прямо сейчас < / strong> показывает последствия не использования 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> для 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-- не может исчезнуть, потому что у него все еще есть связь "Синхронизируется с другими потоками, которые смотрят на num", и это как загрузка-загрузка, так и хранилище выпуска, которые не позволяют переупорядочивать другие операции в этом нить. Для x86 это может быть способно скомпилировать в MFENCE вместо lock add dword [num], 0 (то есть num += 0).

Как обсуждалось в PR0062, более агрессивное слияние несмежных атомарных операций во время компиляции может быть плохим (например, счетчик выполнения обновляется только один раз в конце, а не на каждой итерации), но это также может помочь в производительности без негативных последствий (например, пропуск атомарных инкр. / дек. счетчиков ссылок при создании и уничтожении копии 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
201
Peter Cordes 25 Фев 2018 в 02:23

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

IIRC атомарный вариант инструкции добавления называется lock xadd .

11
Sven Nilsson 8 Сен 2016 в 14:54

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

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

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

10
Cody Gray 25 Окт 2016 в 14:38

На одноядерной машине x86 инструкция add обычно будет атомарной по отношению к другому коду ЦП 1 . Прерывание не может разбить одну инструкцию пополам.

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

Современные системы x86 являются многоядерными, поэтому особый случай однопроцессора здесь не применяется.

Если кто-то нацелен на небольшой встраиваемый ПК и не планирует переносить код на что-нибудь еще, атомарная природа инструкции «добавить» может быть использована. С другой стороны, платформы, на которых операции по своей природе атомарны, становятся все более редкими.

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


Сноска 1: Префикс lock существовал даже в оригинальном 8086, потому что устройства ввода-вывода работают одновременно с ЦП; Драйверы в одноядерной системе должны lock add атомарно увеличивать значение в памяти устройства, если устройство также может его изменять, или в отношении доступа к DMA.

9
Peter Cordes 16 Июл 2018 в 20:23

В те времена, когда компьютеры x86 имели один ЦП, использование одной инструкции гарантировало, что прерывания не будут разделять чтение / изменение / запись, и если память не будет также использоваться в качестве буфера DMA, на самом деле она была атомарной (и C ++ не упоминает потоки в стандарте, поэтому этот вопрос не рассматривался).

Когда двухпроцессор (например, двухпроцессорный Pentium Pro) на настольном компьютере клиента был редкостью, я эффективно использовал его, чтобы избежать префикса LOCK на одноядерном компьютере и повысить производительность.

Сегодня это помогло бы только против нескольких потоков, которые были настроены на одно и то же сродство к ЦП, поэтому потоки, о которых вы беспокоитесь, вступили бы в игру только через истечение срока действия и запуск другого потока на том же ЦП (ядре). Это нереально.

В современных процессорах x86 / x64 одна инструкция разбита на несколько микроопераций , и, кроме того, чтение и запись в память буферизуются. Таким образом, разные потоки, работающие на разных процессорах, не только будут рассматривать это как неатомарное, но и могут увидеть противоречивые результаты относительно того, что он читает из памяти и что, как предполагается, другие потоки прочитали к этому моменту времени: вам нужно добавить ограждения памяти для восстановления нормального поведения.

7
Peter Cordes 5 Фев 2019 в 00:25

Нет. 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

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

Это правило «как если бы».

И независимо от вывода, вы можете думать о синхронизации потоков одинаково - если поток A выполняет num++; num--;, а поток B читает num несколько раз, тогда возможное допустимое чередование состоит в том, что поток B никогда не читает между {{X2 }} и 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++;

: - /

4
tony 12 Сен 2016 в 22:25

Без особых сложностей такая инструкция, как add DWORD PTR [rbp-4], 1, очень похожа на CISC.

Он выполняет три операции: загружает операнд из памяти, увеличивает его, сохраняет операнд обратно в память.
Во время этих операций ЦП захватывает и освобождает шину дважды, в промежутках между ними любой другой агент тоже может захватить ее, и это нарушает атомарность.

AGENT 1          AGENT 2

load X              
inc C
                 load X
                 inc C
                 store X
store X

X увеличивается только один раз.

39
Margaret Bloom 8 Сен 2016 в 15:14

Когда ваш компилятор использует только одну инструкцию для увеличения, а ваша машина однопоточная, ваш код безопасен. ^^

-2
Bonita Montero 6 Май 2017 в 06:02

Попробуйте скомпилировать тот же код на машине, отличной от x86, и вы быстро увидите совсем другие результаты сборки.

Причина, по которой num++ кажется атомарным, заключается в том, что на машинах x86 увеличение 32-битного целого числа фактически является атомарным (при условии, что не происходит извлечения памяти). Но это не гарантируется стандартом С ++ и вряд ли будет иметь место на машине, которая не использует набор инструкций x86. Таким образом, этот код небезопасен для разных платформ в условиях гонки.

У вас также нет строгой гарантии, что этот код защищен от условий гонки даже на архитектуре x86, потому что x86 не настраивает загрузки и сохранения в памяти, если это специально не указано. Поэтому, если несколько потоков попытались обновить эту переменную одновременно, они могут в конечном итоге увеличить кешированные (устаревшие) значения.

Таким образом, причина того, что у нас есть std::atomic<int> и так далее, заключается в том, что когда вы работаете с архитектурой, в которой атомарность базовых вычислений не гарантируется, у вас есть механизм, который заставит компилятор генерировать атомарный код. .

-3
Xirema 8 Сен 2016 в 14:55