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

void f ()
{
  
  int num = 0;
  число ++;
}
f():
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], 0
        add     DWORD PTR [rbp-4], 1
        nop
        pop     rbp
        ret

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

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

ОБНОВЛЕНИЕ

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

Lmn

Ответы (13)

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


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

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

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

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


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

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

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

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

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

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


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


Вернуться в C ++:

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

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

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

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


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

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

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

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

// int flag;  is just a plain global, not std::atomic.
flag--;           // Pretend this is supposed to be some kind of locking attempt
modify_a_data_structure(&foo);    // doesn't look at flag, and the compiler knows this.  (Assume it can see the function def).  Otherwise the usual don't-break-single-threaded-code rules come into play!
flag++;

But it won't. The compiler is free to move the flag++ across the function call (if it inlines the function or knows that it doesn't look at flag). Then it can optimize away the modification entirely, because flag isn't even volatile.

(И нет, C ++ volatile не является полезной заменой std :: atomic. Std :: atomic действительно заставляет компилятор предполагать, что значения в памяти могут быть изменены асинхронно, как volatile, но это еще не все (на практике есть сходства между volatile int и std :: atomic с mo_relaxed для операций чистой загрузки и чистого хранения, но не для RMW). Кроме того, volatile std :: atomic foo не обязательно совпадает с std :: atomic foo, хотя текущие компиляторы не оптимизируют атомику (например, 2 back -to-back сохраняет одно и то же значение), поэтому volatile atomic не изменяет генерацию кода.)

Определение гонок данных для неатомарных переменных как Undefined Behavior - это то, что позволяет компилятору по-прежнему поднимать нагрузки и опускать хранилища из циклов, а также многие другие оптимизации для памяти, на которые могут ссылаться несколько потоков. (См. этот блог 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, чтобы увидеть, какой код на языке ассемблера вы получите от Atomics для этих целей.

#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 -; инструкция:

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

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

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


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

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

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

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

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


С текущими gcc6.2 и clang3.9 вы по-прежнему получаете отдельные операции locked даже с 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

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

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

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

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

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


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

Даже если ваш компилятор всегда выдает это как атомарную операцию, одновременный доступ к num из любого другого потока будет представлять собой гонку данных в соответствии со стандартами 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, и поэтому компилятор может удалить его, замените его бесконечным циклом и т. д.), компилятор может по-прежнему перемещать присвоение указателя или, что еще хуже, инициализацию вектора в точку после операции приращения, вызывая хаос в новом потоке. На практике я совсем не удивлюсь, если оптимизирующий компилятор полностью удалит переменную ready и цикл проверки, поскольку это не влияет на наблюдаемое поведение в соответствии с языковыми правилами (в отличие от ваших личных надежд).

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

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

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

  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. Проверки готовы не могут быть оптимизированы согласно языковым правилам.
  2. ++ ready происходит-до проверка, которая видит ready как ненулевое, и другие операции не могут быть переупорядочены вокруг этих операций. Это связано с тем, что ++ готов и проверка последовательно согласованы, это еще один термин, описанный в модели памяти C ++ и запрещающий это конкретное переупорядочение. Поэтому компилятор не должен переупорядочивать инструкции, а также должен сообщать процессору, что он не должен, например, отложить запись в vec на после приращения готов.Последовательно непротиворечивый - самая надежная гарантия относительно атомности в языковом стандарте. Доступны меньшие (и теоретически более дешевые) гарантии, например: с помощью других методов std :: atomic , но они определенно предназначены только для экспертов и не могут быть сильно оптимизированы разработчиками компилятора, потому что они редко используются.

Да, но ...

Atomic - это не то, что вы хотели сказать. Вы, наверное, не то спрашиваете.

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

Это потокобезопасно?

Это другой вопрос, и есть как минимум две веские причины, чтобы ответить определенным «Нет!».

Во-первых, существует вероятность того, что другое ядро ​​может иметь копию этой строки кэша в L1 (L2 и выше обычно используется совместно, но L1 обычно для каждого ядра!) И одновременно изменяет это значение. Конечно, это тоже происходит атомарно, но теперь у вас есть два «правильных» (правильно, атомарно, модифицированных) значения - какое из них сейчас действительно правильное?
Процессор, конечно, как-нибудь разберется. Но результат может быть не таким, как вы ожидаете.

Во-вторых, там упорядочивание памяти, иначе бывает формулировка - раньше гарантии. Самое главное в атомарных инструкциях не столько в том, что они атомарные. Это заказ.

У вас есть возможность обеспечить гарантию, что все, что происходит с памятью, реализуется в некотором гарантированном, четко определенном порядке, где у вас есть гарантия «произошло раньше». Этот порядок может быть как «смягченным» (читается как: вообще отсутствует), так и настолько строгим, насколько вам нужно.

Например, вы можете установить указатель на некоторый блок данных (скажем, на результаты некоторого расчета), а затем атомарно освободить флаг «данные готовы». Теперь тот, кто получит этот флаг, будет думать, что указатель действителен. И действительно, это будет всегда действительный указатель, и никогда ничего другого. Это потому, что запись в указатель произошла до атомарной операции.

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

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

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

• 100001 . Это нереально.

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

То, что вывод одного компилятора на конкретной архитектуре ЦП с отключенной оптимизацией (поскольку gcc даже не компилирует ++ в , добавляет при оптимизации в быстром и грязном пример), похоже, подразумевает, что увеличение этого способа является атомарным, не означает, что это соответствует стандарту (вы можете вызвать поведение undefined при попытке доступа к num в потоке) и в любом случае неверно, потому что add является не атомарным в x86.

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

Следующие результаты взяты из clang ++ 3.8 при компиляции с -Os.

Увеличение int по ссылке, «обычный» способ:

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

Это компилируется в:

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

Увеличение числа int, переданного по ссылке, атомарным способом:

#include 

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

В этом примере, который не намного сложнее обычного способа, просто добавлен префикс lock к инструкции incl - но осторожно, как было сказано ранее, это не дешево. То, что сборка выглядит короткой, не означает, что она быстрая.

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

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

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

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

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

AGENT 1          AGENT 2

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

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

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

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

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

Нет. 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 никогда не читает между num ++ и num -. Поскольку такое чередование допустимо, компилятор может сделать возможное чередование только. И просто удалите incr / decr полностью.

Здесь есть несколько интересных выводов:

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

(т.е. представьте, что какой-то другой поток обновляет пользовательский интерфейс индикатора выполнения на основе прогресса)

Может ли компилятор превратить это в:

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

progress += local;

вероятно, это действительно так. Но, вероятно, не то, на что надеялся программист: - (

Комитет все еще работает над этим. В настоящее время это «работает», потому что компиляторы мало оптимизируют атомики. Но это меняется.

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

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

while (local--)
    progress++;

: - /

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

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

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

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

... а теперь давайте включим оптимизацию:

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 

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. наблюдается в другом потоке, а
  2. с уважением к аналогичным изменениям, происходящим в других потоках.

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

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

По поводу эффекта от оптимизаций обновлений std :: atomics.

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

Правило как если бы консервативно, особенно в отношении атомных ядер.

считать:

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

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

void incdec(int&) {
    // nada
}

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

Однако это другая игра с мячом:

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

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

2022 WebDevInsider