Ответ 1
Чтобы атомизировать две вещи одновременно с помощью одной атомной операции, вам нужно поместить их в соседнюю память, например. в структуре с двумя элементами. Затем вы можете использовать std::atomic<my_struct>
, чтобы получить gcc для emit lock cmpxchg16b
на x86-64, например.
Вам не нужен встроенный asm для этого, и это стоит немного синтаксической боли С++, чтобы избежать этого. https://gcc.gnu.org/wiki/DontUseInlineAsm.
К сожалению, с текущими компиляторами вам нужно использовать union
, чтобы получить эффективный код для чтения только одной из пары. "Очевидный" способ выполнения атомной нагрузки структуры, а затем только с использованием одного члена все равно приводит к lock cmpxchg16b
для чтения всей структуры, хотя нам нужен только один член. Я уверен, что нормальный 64-битный указатель будет по-прежнему правильно реализовывать семантику получения порядка памяти на x86 (а также атомарность), но текущие компиляторы не делают эту оптимизацию даже для std::memory_order_relaxed
, поэтому мы обманываем их в него с объединением.
(представлен ошибка GCC 80835 об этом. TODO: то же самое для clang, если это полезная идея.)
Контрольный список:
- Убедитесь, что ваш компилятор генерирует эффективный код для загрузки только одного члена в режиме только для чтения, а не
lock cmpxchg16b
этой пары. например используя объединение. - Убедитесь, что ваш компилятор гарантирует, что доступ к одному члену союза после написания другого члена профсоюза имеет четко определенное поведение в этой реализации. Тип union-punning является законным в C99 (так что это должно хорошо работать с C11
stdatomic
), но это UB в ISO С++ 11. Тем не менее, он легален в диалекте GNU С++ (поддерживается, среди прочих, gcc, clang и ICC). - Убедитесь, что ваш объект имеет выравнивание по 16B или 8B-выровненный для 32-разрядных указателей. В более общем плане
alignas(2*sizeof(void*))
должен работать. Команды Misalignedlock
ed могут быть очень медленными на x86, особенно если они пересекают границу линии кэша. clang3.8 даже компилирует его в библиотечный вызов, если объект не выровнен. -
Скомпилировать с
-mcx16
для сборки x86-64.cmpxchg16b
не был поддержан самыми ранними процессорами x86-64 (AMD K8), но должен быть на все после этого. Без-mcx16
вы получаете вызов функции библиотеки (который, вероятно, использует глобальную блокировку). 32-разрядный эквивалентcmpxchg8b
достаточно стар, что современные компиляторы принимают на себя поддержку. (И можно использовать SSE, MMX или даже x87 для 64-битных атомных нагрузок/хранилищ, поэтому использование объединения несколько менее важно для хорошей производительности при чтении одного элемента). -
Убедитесь, что объект-указатель + uintptr_t не заблокирован. Это в значительной степени гарантируется для x32 и 32-разрядных ABI (объект 8B), но не для объектов 16B. например MSVC использует блокировку для x86-64.
gcc7 и позже вызовет libatomic вместо inline
lock cmpxchg16b
и вернет false изatomic_is_lock_free
(по причинам, включая, что это так замедляет это не то, что пользователи ожидают, чтоis_lock_free
будет означать), но по крайней мере на данный момент libatomic-реализация по-прежнему используетlock cmpxchg16b
для целей, где эта команда доступна. (Он может даже segfault для атомных объектов только для чтения, поэтому он действительно не идеален.)
Вот пример кода с CAS retry-loop, который компилируется в asm, который выглядит правильно, и я думаю, что он свободен от UB или других небезопасных С++ для реализаций, которые разрешают использование типа union. Он написан в стиле C (не-членные функции и т.д.), Но это было бы одинаково, если бы вы написали функции-члены.
Смотрите код с выходом asm из gcc6.3 в проводнике компилятора Godbolt. С помощью -m32
он использует cmpxchg8b
так же, как 64-битный код использует cmpxchg16b
. С -mx32
(32-разрядные указатели в длинном режиме) он может просто использовать 64-разрядные cmpxchg
и обычные 64-разрядные целые нагрузки для захвата обоих членов в одной атомной нагрузке.
Это переносимый С++ 11 (за исключением типа union-punning), при этом ничего не зависит от x86. Он эффективен только для целей, которые могут обладать CAS объектом размером двух указателей., например. он компилируется для вызова библиотеки __atomic_compare_exchange_16
для ARM/ARM64 и MIPS64, как вы можете видеть на Godbolt.
Он не компилируется на MSVC, где atomic<counted_ptr>
больше, чем counted_ptr_separate
, поэтому static_assert
ловит его. Предположительно, MSVC включает в себя элемент блокировки в атомарном объекте.
#include <atomic>
#include <stdint.h>
using namespace std;
struct node {
// This alignas is essential for clang to use cmpxchg16b instead of a function call
// Apparently just having it on the union member isn't enough.
struct alignas(2*sizeof(node*)) counted_ptr {
node * ptr;
uintptr_t count; // use pointer-sized integers to avoid padding
};
// hack to allow reading just the pointer without lock-cmpxchg16b,
// but still without any C++ data race
struct counted_ptr_separate {
atomic<node *> ptr;
atomic<uintptr_t> count_separate; // var name emphasizes that accessing this way isn't atomic with ptr
};
static_assert(sizeof(atomic<counted_ptr>) == sizeof(counted_ptr_separate), "atomic<counted_ptr> isn't the same size as the separate version; union type-punning will be bogus");
//static_assert(std::atomic<counted_ptr>{}.is_lock_free());
union { // anonymous union: the members are directly part of struct node
alignas(2*sizeof(node*)) atomic<counted_ptr> next_and_count;
counted_ptr_separate next;
};
// TODO: write member functions to read next.ptr or read/write next_and_count
int data[4];
};
// make sure read-only access is efficient.
node *follow(node *p) { // good asm, just a mov load
return p->next.ptr.load(memory_order_acquire);
}
node *follow_nounion(node *p) { // really bad asm, using cmpxchg16b to load the whole thing
return p->next_and_count.load(memory_order_acquire).ptr;
}
void update_next(node &target, node *desired)
{
// read the old value efficiently to avoid overhead for the no-contention case
// tearing (or stale data from a relaxed load) will just lead to a retry
node::counted_ptr expected = {
target.next.ptr.load(memory_order_relaxed),
target.next.count_separate.load(memory_order_relaxed) };
bool success;
do {
node::counted_ptr newval = { desired, expected.count + 1 };
// x86-64: compiles to cmpxchg16b
success = target.next_and_count.compare_exchange_weak(
expected, newval, memory_order_acq_rel);
// updates exected on failure
} while( !success );
}
Выход asm из clang 4.0 -O3 -mcx16
:
update_next(node&, node*):
push rbx # cmpxchg16b uses rbx implicitly so it has to be saved/restored
mov rbx, rsi
mov rax, qword ptr [rdi] # load the pointer
mov rdx, qword ptr [rdi + 8] # load the counter
.LBB2_1: # =>This Inner Loop Header: Depth=1
lea rcx, [rdx + 1]
lock
cmpxchg16b xmmword ptr [rdi]
jne .LBB2_1
pop rbx
ret
gcc делает некоторые неуклюжие хранилища/перезагрузки, но в основном такая же логика.
follow(node*)
компилируется в mov rax, [rdi]
/ret
, поэтому доступ к указателю только для чтения столь же дешев, как и должно быть, благодаря взлому соединения.
Это зависит от написания объединения через один член и чтения его с помощью другого, для эффективного чтения только указателя без использования lock cmpxchg16b
. Это гарантированно работает в GNU С++ (и ISO C99/C11), но не в ISO С++. Многие другие компиляторы С++ гарантируют, что действие типа union-punning работает, но даже без этого оно, вероятно, все еще будет работать: мы всегда используем нагрузки std::atomic
, которые должны предполагать, что значение было изменено асинхронно. Таким образом, мы должны быть защищены от проблем с псевдонимом, где значения в регистрах по-прежнему считаются живыми после записи значения через другой указатель (или член профсоюза). Тем не менее, перекомпонование времени компиляции может быть проблемой.
Атоматическое чтение только указателя после атомарного cmpxchg указателя + счетчика должно все же давать вам семантику получения/выпуска на x86, но я не думаю, что ISO С++ говорит об этом. Я бы предположим, что широкий релиз-магазин (как часть compare_exchange_weak
будет синхронизироваться с более узкой нагрузкой с одного и того же адреса на большинстве архитектур (например, на x86), но AFAIK С++ std::atomic
не гарантирует ничего о типе- каламбурная.
Не относится к указателю + ABA-счетчик, но может появиться в других приложениях, использующих объединение, чтобы разрешить доступ к подмножествам более крупного атомного объекта: Не использовать объединение, чтобы атомные хранилища имели только указатель или просто счетчик. По крайней мере, если вам не нужна синхронизация с нагрузкой на пару. Даже сильно упорядоченный x86 может переупорядочить узкий магазин с более широкой загрузкой, которая полностью содержит его. Все по-прежнему является атомарным, но вы попадаете в странную территорию, поскольку происходит упорядочение памяти.
На x86-64 для атомной нагрузки 16B требуется lock cmpxchg16b
(который является полным барьером памяти, препятствуя тому, чтобы предыдущий узкий магазин стал глобально видимым после него). Но вы могли бы легко иметь проблему, если бы использовали это с 32-разрядными указателями (или 32-разрядными индексами массива), поскольку обе половины могли быть загружены с регулярным 64b-загрузкой. И я понятия не имею, какие проблемы вы можете увидеть на других архитектурах, если вам нужна синхронизация с другими потоками, а не просто атомарность.
Чтобы узнать больше о std:: memory_order приобретать и выпускать, см. Jeff Preshing отличные статьи.