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

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

Введите описание изображения здесь

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

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

ОБНОВИТЬ

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

13 ответов

Решение

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


Но сначала вопрос языка ассемблера:

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

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

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


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

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

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

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

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

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


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


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

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

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

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

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


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

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

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

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

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

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

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

#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> для gcc).


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

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

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

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

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


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

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

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

AGENT 1          AGENT 2

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

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

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

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

Правило "как будто" является консервативным, особенно с участием атомщиков.

рассматривать:

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

Нет. 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(), который просто выдает 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++;

: - /

Это вывод одного компилятора для конкретной архитектуры ЦП с отключенной оптимизацией (поскольку 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

Да, но...

Атомная не то, что вы хотели сказать.Вы, вероятно, спрашиваете не то.

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

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

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

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

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

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

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

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

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

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

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

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

Другие вопросы по тегам