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

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

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

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

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

UPDATE

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

Ответы

Ответ 1

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


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

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

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

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


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

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

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

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

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

Обратите внимание, что префикс 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 факт: gcc -O3 -m32 -mtune=i586 фактически выдаст это, потому что суперскалярный конвейер (Pentium) P5 не декодировал сложные инструкции для нескольких простых микроопераций, как это делают P6 и более поздние микроархитектуры. Для получения дополнительной информации см. руководство по таблицам команд Agner Fog/микроархитектура и тег wiki для многих полезных ссылок (включая руководства Intel x86 ISA, которые свободно доступны в формате PDF)).


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

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

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

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

// flag is just a plain int global, not std::atomic<int>.
flag--;       // This isn't a real lock, but pretend it 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 обеспечивает оптимизацию компилятора.)


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


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

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

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

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

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


С текущими версиями gcc6.2 и clang3.9 вы по-прежнему получаете отдельные lock операции даже с memory_order_relaxed в наиболее очевидном оптимизируемом случае. ( void multiple_ops_relaxed(std::atomic& num) {%0A++num.fetch_add(+1, std::memory_order_relaxed);%0A++num.fetch_add(-1, std::memory_order_relaxed);%0A++num.fetch_add(+6, std::memory_order_relaxed);%0A++num.fetch_add(-5, std::memory_order_relaxed);%0A++//num.fetch_add(-1, std::memory_order_relaxed); }'),l:'5',n:'1',o:'C++ source #1',t:'0')),k:50,l:'4',m:100,n:'0',o:'',s:0,t:'0'),(g:!((g:!((h:compiler,i:(compiler:g62,filters:(b:'0',commentOnly:'0',directives:'0',intel:'0'),options:'-std=gnu++11 -Wall -Wextra -O3 -fverbose-asm'),l:'5',n:'0',o:'#1 with x86-64 gcc+6.2',t:'0')),k:50,l:'4',m:84.06862745098039,n:'0',o:'',s:0,t:'0'),(g:!((h:output,i:(compiler:1,editor:1),l:'5',n:'0',o:'#1 with x86-64 gcc+6.2',t:'0')),l:'4',m:15.931372549019606,n:'0',o:'',s:0,t:'0')),k:50,l:'3',n:'0',o:'',t:'0')),l:'2',n:'0',o:'',t:'0')),version:4 rel=noreferrer>Проводник компилятора 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

Ответ 2

... и теперь разрешите оптимизацию:

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

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

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

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

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

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

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

Правило as-if является консервативным, в частности, с атомикой.

рассмотреть следующие вопросы:

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

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

void incdec(int&) {
    // nada
}

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

Ответ 3

Без многих осложнений инструкция типа 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 увеличивается только один раз.

Ответ 4

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

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

Ответ 5

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

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

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

Ответ 6

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

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

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

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

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


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

Ответ 7

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

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

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

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

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

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

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();
}

Это безопасно, потому что:

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

Ответ 8

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

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

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

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

Ответ 9

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

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

вывод образца:

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

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

Это правило "как есть".

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

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

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

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

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

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

progress += local;
Возможно, это верно. Но, вероятно, не то, на что надеялся программист: - (

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

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

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

while (local--)
    progress++;

: -/

Ответ 10

Да, но...

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

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

Это поточно-безопасный?

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

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

Разумеется, процессор каким-то образом разобратся. Но результат может быть не таким, как вы ожидаете.

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

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

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

Ответ 11

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

Ответ 12

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

Ответ 13

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

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

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

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