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

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

Enter image description here

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

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

обновление

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

13 ответов


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


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

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

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

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


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

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

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

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

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

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


на однопроцессорной машине или в однопоточном процессе, одно RMW инструкция на самом деле is атомно-без lock префикс. Единственный способ для другого кода получить доступ к общей переменной-это для CPU сделать контекстный переключатель, который не может произойти в середине инструкции. Так просто 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 бесплатно получает хранилища загрузок/выпусков, вам все равно нужно сказать компилятору не переупорядочивать с помощью 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. Вы можете выбрать другое целевые архитектуры, включая 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 допускается. Наличие буфера хранилища необходимо для хорошей производительности на конвейерном процессоре вне порядка. Память Пойман с поличным показывает последствия не использование 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 is 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, более агрессивное слияние несмежных атомарных ops во время компиляции может быть плохим (например, счетчик прогресса обновляется только один раз в конце вместо каждой итерации), но он также может помочь производительности без недостатков (например, пропуск атомарного inc / dec ref подсчитывает, когда копия shared_ptr создается и уничтожается, если компилятор может доказать, что другой shared_ptr объект существует на весь срок службы временного.)

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


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

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::atomics.

стандарт 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-стиль.

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

AGENT 1          AGENT 2

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

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


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

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 и будет разбивать инструкции на микрооперации, переупорядочивать и / или спекулятивно выполнять их, в степени, ограниченной синхронизацией примитивов, таких как (на Intel)LOCK префикс или память заборы, в порядке максимизировать операции в секунду.

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

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

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

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

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

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

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

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

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


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


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

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

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

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


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

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

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

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

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

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

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

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

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

progress += local;

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

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

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

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

while (local--)
    progress++;

: -/


да, но...

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

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

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

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

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

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

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

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


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