Что такое std :: atomic?

174

Я понимаю, что std::atomic<>это атомный объект. Но в какой степени? Насколько я понимаю, операция может быть атомарной. Что именно означает сделать объект атомарным? Например, если два потока одновременно выполняют следующий код:

a = a + 12;

Тогда вся операция (скажем add_twelve_to(int)) атомная? Или внесены изменения в переменную atomic (так operator=())?

curiousguy
источник
9
Вам нужно использовать что-то вроде, a.fetch_add(12)если вы хотите атомную RMW.
Керрек SB
Да, это то, что я не понимаю. Что подразумевается под созданием объекта атомарным. Если бы существовал интерфейс, его можно было бы просто сделать атомарным с помощью мьютекса или монитора.
2
@AaryamanSagar это решает проблему эффективности. Мьютексы и мониторы несут вычислительные затраты. Использование std::atomicпозволяет стандартной библиотеке решать, что нужно для достижения атомарности.
Дрю Дорманн
1
@AaryamanSagar: std::atomic<T>это тип, который допускает атомарные операции. Это волшебным образом не делает вашу жизнь лучше, вы все равно должны знать, что вы хотите с ней делать. Это для очень конкретного случая использования, и использование атомарных операций (над объектом), как правило, очень тонкое и должно рассматриваться с нелокальной точки зрения. Так что, если вы уже не знаете, что и зачем вам нужны атомарные операции, тип, вероятно, не очень полезен для вас.
Kerrek SB

Ответы:

188

Каждое создание и полная специализация std :: atomic <> представляет тип, с которым разные потоки могут одновременно работать (свои экземпляры), не вызывая неопределенного поведения:

Объекты атомарных типов - единственные объекты C ++, которые свободны от гонок данных; то есть, если один поток пишет в атомарный объект, а другой поток читает из него, поведение четко определено.

Кроме того, доступ к атомарным объектам может установить межпотоковую синхронизацию и упорядочить неатомарную доступ к памяти, как указано в std::memory_order.

std::atomic<>оборачивает операции, которые в pre-C ++ 11 раз приходилось выполнять с использованием (например) взаимосвязанных функций с MSVC или атомарными bultins в случае GCC.

Кроме того, std::atomic<>дает вам больше контроля, позволяя различные порядки памяти, которые задают ограничения синхронизации и упорядочения. Если вы хотите узнать больше об атомарности C ++ 11 и модели памяти, эти ссылки могут быть полезны:

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

std::atomic<long> value(0);
value++; //This is an atomic op
value += 5; //And so is this

Поскольку синтаксис оператора не позволяет указывать порядок памяти, эти операции будут выполняться с std::memory_order_seq_cst, так как это порядок по умолчанию для всех элементарных операций в C ++ 11. Он гарантирует последовательную согласованность (общий глобальный порядок) между всеми атомарными операциями.

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

std::atomic<long> value {0};
value.fetch_add(1, std::memory_order_relaxed); // Atomic, but there are no synchronization or ordering constraints
value.fetch_add(5, std::memory_order_release); // Atomic, performs 'release' operation

Теперь ваш пример:

a = a + 12;

не будет оцениваться до одной атомарной операции: она приведет к a.load()(который является самой атомарной), затем сложению между этим значением 12и a.store()(и атомарным) окончательного результата. Как я отмечал ранее, здесь std::memory_order_seq_cstбудет использоваться.

Однако, если вы напишите a += 12, это будет атомарная операция (как я уже отмечал ранее) и примерно эквивалентна a.fetch_add(12, std::memory_order_seq_cst).

Что касается вашего комментария:

Регулярный intимеет атомные нагрузки и магазины. Какой смысл его оборачивать atomic<>?

Ваше утверждение верно только для архитектур, которые предоставляют такую ​​гарантию атомности для магазинов и / или грузов. Есть архитектуры, которые этого не делают. Кроме того, обычно требуется, чтобы операции выполнялись с адресом, выровненным по слову / слову, чтобы быть атомарным std::atomic<>- это то, что гарантированно будет атомарным на каждой платформе без дополнительных требований. Более того, он позволяет писать код так:

void* sharedData = nullptr;
std::atomic<int> ready_flag = 0;

// Thread 1
void produce()
{
    sharedData = generateData();
    ready_flag.store(1, std::memory_order_release);
}

// Thread 2
void consume()
{
    while (ready_flag.load(std::memory_order_acquire) == 0)
    {
        std::this_thread::yield();
    }

    assert(sharedData != nullptr); // will never trigger
    processData(sharedData);
}

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

  • store()флаг установлен после того, sharedDataкак установлен (мы предполагаем, что generateData()всегда возвращает что-то полезное, в частности, никогда не возвращает NULL) и использует std::memory_order_releaseпорядок:

memory_order_release

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

  • sharedDataиспользуется после whileвыхода из цикла, и, таким образом, load()флаг from возвращает ненулевое значение. load()использует std::memory_order_acquireпорядок:

std::memory_order_acquire

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

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

Матеуш Гржеек
источник
2
Есть ли на самом деле архитектуры, которые не имеют атомных нагрузок и хранилищ для таких примитивов, как ints?
7
Дело не только в атомности. это также о порядке, поведении в многоядерных системах и т. д. Вы можете прочитать эту статью .
Матеуш Гржеек
4
@ AaryamanSagar Если я не ошибаюсь, даже на x86 чтение и запись являются атомарными, ТОЛЬКО если выровнены по границам слова.
v.shashenko
@MateuszGrzejek Я взял ссылку на атомарный тип. Не могли бы вы проверить, гарантирует ли следующее выполнение атомарной операции при назначении объекта ideone.com/HpSwqo
xAditya3393
3
@TimMB Да, обычно у вас есть (как минимум) две ситуации, когда порядок выполнения может быть изменен: (1) компилятор может переупорядочить инструкции (насколько позволяет стандарт), чтобы обеспечить лучшую производительность выходного кода (на основе использования регистров ЦП, прогнозов и т. д.) и (2) ЦП может выполнять инструкции в другом порядке, например, для минимизации количества точек синхронизации кеша. Ограничения порядка, предусмотренные для std::atomic( std::memory_order), служат именно той цели, которая ограничивает повторные заказы, которые допускаются.
Mateusz Grzejek
20

Я понимаю, что std::atomic<>делает объект атомарным.

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

a = a + 12;

std::atomic<>не (использовать шаблонные выражения для) упрощает это до одной атомарной операции, вместо этого operator T() const volatile noexceptэлемент делает атомарное load()из a, затем добавляется двенадцать, и operator=(T t) noexceptделает a store(t).

Тони Делрой
источник
Это было то, что я хотел спросить. Обычный int имеет атомные нагрузки и хранилища. Какой смысл оборачивать это атомарным <>
8
@AaryamanSagar Простое изменение нормали intне гарантирует переносимость изменений из других потоков, а также их чтение не гарантирует, что вы увидите изменения других потоков, а некоторые вещи, такие как my_int += 3, не гарантированно будут выполняться атомарно, если вы не используете std::atomic<>- они могут включать выборка, затем добавление, затем сохранение последовательности, в которой какой-то другой поток, пытающийся обновить то же самое значение, может прийти после выборки и перед сохранением, и засорять обновление вашего потока.
Тони Делрой
« Простое изменение обычного int не гарантирует, что изменение будет видно из других потоков ». Хуже того: любая попытка измерить эту видимость приведет к UB.
любопытный парень
8

std::atomic существует, потому что многие ISA имеют прямую аппаратную поддержку для него

То, о чем говорит стандарт C ++ std::atomic, было проанализировано в других ответах.

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

Основным выводом этого эксперимента является то, что современные процессоры имеют прямую поддержку целочисленных атомарных операций, например префикс LOCK в x86, и в std::atomicосновном существуют как переносимый интерфейс для этих операций: что означает инструкция «lock» в сборке x86? В aarch64 будет использоваться LDADD .

Эта поддержка допускает более быстрые альтернативы более общим методам, таким как std::mutex, которые могут сделать более сложные многокомпонентные разделы атомарными, за счет того, что они медленнее, чем std::atomicпотому, что std::mutexэто делает futexсистемные вызовы в Linux, что намного медленнее, чем пользовательские инструкции, создаваемые std::atomic, см. также: создает ли std :: mutex забор?

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

main.cpp

#include <atomic>
#include <iostream>
#include <thread>
#include <vector>

size_t niters;

#if STD_ATOMIC
std::atomic_ulong global(0);
#else
uint64_t global = 0;
#endif

void threadMain() {
    for (size_t i = 0; i < niters; ++i) {
#if LOCK
        __asm__ __volatile__ (
            "lock incq %0;"
            : "+m" (global),
              "+g" (i) // to prevent loop unrolling
            :
            :
        );
#else
        __asm__ __volatile__ (
            ""
            : "+g" (i) // to prevent he loop from being optimized to a single add
            : "g" (global)
            :
        );
        global++;
#endif
    }
}

int main(int argc, char **argv) {
    size_t nthreads;
    if (argc > 1) {
        nthreads = std::stoull(argv[1], NULL, 0);
    } else {
        nthreads = 2;
    }
    if (argc > 2) {
        niters = std::stoull(argv[2], NULL, 0);
    } else {
        niters = 10;
    }
    std::vector<std::thread> threads(nthreads);
    for (size_t i = 0; i < nthreads; ++i)
        threads[i] = std::thread(threadMain);
    for (size_t i = 0; i < nthreads; ++i)
        threads[i].join();
    uint64_t expect = nthreads * niters;
    std::cout << "expect " << expect << std::endl;
    std::cout << "global " << global << std::endl;
}

GitHub вверх по течению .

Скомпилируйте, запустите и разберите:

comon="-ggdb3 -O3 -std=c++11 -Wall -Wextra -pedantic main.cpp -pthread"
g++ -o main_fail.out                    $common
g++ -o main_std_atomic.out -DSTD_ATOMIC $common
g++ -o main_lock.out       -DLOCK       $common

./main_fail.out       4 100000
./main_std_atomic.out 4 100000
./main_lock.out       4 100000

gdb -batch -ex "disassemble threadMain" main_fail.out
gdb -batch -ex "disassemble threadMain" main_std_atomic.out
gdb -batch -ex "disassemble threadMain" main_lock.out

Чрезвычайно вероятный «неправильный» вывод состояния гонки для main_fail.out:

expect 400000
global 100000

и детерминированный «правильный» вывод остальных:

expect 400000
global 400000

Разборка main_fail.out:

   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     mov    0x29b5(%rip),%rcx        # 0x5140 <niters>
   0x000000000000278b <+11>:    test   %rcx,%rcx
   0x000000000000278e <+14>:    je     0x27b4 <threadMain()+52>
   0x0000000000002790 <+16>:    mov    0x29a1(%rip),%rdx        # 0x5138 <global>
   0x0000000000002797 <+23>:    xor    %eax,%eax
   0x0000000000002799 <+25>:    nopl   0x0(%rax)
   0x00000000000027a0 <+32>:    add    $0x1,%rax
   0x00000000000027a4 <+36>:    add    $0x1,%rdx
   0x00000000000027a8 <+40>:    cmp    %rcx,%rax
   0x00000000000027ab <+43>:    jb     0x27a0 <threadMain()+32>
   0x00000000000027ad <+45>:    mov    %rdx,0x2984(%rip)        # 0x5138 <global>
   0x00000000000027b4 <+52>:    retq

Разборка main_std_atomic.out:

   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     cmpq   $0x0,0x29b4(%rip)        # 0x5140 <niters>
   0x000000000000278c <+12>:    je     0x27a6 <threadMain()+38>
   0x000000000000278e <+14>:    xor    %eax,%eax
   0x0000000000002790 <+16>:    lock addq $0x1,0x299f(%rip)        # 0x5138 <global>
   0x0000000000002799 <+25>:    add    $0x1,%rax
   0x000000000000279d <+29>:    cmp    %rax,0x299c(%rip)        # 0x5140 <niters>
   0x00000000000027a4 <+36>:    ja     0x2790 <threadMain()+16>
   0x00000000000027a6 <+38>:    retq   

Разборка main_lock.out:

Dump of assembler code for function threadMain():
   0x0000000000002780 <+0>:     endbr64 
   0x0000000000002784 <+4>:     cmpq   $0x0,0x29b4(%rip)        # 0x5140 <niters>
   0x000000000000278c <+12>:    je     0x27a5 <threadMain()+37>
   0x000000000000278e <+14>:    xor    %eax,%eax
   0x0000000000002790 <+16>:    lock incq 0x29a0(%rip)        # 0x5138 <global>
   0x0000000000002798 <+24>:    add    $0x1,%rax
   0x000000000000279c <+28>:    cmp    %rax,0x299d(%rip)        # 0x5140 <niters>
   0x00000000000027a3 <+35>:    ja     0x2790 <threadMain()+16>
   0x00000000000027a5 <+37>:    retq

Выводы:

  • неатомарная версия сохраняет глобальное в регистр и увеличивает регистр.

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

  • std::atomicкомпилируется в lock addq. Префикс LOCK выполняет следующую incвыборку, модификацию и обновление памяти атомарно.

  • наш явный встроенный префикс LOCK сборки компилируется почти так же, как std::atomic, за исключением того, что incвместо используется наш add. Не уверен, почему GCC выбрал add, учитывая, что наш INC генерировал декодирование на 1 байт меньше.

ARMv8 может использовать либо LDAXR + STLXR, либо LDADD в новых процессорах: как запустить потоки в простом C?

Протестировано в Ubuntu 19.10 AMD64, GCC 9.2.1, Lenovo ThinkPad P51.

Сиро Сантилли 郝海东 冠状 病 六四 事件 法轮功
источник