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


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

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

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

обновление

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

13 133

13 ответов:

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


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

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

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

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


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

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

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

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

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

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


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


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

это совершенно фиктивно использовать num++ не сообщая компилятору, что вам нужно скомпилировать его в одну реализацию read-modify-write:

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

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

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


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

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

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

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

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

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

определение гонок данных по неатомным переменным как неопределенное поведение-это то, что позволяет компилятору по-прежнему поднимать нагрузки и поглощать хранилища из циклов и многие другие оптимизации для памяти, на которые могут ссылаться несколько потоков. (См.этот блог LLVM подробнее о том, как UB включает оптимизацию компилятора.)


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

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

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

#include <atomic>
std::atomic<int> num;
void inc_relaxed() {
  num.fetch_add(1, std::memory_order_relaxed);
}

int load_num() { return num; }            // Even seq_cst loads are free on x86
void store_num(int val){ num = val; }
void store_num_release(int val){
  num.store(val, std::memory_order_release);
}
// Can the compiler collapse multiple atomic operations into one? No, it can't.

# g++ 6.2 -O3, targeting x86-64 System V calling convention. (First argument in edi/rdi)
inc_relaxed():
    lock add        DWORD PTR num[rip], 1      #### Even relaxed RMWs need a lock. There's no way to request just a single-instruction RMW with no lock, for synchronizing between a program and signal handler for example. :/ There is atomic_signal_fence for ordering, but nothing for RMW.
    ret
inc_seq_cst():
    lock add        DWORD PTR num[rip], 1
    ret
load_num():
    mov     eax, DWORD PTR num[rip]
    ret
store_num(int):
    mov     DWORD PTR num[rip], edi
    mfence                          ##### seq_cst stores need an mfence
    ret
store_num_release(int):
    mov     DWORD PTR num[rip], edi
    ret                             ##### Release and weaker doesn't.
store_num_relaxed(int):
    mov     DWORD PTR num[rip], edi
    ret

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


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

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

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

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


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

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

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

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

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


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

void multiple_ops_relaxed(std::atomic<unsigned int>& num) {
  num.fetch_add( 1, std::memory_order_relaxed);
  num.fetch_add(-1, std::memory_order_relaxed);
  num.fetch_add( 6, std::memory_order_relaxed);
  num.fetch_add(-5, std::memory_order_relaxed);
  //num.fetch_add(-1, std::memory_order_relaxed);
}

multiple_ops_relaxed(std::atomic<unsigned int>&):
    lock add        DWORD PTR [rdi], 1
    lock sub        DWORD PTR [rdi], 1
    lock add        DWORD PTR [rdi], 6
    lock sub        DWORD PTR [rdi], 5
    ret

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

f():
        rep ret

хорошо, давайте дадим ему шанс:

void f(int& num)
{
  num = 0;
  num++;
  --num;
  num += 6;
  num -=5;
  --num;
}

результат:

f(int&):
        mov     DWORD PTR [rdi], 0
        ret

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

сравнить с:

#include <atomic>

void f(std::atomic<int>& num)
{
  num = 0;
  num++;
  --num;
  num += 6;
  num -=5;
  --num;
}

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

f(std::atomic<int>&):
        mov     DWORD PTR [rdi], 0
        mfence
        lock add        DWORD PTR [rdi], 1
        lock sub        DWORD PTR [rdi], 1
        lock add        DWORD PTR [rdi], 6
        lock sub        DWORD PTR [rdi], 5
        lock sub        DWORD PTR [rdi], 1
        ret

теперь каждая модификация: -

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

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

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

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

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

правило as-if является консервативным, особенно с участием атомной энергетики.

считаем:

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

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

void incdec(int&) {
    // nada
}

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

однако, это другая игра в мяч:

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

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

вот демо:

#include <thread>
#include <atomic>

int main()
{
    for (int iter = 0 ; iter < 20 ; ++iter)
    {
        std::atomic<int> num = { 0 };
        std::thread t1([&] {
            for (int i = 0 ; i < 10000000 ; ++i)
            {
                ++num;
                --num;
            }
        });
        std::thread t2([&] {
            for (int i = 0 ; i < 10000000 ; ++i)
            {
                num = 100;
            }
        });

        t2.join();
        t1.join();
        std::cout << num << std::endl;
    }
}

пример вывода:

99
99
99
99
99
100
99
99
100
100
100
100
99
99
100
99
99
100
100
99

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

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

AGENT 1          AGENT 2

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

это безопасно, так:

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

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

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

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

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

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


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

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

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

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

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

нет. https://www.youtube.com/watch?v=31g0YE61PLQ (Это просто ссылка на сцену" нет " из "офиса")

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

пример вывода:

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

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

это правило" как-будто".

и независимо от вывода, вы можете думать о синхронизации потоков одинаково - если поток A делает num++; num--; и поток B читает num неоднократно, то возможным допустимым чередованием является то, что поток B никогда не читает между num++ и num--. Поскольку это чередование допустимо, компилятор может сделать это только возможно чередование. И просто удалите incr / decr полностью.

есть некоторые интересные последствия здесь:

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

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

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

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

progress += local;

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

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

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

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

while (local--)
    progress++;

: -/

да, но...

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

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

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

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

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

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

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

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

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

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

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

увеличение int по ссылке, "обычный" способ:

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

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

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

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

#include <atomic>

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

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

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

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

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

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

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

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