Использование std::atomic с системным вызовом futex

В C++20 мы получили возможность засыпать атомарные переменные, ожидая изменения их значения. Мы делаем это с помощью метода std::atomic::wait.

К сожалению, в то время как wait стандартизирован, wait_for и wait_until нет. Это означает, что мы не можем спать с атомарной переменной с тайм-аутом.

Сон в атомарной переменной в любом случае реализуется за кулисами с помощью WaitOnAddress в Windows и системный вызов futex на Линукс.

Обходя вышеуказанную проблему (нет возможности спать с атомарной переменной с тайм-аутом), я мог бы передать адрес памяти std::atomic в WaitOnAddress в Windows, и он будет (вроде) работать без UB, так как функция получает void* как параметр, и допустимо приведение std::atomic<type> к void*

В Linux неясно, можно ли смешивать std::atomic с futex. futex получает либо uint32_t*, либо int32_t* (в зависимости от того, какое руководство вы читаете), а преобразование std::atomic<u/int> в u/int* является UB. С другой стороны, в инструкции написано

Аргумент uaddr указывает на слово фьютекса. На всех платформах фьютексы представляют собой четырехбайтовые целые числа, которые должны быть выровнены по четырехбайтовой границе. Операция, которую необходимо выполнить над фьютексом, указывается в аргументе futex_op; val — это значение, смысл и назначение которого зависят от futex_op.

Намек на то, что alignas(4) std::atomic<int> должно работать, и не имеет значения, какой это целочисленный тип, если тип имеет размер 4 байта и выравнивание 4.

Кроме того, я видел много мест, где реализован этот трюк с объединением атомов и фьютексов, в том числе увеличение и TBB .

Итак, как лучше всего спать с атомарной переменной с тайм-аутом без UB? Должны ли мы реализовать собственный атомарный класс с примитивами ОС, чтобы добиться этого правильно?

(Решения, такие как смешивание атомарных и условных переменных, существуют, но неоптимальны)


person David Haim    schedule 10.04.2021    source источник
comment
WaitOnAddress — это ограниченная реализация условной переменной, атомарность не имеет значения. Итак, вместо использования атомарных переменных, почему бы вам не попробовать классическую условную переменную из стандартной библиотеки?   -  person facetus    schedule 12.04.2021
comment
Пропускная способность @facetus, в основном.   -  person David Haim    schedule 15.04.2021
comment
WaitOnAddress не имеет ничего общего с атомарностью, и я уверен, что не даст вам никаких преимуществ по сравнению с std::condition_variable. WaitOnAddress ЯВЛЯЕТСЯ условной переменной по своей семантике, она просто скрывает за сценой явный мьютекс. Кроме того, он делает то же самое.   -  person facetus    schedule 18.04.2021


Ответы (1)


Вам не обязательно нужно реализовывать полный пользовательский atomic API, на самом деле должно быть безопасно просто вытащить указатель на базовые данные из atomic<T> и передать его системе.

Поскольку std::atomic не предлагает какой-либо эквивалент native_handle, как предлагают другие примитивы синхронизации, вы застрянете, выполняя некоторые специфичные для реализации хаки, чтобы попытаться заставить его взаимодействовать с собственным API.

По большей части разумно предположить, что первый член этих типов в реализации будет таким же, как тип T — по крайней мере, для целочисленных значений [1]. Это гарантия, которая позволит извлечь это значение.

... и преобразование std::atomic<u/int> в u/int* является UB

На самом деле это не так.

Стандарт гарантирует std::atomic Стандартный макет. Одним из полезных, но часто эзотерических свойств стандартных типов макетов является то, что безопасно reinterpret_cast использовать T значение или ссылку first подобъект (например, первый элемент std::atomic).

Пока мы можем гарантировать, что std::atomic<u/int> содержит только u/int в качестве члена (или, по крайней мере, в качестве его первого члена), тогда совершенно безопасно извлекать тип следующим образом:

auto* r = reinterpret_cast<std::uint32_t*>(&atomic);
// Pass to futex API...

Этот подход также должен удерживать окна для приведения atomic к базовому типу перед передачей его в void* API.

Примечание. Передача указателя T* на void*, который интерпретируется как U* (например, atomic<T>* в void*, когда он ожидает T*), является неопределенным поведением — даже со стандартным -макет гарантии (насколько мне известно). Он по-прежнему будет вероятно работать, потому что компилятор не может видеть системные API, но это не делает код правильно сформированным.

Примечание 2: я не могу говорить об API WaitOnAddress, так как на самом деле я его не использовал, но любой атомарный API, который зависит от адреса правильно выровненного целочисленного значения (void* или иного), должен работать правильно, извлекая указатель на базовое значение.


[1] Так как это помечено C++20, вы можете проверить это с помощью std::is_layout_compatible с static_assert:

static_assert(std::is_layout_compatible_v<int,std::atomic<int>>);

(Спасибо @apmccartney за это предложение в комментариях).

Я могу подтвердить, что этот макет будет совместим с Microsoft STL, libc++ и libstdc++; однако, если у вас нет доступа к is_layout_compatible и вы используете другую систему, вы можете проверить заголовки вашего компилятора, чтобы убедиться, что это предположение выполняется.

person Human-Compiler    schedule 21.04.2021
comment
Интересный момент про УБ. Было бы UB deref от int* к объекту atomic<int> без синхронизации, чтобы убедиться, что другие потоки не читают/не пишут его в это время, но передача его в futex в основном похожа на использование операции atomic_ref<int> на этом int объект - вы вызываете машинный код, который разработан для обеспечения безопасности в присутствии других потоков, одновременно читающих + записывающих. Пока atomic<T> не имеет блокировки; может быть, static_assert на is_always_lock_free будет хорошей идеей, если вы также делаете вещи с поясом и подтяжками, такие как alignas(4) std::atomic<int>. - person Peter Cordes; 21.04.2021
comment
Я думал о том, чтобы предложить static_assert на is_always_lock_free, но передумал. Расширение atomic таким образом почти всегда потребует некоторого уровня связи с реализацией std::atomic, и в этот момент, вероятно, потребуется некоторое знание реализации, чтобы действительно разделить это между родной системой. API. Кроме того, UB для удаления ссылки int* ... точки -- технически это не будет UB, поскольку указатель является допустимым и действительным. Он просто не был бы секвенирован, если бы к нему обращались в многопоточном контексте (что сделало бы его UB) - person Human-Compiler; 22.04.2021
comment
Это именно то, что я имел в виду под отсутствием синхронизации...: вы могли бы создать UB с гонкой данных, а не UB со строгим псевдонимом. - person Peter Cordes; 22.04.2021
comment
Re: Сноска [1] Это можно проверить программно. См. std::is_layout_compatible. en.cppreference.com/w/cpp/types/is_layout_compatible - person apmccartney; 15.06.2021
comment
@apmccartney Отличное предложение! Я не полностью изучил все новые трейты, добавленные в C++20, так что спасибо, что обратили на это мое внимание. - person Human-Compiler; 15.06.2021
comment
static_assert(sizeof(int) == sizeof(std::atomic<int>) && std::atomic<int>::is_always_lock_free) является разумной проверкой наилучших усилий с C++17, но без C++20. Если они имеют одинаковый размер и atomic_int равно lock_free, это исключает наиболее возможную несовместимость. - person Peter Cordes; 16.06.2021