Я хочу реализовать блокировку последовательности в C++23. По возможности не следует полагаться на нестандартные расширения или неопределенное поведение.
Есть предложение P1478R8: Побайтовая атомарная память memcpy, которая точно описывает мой вариант использования.
Это предложение предлагает добавить atomic_load_per_byte_memcpy
и atomic_store_per_byte_memcpy
в новый заголовок bytewise_atomic_memcpy
, который может копировать побайтово, используя атомарную семантику.
Как правильно реализованы блокировки последовательностей в C++ до C++23?
Как сейчас реализовать функции P1478?
Я не нашел эталонной реализации предложения, а также никакой другой реализации блокировки последовательности, которая специально решала бы эту проблему. Конечно, я мог бы реализовать это вручную, но это, вероятно, не привело бы к лучшей производительности, как при простой реализации memcpy
. Есть ли лучший способ?
У меня такое ощущение, что, хотя по стандарту C++ это неопределенное поведение, в реальной жизни проблему часто игнорируют и просто используют простой memcpy
.
Как правильно реализованы блокировки последовательностей в C++ до C++23?
Это не так, если только все заблокированные переменные сами не являются atomic
.
Как сейчас реализовать функции P1478?
Вы не можете.
Блокировки последовательностей обычно вызывают гонки данных в полезных данных, что оправдано игнорированием результата гонки данных при изменении порядкового номера.
Однако в текущей модели памяти C++ гонки данных по определению являются неопределённым поведением, поэтому сегодня не существует правильного способа реализовать блокировку последовательности в C++, если полезные данные не относятся к типу atomic
.
В этом весь смысл P1478 - позволить это.
У меня такое ощущение, что, хотя это неопределенное поведение согласно стандарту C++, в реальной жизни проблема часто игнорируется и просто используется простой memcpy.
Да, и эти реализации могут сломаться в любой момент.
@Sneftel О какой платформе/компиляторе/архитектуре вы говорите? Сегодня вы можете писать блокировки последовательности непосредственно на ассемблере, но не на C++.
@Sneftel: некоторые компиляторы определяют поведение volatile
достаточно строго, поэтому использование его для полезной нагрузки может иметь реальные гарантии. На практике это определенно работает с известными компиляторами, поскольку в реальных компиляторах, таких как GCC, барьеры памяти могут упорядочивать загрузку или сохранение. более ранний или поздний неатомарный доступ, даже способами, которые не являются выпуском или приобретением. См. Реализация 64-битного атомного счетчика с 32-битными атомами, где описана моя попытка реализовать его, который, на мой взгляд, достаточно надежен в GNU C или C++.
@Sneftel: см. также Изменение порядка GCC при загрузке с помощью memory_order_seq_cst. Это разрешено? (ошибка GCC, затрагивающая SeqLocks) и Законно ли преобразование fetch_add(0, Memory_order_relaxed/release) в mfence + mov? (пример гипотетических или реальных оптимизаций компилятора, которые могут повлиять на секлок)
@Sneftel: Но да, orlp прав, единственный способ написать секлок без UB — это сделать так, чтобы полезная нагрузка состояла из нескольких элементов std::atomic<long>
, которые вы копируете с помощью memory_order_relaxed
. Это не позволит компиляторам использовать более широкую копию (например, x86 movdqa
для захвата 16 байт за раз). Если вы знаете, что целевая ISA, например. 32-битный ARM и полезная нагрузка 64-битная, вы можете выбрать 32-битный размер элемента, так как вам, вероятно, нужна полезная нагрузка в паре целочисленных регистров. Но в целом это отстой, не позволяющий оптимизировать один 64-битный регистр на 64-битных ISA для uint64_t
.
@Peter Cordes: Но не нарушит ли это правила псевдонимов типов, если мой настоящий тип тоже не длинный?
@Peter Cordes: Учитывая, что у нас x86_64, будет ли проблемой загрузка 16 байт? Я думаю, что аппаратное обеспечение с этим справляется, единственное, что это UB для C++, и могут сработать потенциально опасные оптимизации компилятора.
@sedor: 16-байтовая загрузка гарантирована атомарно только на процессорах с AVX. Таким образом, в GCC или clang std::atomic<__int128>::load(relaxed)
приводит к вызову функции биатомной библиотеки, которая использует vmovaps
или аналогичный, если эта функция ЦП доступна, в противном случае используется lock cmpxchg16b
. А в некоторых реализациях (например, MSVC) std::atomic<>
для 16-байтовой структуры используется фактическая блокировка. (GCC7 и более поздние версии сообщают, что он не защищен от блокировки, поскольку резервный вариант lock cmpxchg16b
не имеет ожидаемой производительности, такой как масштабируемость на стороне чтения, но версия GCC технически не защищена от блокировки.)
@sedor: Re: строгое псевдонимирование: верно, вам следует tmp[i] = payload[i].load(relaxed);
скопировать в локальный tmp-массив элементов uint32_t
, а затем memcpy
оттуда в вашу фактическую структуру полезных данных. (memcpy безопасен со строгим псевдонимом, например std::bit_cast
). Или сделайте что-нибудь вручную для более простых случаев, например (uint64_t(high) << 32) | low
, чтобы объединить половинки u32 с u64.
@sedor: поскольку компиляторы оптимизируют 4 соседние std::atomic<uint32_t>
загрузки в одну 16-байтовую загрузку, это теоретически разрешено для целей, где 16-байтовая выровненная загрузка гарантированно атомарна. Но, к сожалению, в руководствах Intel или AMD нет документированной гарантии относительно атомарности векторной загрузки/сохранения и сбора/рассеивания для каждого элемента? хотя на самом деле неправдоподобно иметь разрывы внутри 4-байтовых границ, когда они являются частью более широкой нагрузки.
И тем не менее, компиляторы в настоящее время не оптимизируют атомарность. См. Почему компиляторы не объединяют избыточные записи std::atomic? - то, как они не оптимизируются, очень похоже на то, как настоящие компиляторы справляются с volatile
: они не объединяют непрерывный доступ и не удаляют мертвые хранилища даже без промежуточных операций чтения.
@Peter Cordes: Но может ли неатомарность 16-байтовых загрузок быть проблемой? Результат может оказаться поддельным, но это будет обнаружено секлокировкой. Что касается аппаратной части, то если она не вызывает исключения, все должно быть в порядке, не так ли? Что касается компилятора, нам нужно защититься от UB. Или спросить иначе: если я буду использовать 16-байтовую загрузку через встроенную сборку, это будет нормально?
@sedor: для секлока нет, это не может быть проблемой. Но std::atomic<T>
или std::atomic_ref<T>
— наш единственный способ избежать UB гонок данных для полезной нагрузки в текущем C++. Конечно, вы можете делать все, что эффективно, если писать секлокку на ассемблере или использовать расширения C++, специфичные для компилятора, такие как встроенный asm или семантику volatile
(которая в GNU C поддерживается для подобных вещей; именно так ядро Linux реализует свою собственную реализацию). атомика.)
Спасибо за все ссылки, очень интересно читать.
Что ж, но секлокки определенно полностью поддерживаются и надежны как минимум на одной платформе/компиляторе/архитектуре. Так что же они делают?