Что именно является std::atomic?

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

a = a + 12;

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

2 ответа

Решение

Каждое создание и полная специализация 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

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

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

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

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

Итак, теперь посмотрим, что std::atomic компилируется, чтобы получить иную информацию.

Главный вывод из этого эксперимента заключается в том, что современные процессоры имеют прямую поддержку атомарных целочисленных операций, например префикса LOCK в x86 и std::atomicв основном существует как переносимый интерфейс для этих вторжений: что означает инструкция "блокировки" в сборке 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

Выводы:

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

    Таким образом, в конце очень вероятно, что четыре записи вернутся в global с тем же "неправильным" значением 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.

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

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

a = a + 12;

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

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