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


153

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

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

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

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

ОБНОВИТЬ

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


65
Кто тебе сказал, что addэто атомно?
Слава

6
учитывая, что одной из особенностей атомики является предотвращение определенных видов переупорядочения во время оптимизации, нет, независимо от атомарности фактической операции
jaggedSpire

19
Я также хотел бы отметить, что, если это является атомарным на вашей платформе, нет никакой гарантии, что это будет на другой платформе. Будьте независимы от платформы и выражайте свои намерения, используя std::atomic<int>.
Натан Оливер

8
Во время выполнения этой addинструкции другое ядро ​​может украсть этот адрес памяти из кеша этого ядра и изменить его. На процессоре x86 addинструкция должна иметь lockпрефикс, если адрес должен быть заблокирован в кеше на время операции.
Дэвид Шварц

21
Это возможно для любой операции случиться , чтобы быть «атомными» . Все, что вам нужно сделать, - это повезти, и вам никогда не удастся выполнить что-то, что покажет, что это не атомарно. Атомика ценна только как гарантия . Учитывая, что вы смотрите на код сборки, вопрос заключается в том, дает ли эта конкретная архитектура вам гарантию, и предоставляет ли компилятор гарантию того, что это реализация уровня сборки, которую они выбирают.
Cort Ammon

Ответы:


197

Это абсолютно то, что 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, а не осциллограф подключен к выводам процессора). Вот почему он существует. (Смотри также этот Q & A ).

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


Как я уже упоминал, префикс x86lock является полным барьером памяти, поэтому при использовании 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 разрешено. Наличие буфера хранилища важно для хорошей производительности на конвейерном процессоре с неработоспособностью. Джефф Preshing в Изменение порядка памяти Оказавшись в законе показывает последствия не используя MFENCE с реальным кодом , чтобы показать изменение порядка происходит на реальном оборудовании.


Re: обсуждение в комментариях ответа @Richard Hodges о компиляторах, объединяющих num++; num-=2;операции std :: atomic в одну 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--не может исчезнуть, потому что у него все еще есть отношение Synchronizes With с другими потоками, которые смотрят num, и это и load-load, и release-store, которые запрещают переупорядочение других операций в этом потоке. Для x86 это может быть в состоянии скомпилировать в MFENCE, вместо lock add dword [num], 0(то есть num += 0).

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

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


В текущих версиях gcc6.2 и clang3.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

1
«[с использованием отдельных инструкций] раньше было более эффективно ... но современные процессоры x86 снова обрабатывают операции RMW по крайней мере так же эффективно» - это все еще более эффективно в случае, когда обновленное значение будет использовано позже в той же функции и есть свободный регистр, доступный для компилятора, чтобы сохранить его (и переменная не помечена как volatile, конечно). Это означает, что весьма вероятно, что то, будет ли компилятор генерировать одну или несколько инструкций для операции, зависит от остального кода в функции, а не только от одной рассматриваемой строки.
Периата Breatta

@PeriataBreatta: да, хорошая мысль. В asm вы можете использовать mov eax, 1 xadd [num], eax(без префикса блокировки) реализацию постинкремента num++, но это не то, что делают компиляторы.
Питер Кордес

3
@ DavidC.Rankin: Если у вас есть какие-либо изменения, которые вы хотели бы сделать, не стесняйтесь. Я не хочу делать это CW, хотя. Это все еще моя работа (и мой беспорядок: P). Я приведу некоторые вещи в порядок после моей игры Ultimate [frisbee] :)
Питер Кордес,

1
Если не вики сообщества, то может быть ссылка на соответствующий тег вики. (и x86, и атомарные теги?). Это стоит дополнительной связи, а не обнадеживающего возвращения с помощью общего поиска по SO (Если бы я знал лучше, где он должен подходить в этом отношении, я бы сделал это. Мне придется глубже вникнуть в теги do & not's ссылка на вики)
Дэвид С. Ранкин

1
Как всегда - отличный ответ! Хорошее различие между согласованностью и атомарностью (где некоторые другие неправильно
поняли

39

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

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с.

Стандарт 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

5
Это не объясняет, что неadd dword [rdi], 1 является атомарным (без префикса). Загрузка атомарна, а хранилище атомарно, но ничто не мешает другому потоку изменить данные между загрузкой и хранилищем. Таким образом, магазин может наступить на модификацию, сделанную другим потоком. См. Jfdube.wordpress.com/2011/11/30/understanding-atomic-operations . Кроме того, статьи Джеффа Прешинга без блокировок очень хороши , и он упоминает основную проблему RMW в этой вводной статье. lock
Питер Кордес

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

2
Это, возможно, причина для компиляторов не реализовывать это (принцип наименьшего удивления и так далее). Наблюдение за этим было бы возможно на практике на реальном оборудовании. Однако правила упорядочения памяти в C ++ ничего не говорят о какой-либо гарантии того, что нагрузки одного потока «равномерно» смешиваются с операциями других потоков в абстрактной машине C ++. Я все еще думаю, что это будет законно, но программист - враждебно.
Питер Кордес

2
Мысленный эксперимент: рассмотрим реализацию C ++ в совместной многозадачной системе. Он реализует std :: thread, вставляя точки выхода там, где это необходимо, чтобы избежать взаимоблокировок, но не между каждой инструкцией. Я полагаю, вы бы поспорили, что что-то в стандарте C ++ требует предела текучести между num++и num--. Если вы можете найти раздел в стандарте, который требует этого, он решит это. Я почти уверен, что требуется только то, чтобы ни один наблюдатель не мог увидеть неправильный переупорядочение, которое не требует выхода. Поэтому я думаю, что это просто вопрос качества реализации.
Питер Кордес

5
Для окончательности я спросил в списке рассылки std для обсуждения. Этот вопрос поднял 2 статьи, которые, похоже, совпадают с Питером и касаются проблем, которые у меня есть по поводу таких оптимизаций: wg21.link/p0062 и wg21.link/n4455. Спасибо Энди, который привлек их внимание.
Ричард Ходжес

38

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

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

AGENT 1          AGENT 2

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

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


7
@LeoHeinsaar Для того, чтобы это было так, каждый чип памяти должен иметь свой собственный арифметический логический блок (АЛУ). По сути, это потребовало бы, чтобы каждый чип памяти был процессором.
Ричард Ходжес

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

@PeterCordes Ваш комментарий - именно тот ответ, который я искал. Ответ Маргарет заставил меня заподозрить, что нечто подобное должно происходить внутри.
Лев Хейнсаар,

Превратил этот комментарий в полный ответ, включая обращение к C ++ части вопроса.
Питер Кордес

1
@PeterCordes Спасибо, очень подробно и по всем пунктам. Очевидно, что это была гонка данных и, следовательно, неопределенное поведение по стандарту C ++, мне было просто любопытно, можно ли предположить, что в случаях, когда сгенерированный код является тем, что я опубликовал, это может быть атомарным и т. Д. И т. Д. Руководства очень четко определяют атомарность в отношении операций с памятью, а не неделимость команд, как я предположил: «Заблокированные операции являются атомарными относительно всех других операций с памятью и всех видимых извне событий».
Лев Хейнсаар,

11

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

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


3
lock xaddреализует C ++ std :: atomic fetch_add, возвращая старое значение. Если вам это не нужно, компилятор будет использовать обычные инструкции назначения памяти с lockпрефиксом. lock addили lock inc.
Питер Кордес

1
add [mem], 1все равно не будет атомарным на SMP-машине без кеша, смотрите мои комментарии к другим ответам.
Питер Кордес

Смотрите мой ответ для более подробной информации о том, как именно это не атомарно. Также конец моего ответа на этот связанный вопрос .
Питер Кордес

10

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

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

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


9

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

1
Если компилятор не может увидеть все варианты использования ready, он, вероятно, скомпилируется while (!ready);во что-то более похожее if(!ready) { while(true); }. Upvoted: ключевая часть std :: atomic меняет семантику для принятия асинхронной модификации в любой точке. Наличие UB обычно - это то, что позволяет компиляторам поднимать нагрузки и выводить хранилища из циклов.
Питер Кордес

9

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

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

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

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

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


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


Это даже не вообще атомарно: другой поток может обновить ту же самую переменную одновременно, и только одно обновление вступает во владение.
fuz

1
Рассмотрим многоядерную систему. Конечно, внутри одного ядра инструкция является атомарной, но она не атомарна по отношению ко всей системе.
fuz

1
@FUZxxl: Каковы были четвертое и пятое слова моего ответа?
суперкат

1
@supercat Ваш ответ очень вводит в заблуждение, потому что он рассматривает только редкий в настоящее время случай с одним ядром и дает OP ложное чувство безопасности. Вот почему я прокомментировал и многоядерный корпус.
fuz

1
@FUZxxl: я сделал правку, чтобы прояснить потенциальную путаницу для читателей, которые не заметили, что речь не идет о нормальных современных многоядерных процессорах. (А также более конкретно о некоторых вещах, в которых суперкат не был уверен). Кстати, все в этом ответе уже в моем, кроме последнего предложения о том, как редко встречаются платформы, на которых чтение-изменение-запись атомарны "бесплатно".
Питер Кордес

7

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

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

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

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


1
Прерывания еще не расщепляются операции МРО, так что они действительно до сих пор синхронизировать один поток с обработчиков сигналов , которые выполняются в том же потоке. Конечно, это работает, только если asm использует одну инструкцию, а не отдельную загрузку / изменение / сохранение. C ++ 11 может предоставить эту аппаратную функциональность, но это не так (возможно, потому, что в ядрах Uniprocessor было действительно полезно синхронизировать с обработчиками прерываний, а не в пользовательском пространстве с обработчиками сигналов). Также в архитектурах нет инструкций чтения-изменения-записи-назначения памяти. Тем не менее, он может просто скомпилироваться как расслабленный атомарный RMW на не-x86
Питер Кордес

Хотя, насколько я помню, использование префикса Lock не было абсурдно дорогим, пока не появились суперскалера. Таким образом, не было никакой причины замечать, что это замедляет работу важного кода в 486, даже если он не нужен этой программе.
JDługosz

Да, прости! Я на самом деле не читал внимательно. Я видел начало абзаца с красной сельдью о расшифровке до уп, и не дочитал до конца, чтобы увидеть, что вы на самом деле сказали. Re: 486: Я думаю, что я читал, что самым ранним SMP был какой-то Compaq 386, но его семантика упорядочения памяти отличалась от той, что в настоящее время говорит ISA x86. В нынешних руководствах по x86 может даже упоминаться SMP 486. Они, конечно, не были распространены даже в HPC (кластеры Beowulf) до дней PPro / Athlon XP, хотя, я думаю.
Питер Кордес

1
@PeterCordes Хорошо. Конечно, при условии, что также нет наблюдателей DMA / устройства - не поместился в область комментариев, чтобы включить это также. Спасибо JDługosz за отличное дополнение (ответ, а также комментарии). Действительно завершил обсуждение.
Лев Хейнсаар

3
@Leo: Один ключевой момент, который не был упомянут: процессоры с неупорядоченным порядком переупорядочивают вещи внутренне, но золотое правило заключается в том, что для одного ядра они сохраняют иллюзию выполнения команд по одному. (И это включает прерывания, которые вызывают переключение контекста). Значения могут храниться в памяти электрически не по порядку, но одно ядро, на котором все работает, отслеживает все переупорядочения, которые оно выполняет само, чтобы сохранить иллюзию. Вот почему вам не нужен барьер памяти для asm-эквивалента, a = 1; b = a;чтобы правильно загрузить только что сохраненную вами 1.
Питер Кордес

4

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

: - /


Этот ответ, кажется, только отвечает на дополнительный вопрос, который мы с Ричардом размышляли. Мы в конце концов решить это: получается, что да, стандарт C ++ это позволяет объединение операций на не- volatileатомных объектов, когда это не нарушает другие правила. Два документа для обсуждения стандартов обсуждают именно это (ссылки в комментарии Ричарда ), один из которых использует один и тот же пример счетчика прогресса. Так что это проблема качества реализации, пока C ++ не стандартизирует способы ее предотвращения.
Питер Кордес

Да, мое «Нет» действительно является ответом на всю цепочку рассуждений. Если вопрос просто «может ли num ++ быть атомарным на каком-то компиляторе / реализации», ответ верен. Например, компилятор может решить добавить lockв каждую операцию. Или какая-то комбинация компилятор + однопроцессор, в которой ни один из них не переупорядочивал (т. Е. «Добрые старые времена») - все атомарно. Но какой в ​​этом смысл? Вы не можете действительно полагаться на это. Если вы не знаете, для какой системы вы пишете. (Даже тогда лучше было бы, чтобы atomic <int> не добавлял лишних операций в этой системе. Поэтому вы все равно должны написать стандартный код ...)
Тони

1
Обратите внимание, что And just remove the incr/decr entirely.это не совсем правильно. Это все еще операция захвата и выпуска num. На x86 num++;num--можно было просто скомпилировать MFENCE, но точно ничего. (Если только анализ всей программы компилятора не может доказать, что ничего не синхронизируется с этой модификацией num, и что не имеет значения, если некоторые хранилища до этого откладываются до последующих загрузок после этого.) Например, если это было -lock-right-сразу сценарий использования, у вас все еще есть два отдельных критических раздела (возможно, с использованием mo_relaxed), а не один большой.
Питер Кордес,

@PeterCordes ах да, согласился.
Тони

2

Да, но...

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

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

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

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

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

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

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

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


2
Загрузка и хранилище являются атомарными, но вся операция чтения-изменения-записи в целом определенно не атомарна. Кэши являются связными, поэтому никогда не могут содержать конфликтующие копии одной и той же строки ( en.wikipedia.org/wiki/MESI_protocol ). Другое ядро ​​даже не может иметь копию только для чтения, в то время как это ядро ​​имеет измененное состояние. Что делает его неатомарным, так это то, что ядро, выполняющее RMW, может потерять право собственности на строку кэша между загрузкой и хранилищем.
Питер Кордес

2
Кроме того, нет, целые строки кэша не всегда передаются атомарно. Посмотрите этот ответ , где экспериментально продемонстрировано, что Opteron с несколькими сокетами делает 16-битные SSE-хранилища неатомарными, передавая строки кэша в 8-битные блоки с гипертранспортом, даже если они являются атомарными для однопроцессорных ЦП одного типа (потому что загрузка / аппаратное обеспечение магазина имеет путь 16 к кэш-памяти L1). x86 гарантирует атомарность только для отдельных нагрузок или для хранения до 8B.
Питер Кордес

Оставление выравнивания для компилятора не означает, что память будет выровнена по 4-байтовой границе. Компиляторы могут иметь параметры или прагмы для изменения границы выравнивания. Это полезно, например, для работы с плотно упакованными данными в сетевых потоках.
Дмитрий Рубанович

2
Софистика, больше ничего. Целое число с автоматическим хранением, которое не является частью структуры, как показано в примере, будет абсолютно правильно выровнено. Утверждать что-либо другое просто глупо. Строки кэша, а также все POD имеют размер PoT (степень двойки) и выровнены - на любой не иллюзорной архитектуре в мире. Математика имеет то, что любой правильно выровненный PoT вписывается ровно в один (никогда больше) любого другого PoT того же размера или больше. Поэтому мое утверждение верно.
Деймон

1
@ Дэймон, пример, приведенный в вопросе, не упоминает структуру, но он не ограничивает вопрос только ситуациями, когда целые числа не являются частями структур. POD, безусловно, могут иметь размер PoT и не быть выровненными. Посмотрите на этот ответ для примеров синтаксиса: stackoverflow.com/a/11772340/1219722 . Так что это вряд ли "софистика", потому что POD, объявленные таким образом, используются в сетевом коде довольно редко в реальном коде.
Дмитрий Рубанович

2

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

-2

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


-3

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

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

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

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


«потому что на компьютерах с архитектурой x86 увеличение 32-разрядного целого числа фактически является атомарным». Можете ли вы предоставить ссылку на документацию, подтверждающую это?
Слава

8
Это не атомарно на x86 либо. Это одноядерный безопасный, но если есть несколько ядер (и есть), это совсем не атомарно.
Гарольд

Действительно ли x86 addгарантированно атомарен? Я не удивлюсь, если приращения регистра будут атомарными, но это вряд ли полезно; чтобы сделать приращение регистра видимым для другого потока, он должен находиться в памяти, что потребует дополнительных инструкций для его загрузки и сохранения, удаляя атомарность. Я понимаю, что именно поэтому lockпрефикс существует для инструкций; единственный полезный элемент addприменяется к разыменованной памяти и использует lockпрефикс, чтобы гарантировать, что строка кэша заблокирована на время операции .
ShadowRanger

@Slava @Harold @ShadowRanger Я обновил ответ. addявляется атомарным, но я ясно дал понять, что это не означает, что код безопасен по состоянию гонки, потому что изменения не становятся глобально видимыми сразу.
Xirema

3
@Xirema, что делает его "не атомарным" по определению, хотя
Гарольд
Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.