Что именно является 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 и модели памяти, эти ссылки могут быть полезны:
- C++ атомика и упорядочение памяти
- Сравнение: программирование без блокировки с атомарностью в C++ 11 с мьютексом и RW-блокировками
- C++ 11 представил стандартизированную модель памяти. Что это значит? И как это повлияет на программирование на C++?
- Параллелизм в 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;
}
Скомпилировать, запустить и дизассемблировать:
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)
,