Как я могу реализовать счетчик ABA с С++ 11 CAS?

Я реализую незаблокированную очередь на основе этого алгоритма, который использует счетчик для решения проблемы ABA. Но я не знаю, как реализовать этот счетчик с С++ 11 CAS. Например, из алгоритма:

E9:    if CAS(&tail.ptr->next, next, <node, next.count+1>)

Это атомная операция, то есть если tail.ptr->next равно next, пусть tail.ptr->next указывает на node и одновременно (атомарно) сделать next.count+1. Однако, используя С++ 11 CAS, я могу реализовать только:

std::atomic_compare_exchange_weak(&tail.ptr->next, next, node);

который не может сделать next.count+1 одновременно.

Ответ 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*)) должен работать. Команды Misaligned lock 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 отличные статьи.