C++20 std::atomic <float> - std::atomic <double>. Specializations

C++20 включает специализации для atomic<float> а также atomic<double>. Может ли кто-нибудь здесь объяснить, для каких практических целей это должно быть полезно? Единственная цель, которую я могу себе представить, - это когда у меня есть поток, который изменяет атомарный двойной или плавающий асинхронно в случайных точках, а другие потоки читают эти значения асинхронно (но volatile double или float фактически должны делать то же самое на большинстве платформ). Но необходимость в этом должна быть крайне редкой. Я думаю, что этот редкий случай не может оправдать включение в стандарт C++20.

3 ответа

atomic<float> а также atomic<double>существуют с C++11. Вatomic<T> шаблон работает для произвольных тривиально копируемых T. Все, что вы могли взломать, используя устаревшие версии до C++11volatile для общих переменных можно сделать с C++11 atomic<double> с участием std::memory_order_relaxed.

Чего не существует до C++20, так это атомарных операций RMW, таких какx.fetch_add(3.14); или для краткости x += 3.14. ( Почему атомарный двойник не реализовал полностью чудеса, почему бы и нет). Эти функции-члены были доступны только вatomic целочисленные специализации, поэтому вы можете загружать, хранить, обменивать и CAS только на float а также double, как и для произвольных T как типы классов.

См. В разделе Атомная двойная плавающая точка или загрузка / сохранение векторных данных SSE/AVX на x86_64 для получения подробной информации о том, как создать собственныйcompare_exchange_weak, and how that (and pure load, pure store, and exchange) compiles in practice with GCC and clang for x86. (Not always optimal, gcc bouncing to integer regs unnecessarily.) Also for details on lack of atomic<__m128i> load/store because vendors won't publish real guarantees to let us take advantage (in a future-proof way) of what current HW does.

These new specializations provide maybe some efficiency (on non-x86) and convenience with fetch_add and fetch_sub (and the equivalent += and -= overloads). Only those 2 operations that are supported, not fetch_mul or anything else. See the current draft of 31.8.3 Specializations for floating-point types, and cppreference std::atomic

It's not like the committee went out of their way to introduce new FP-relevant atomic RMW member functions fetch_mul, min, max, or even absolute value or negation, which is ironically easier in asm, just bitwise AND or XOR to clear or flip the sign bit and can be done with x86 lock and if the old value isn't needed. Actually since carry-out from the MSB doesn't matter, 64-bit lock xadd can implement fetch_xor with 1ULL<<63. Assuming of course IEEE754 style sign/magnitude FP. Similarly easy on LL/SC machines that can do 4-byte or 8-byte fetch_xor, and they can easily keep the old value in a register.

So the one thing that could be done significantly more efficiently in x86 asm than in portable C++ without union hacks (atomic bitwise ops on FP bit patterns) still isn't exposed by ISO C++.

It makes sense that the integer specializations don't have fetch_mul: integer add is much cheaper, typically 1 cycle latency, the same level of complexity as atomic CAS. But for floating point, multiply and add are both quite complex and typically have similar latency. Moreover, if atomic RMW fetch_add is useful for anything, I'd assume fetch_mul would be, too. Again unlike integer where lockless algorithms commonly add/sub but very rarely need to build an atomic shift or mul out of a CAS. x86 doesn't have memory-destination multiply so has no direct HW support for lock imul.

It seems like this is more a matter of bringing atomic<double> up to the level you might naively expect (supporting .fetch_add and sub like integers), not of providing a serious library of atomic RMW FP operations. Perhaps that makes it easier to write templates that don't have to check for integral, just numeric, types?

Can anyone here explain for what practical purpose this should be good for?

For pure store / pure load, maybe some global scale factor that you want to be able to publish to all threads with a simple store? And readers load it before every work unit or something. Or just as part of a lockless queue or stack of double.

It's not a coincidence that it took until C++20 for anyone to say "we should provide fetch_add for atomic<double> in case anyone wants it."

Plausible use-case: to manually multi-thread the sum of an array (instead of using #pragma omp parallel for simd reduction(+:my_sum_variable) or a standard <algorithm> like std::accumulate with a C++17 parallel execution policy).

The parent thread might start with atomic<double> total = 0; and pass it by reference to each thread. Then threads do *totalptr += sum_region(array+TID*size, size)накапливать результаты. Вместо того, чтобы иметь отдельную выходную переменную для каждого потока и собирать результаты в одном вызывающем. Это неплохо для разногласий, если все потоки не заканчиваются почти одновременно. (Что вполне вероятно, но это, по крайней мере, правдоподобный сценарий.)


Если вам просто нужна отдельная загрузка и отдельная атомарность хранилища, как вы надеетесь, от volatile, у вас уже есть это с C++11.

Не использовать volatile для заправки: используйте atomic<T> с участием mo_relaxed

Смотрите, когда использовать volatile с многопоточностью? для получения подробной информации о mo_relaxed atomic vs. legacyvolatile для многопоточности. volatiledata race - это UB, но на практике он работает как часть атомики roll-your-own на компиляторах, которые его поддерживают, с встроенным asm, если вы хотите упорядочить wrt. другие операции, или если вам нужна атомарность RMW вместо отдельной загрузки / ALU / отдельного хранилища. Все основные процессоры имеют согласованную кеш-память / общую память. Но с C++11 для этого нет причин:std::atomic<> устаревший скрученный вручную volatile общие переменные.

По крайней мере теоретически. На практике в некоторых компиляторах (например, GCC) все еще есть пропущенные оптимизации дляatomic<double> / atomic<float>даже для простой загрузки и хранения. (И новые перегрузки C++20 еще не реализованы на Godbolt). atomic<integer> это нормально, и он оптимизирует, а также изменчивые или простые целые числа + барьеры памяти.

В некоторых ABI (например, 32-разрядных x86) alignof(double) всего 4. Компиляторы обычно выравнивают его по 8, но внутри структур они должны следовать правилам упаковки структур ABI, поэтому volatile doubleвозможно. На практике разрыв будет возможен, если он разбивает границу строки кэша, или на некоторых AMD 8-байтовую границу. atomic<double> вместо того volatileможет иметь значение для правильности на некоторых реальных платформах, даже если вам не нужен атомарный RMW. например, эта ошибка G++, которая была исправлена ​​путем увеличения использованияalignas() в std::atomic<> реализация для объектов, достаточно малых, чтобы быть lock_free.

(И, конечно же, есть платформы, на которых 8-байтовое хранилище не является атомарным по своей природе, поэтому, чтобы избежать разрывов, вам нужен откат к блокировке. Если вам важны такие платформы, модель периодической публикации должна использовать вручную свернутый SeqLock или atomic<float> если atomic<double> не always_lock_free.)


Вы можете получить такой же эффективный генератор кода (без дополнительных инструкций барьера) из atomic<T> используя mo_relaxed, как вы можете с volatile. К сожалению, на практике не все компиляторы имеют эффективныеatomic<double>. Например, GCC9 для x86-64 копирует из XMM в целочисленные регистры общего назначения.

#include <atomic>

volatile double vx;
std::atomic<double> ax;
double px; // plain x

void FP_non_RMW_increment() {
    px += 1.0;
    vx += 1.0;     // equivalent to vx = vx + 1.0
    ax.store( ax.load(std::memory_order_relaxed) + 1.0, std::memory_order_relaxed);
}

#if __cplusplus > 201703L    // is there a number for C++2a yet?
// C++20 only, not yet supported by libstdc++ or libc++
void atomic_RMW_increment() {
    ax += 1.0;           // seq_cst
    ax.fetch_add(1.0, std::memory_order_relaxed);   
}
#endif

Godbolt GCC9 для x86-64, gcc -O3. (Также включена целочисленная версия)

FP_non_RMW_increment():
        movsd   xmm0, QWORD PTR .LC0[rip]   # xmm0 = double 1.0 

        movsd   xmm1, QWORD PTR px[rip]        # load
        addsd   xmm1, xmm0                     # plain x += 1.0
        movsd   QWORD PTR px[rip], xmm1        # store

        movsd   xmm1, QWORD PTR vx[rip]
        addsd   xmm1, xmm0                     # volatile x += 1.0
        movsd   QWORD PTR vx[rip], xmm1

        mov     rax, QWORD PTR ax[rip]      # integer load
        movq    xmm2, rax                   # copy to FP register
        addsd   xmm0, xmm2                     # atomic x += 1.0
        movq    rax, xmm0                   # copy back to integer
        mov     QWORD PTR ax[rip], rax      # store

        ret

clang компилирует его эффективно, с той же загрузкой и сохранением move-scalar-double для ax что касается vx а также px.

Интересный факт: C++20 явно устарел vx += 1.0. Возможно, это поможет избежать путаницы между отдельной загрузкой и хранением, например, vx = vx + 1.0 и атомарным RMW? Чтобы прояснить, в этом заявлении есть 2 отдельных изменчивых доступа?

<source>: In function 'void FP_non_RMW_increment()':
<source>:9:8: warning: compound assignment with 'volatile'-qualified left operand is deprecated [-Wvolatile]
    9 |     vx += 1.0;     // equivalent to vx = vx + 1.0
      |     ~~~^~~~~~


Обратите внимание, что x = x + 1 это не то же самое, что x += 1 за atomic<T> x: первый загружается во временный, добавляет, затем сохраняет. (С последовательной согласованностью для обоих).

РЕДАКТИРОВАТЬ: добавление комментария Ульриха Экхардта для пояснения:`` Позвольте мне попытаться перефразировать это: даже если volatile на одной конкретной платформе / среде / компиляторе сделал то же самое, что и atomic<>, вплоть до сгенерированного машинного кода, тогда atomic <> все еще гораздо более выразительный в своих гарантиях, и, кроме того, он гарантированно портативен. Более того, когда вы можете писать самодокументированный код, вы должны это делать ".

Летучий иногда имеет два следующих эффекта:

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

См. Также Понимание ключевого слова volatile в C++

TL; DR;

Скажите прямо, чего вы хотите.

  • Не полагайтесь на "volatile", делайте то, что хотите, если "что" не является исходной целью volatile, например, позволяет внешним датчикам или DMA изменять адрес памяти без вмешательства компилятора.
  • Если вам нужен атомар, используйте std::atomic.
  • Если вы хотите отключить строгую оптимизацию псевдонима, сделайте то же самое, что и ядро ​​Linux, и отключите строгую оптимизацию псевдонима, например, в gcc.
  • Если вы хотите отключить другие виды оптимизации компилятора, используйте встроенные функции компилятора или код явной сборки, например, для ARM или x86_64.
  • Если вы хотите "ограничить" семантику ключевых слов, как в C, используйте соответствующую встроенную функцию ограничения в C++ в вашем компиляторе, если она доступна.
  • Короче говоря, не полагайтесь на поведение, зависящее от компилятора и семейства ЦП, если конструкции, предоставляемые стандартом, более понятны и переносимы. Используйте, например, godbolt.org, чтобы сравнить вывод ассемблера, если вы считаете, что ваш "хакерский прием" более эффективен, чем правильное выполнение.

Из std::memory_order

Отношения с изменчивыми

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

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

Заметным исключением является Visual Studio, где при настройках по умолчанию каждая изменчивая запись имеет семантику выпуска, а каждое изменчивое чтение имеет семантику получения (MSDN), и, таким образом, изменчивые элементы могут использоваться для межпотоковой синхронизации. Стандартная изменчивая семантика неприменима к многопоточному программированию, хотя ее достаточно, например, для связи с обработчиком std::signal, который выполняется в том же потоке при применении к переменным sig_atomic_t.

Напоследок: на практике единственными доступными языками для построения ядра ОС обычно являются C и C++. Учитывая это, я хотел бы, чтобы в двух стандартах содержались положения о том, чтобы "сообщить компилятору об отключении", то есть иметь возможность явно указать компилятору не изменять "намерение" кода. Целью было бы использовать C или C++ в качестве переносимого ассемблера даже в большей степени, чем сегодня.

Несколько глупый пример кода стоит скомпилировать, например, на godbolt.org для ARM и x86_64, оба gcc, чтобы увидеть, что в случае ARM компилятор генерирует две операции __sync_synchronize (барьер HW CPU) для атомарного, но не для изменчивого варианта. кода (раскомментируйте тот, который хотите). Дело в том, что использование atomic дает предсказуемое, переносимое поведение.

#include <inttypes.h>
#include <atomic>

std::atomic<uint32_t> sensorval;
//volatile uint32_t sensorval;

uint32_t foo()
{
    uint32_t retval = sensorval;
    return retval;
}
int main()
{
    return (int)foo();
}

Вывод Godbolt для ARM gcc 8.3.1:

foo():
  push {r4, lr}
  ldr r4, .L4
  bl __sync_synchronize
  ldr r4, [r4]
  bl __sync_synchronize
  mov r0, r4
  pop {r4, lr}
  bx lr
.L4:
  .word .LANCHOR0

Для тех, кто хочет пример X86, мой коллега, Ангус Леппер, любезно предоставил этот пример: Godbolt пример плохого нестабильного использования на x86_64

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

Да, это единственная цель атома, независимо от фактического типа. может быть атомарнымbool, char, int, long или что угодно.

Какое бы использование вы ни использовали type, std::atomic<type>является его поточно-ориентированной версией. Какое бы использование вы ни использовалиfloat или double, std::atomic<float/double> можно писать, читать или сравнивать потокобезопасным способом.

говоря это std::atomic<float/double> имеет лишь редкое применение практически говорит, что float/double имеют редкое использование.

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