Требуется ли барьер памяти или атомарная операция в цикле ожидания занятости?
Рассмотрим следующее spin_lock()
реализация, первоначально из этого ответа:
void spin_lock(volatile bool* lock) {
for (;;) {
// inserts an acquire memory barrier and a compiler barrier
if (!__atomic_test_and_set(lock, __ATOMIC_ACQUIRE))
return;
while (*lock) // no barriers; is it OK?
cpu_relax();
}
}
Что я уже знаю:
volatile
предотвращает оптимизацию компилятора*lock
перечитайте на каждой итерацииwhile
петля;volatile
не вставляет ни память, ни барьеры компилятора;- такая реализация на самом деле работает в GCC для
x86
(например, в ядре Linux) и некоторые другие архитектуры; - по крайней мере один барьер памяти и компилятора требуется в
spin_lock()
реализация для общей архитектуры; этот пример вставляет их в__atomic_test_and_set()
,
Вопросы:
Является
volatile
здесь достаточно или есть какие-либо архитектуры или компиляторы, где требуется барьер памяти или компилятора или атомарная операция вwhile
цикл?1.1 Согласно
C++
стандарты?1.2 На практике, для известных архитектур и компиляторов, особенно для GCC и платформ, которые он поддерживает?
- Безопасна ли эта реализация на всех архитектурах, поддерживаемых GCC и Linux? (Это по крайней мере неэффективно на некоторых архитектурах, верно?)
- Это
while
петля безопасна в соответствии сC++11
а его модель памяти?
Есть несколько связанных вопросов, но я не смог построить однозначный и однозначный ответ из них:
Q: барьер памяти в одном потоке
В принципе: да, если выполнение программы перемещается с одного ядра на другое, оно может не увидеть все записи, которые произошли на предыдущем ядре.
Q: барьер памяти и очистка кеша
Практически во всех современных архитектурах кэши (например, кэши L1 и L2) обеспечиваются согласованным с помощью аппаратного обеспечения. Нет необходимости очищать кеш, чтобы сделать память видимой для других процессоров.
Вопрос: Является ли моя реализация спин-блокировки правильной и оптимальной?
В: Для спин-блокировки всегда требуется барьер памяти? Дорого ли крутится барьер памяти?
Вопрос: Ожидаете ли вы, что будущие поколения процессоров не будут связанными с кэшем?
3 ответа
- Является ли здесь достаточно изменчивым или существуют какие-либо архитектуры или компиляторы, где в цикле while требуется память, барьер компилятора или атомарная операция?
Будет ли изменчивый код увидеть изменения. Да, но не обязательно так быстро, как если бы был барьер памяти. В какой-то момент произойдет некоторая форма синхронизации, и новое состояние будет считано из переменной, но нет никаких гарантий относительно того, сколько всего произошло в другом месте кода.
1.1 Согласно стандартам C++?
Из http://en.cppreference.com/w/cpp/atomic/memory_order
Именно модель памяти и порядок памяти определяют обобщенное аппаратное обеспечение, над которым должен работать код. Чтобы сообщение передавалось между потоками выполнения, необходимо установить связь между потоками. Это требует либо...
- А синхронизируется с Б
- A имеет стандартную атомарную операцию перед B
- Косвенно синхронизируется с B (через X).
- A секвенируется перед X, который между потоками происходит до B
- Interthread происходит до X и X interthread происходит до B.
Поскольку вы не выполняете ни один из этих случаев, в вашей программе будут формы, в которых на некоторых современных аппаратных средствах она может дать сбой.
На практике, конец временного интервала приведет к тому, что память станет когерентной, или любая форма барьера в потоке без спин-блокировки обеспечит очистку кэшей.
Не уверен в причинах чтения волатильности, получая "текущее значение".
1.2 На практике, для известных архитектур и компиляторов, особенно для GCC и платформ, которые он поддерживает?
Поскольку код не согласуется с обобщенным ЦП, из C++11
тогда, вероятно, этот код не сможет работать с версиями C++, которые пытаются придерживаться стандарта.
Из cppreference: const volatile qualifiers Volatile access останавливает оптимизацию от перемещения работы с до и до нее, а также после и до нее.
"Это делает изменчивые объекты пригодными для связи с обработчиком сигнала, но не с другим потоком выполнения"
Таким образом, реализация должна гарантировать, что инструкции читаются из области памяти, а не из любой локальной копии. Но это не обязательно гарантирует, что энергозависимая запись будет очищена через кэши, чтобы получить согласованное представление для всех процессоров. В этом смысле нет временной границы того, как долго после записи в переменную переменной станет видимой для другого потока.
Также посмотрите kernel.org, почему volatile почти всегда неправильно в ядре
Безопасна ли эта реализация на всех архитектурах, поддерживаемых GCC и Linux? (Это по крайней мере неэффективно на некоторых архитектурах, верно?)
Нет гарантии, что изменчивое сообщение выходит из потока, который его устанавливает. Так что не совсем безопасно. На Linux это может быть безопасно.
Является ли цикл while безопасным в соответствии с C++11 и его моделью памяти?
Нет - поскольку он не создает никаких примитивов обмена сообщениями между потоками.
Это важно: в C++ volatile
не имеет ничего общего с параллелизмом! Цель volatile
это сказать компилятору, что он не должен оптимизировать доступ к затронутому объекту. Он ничего не говорит процессору, прежде всего потому, что процессор уже знает, будет ли память volatile
или нет. Цель volatile
эффективно бороться с отображением памяти ввода-вывода.
Стандарт C++ очень ясно показывает в разделе 1.10 [intro.multithread], что несинхронизированный доступ к объекту, который изменен в одном потоке и доступен (изменен или прочитан) в другом потоке, является неопределенным поведением. Примитивы синхронизации, избегающие неопределенного поведения, являются компонентами библиотеки, такими как атомарные классы или мьютексы. В этом пункте упоминается volatile
только в контексте сигналов (т. е. как volatile sigatomic_t
) и в контексте продвижения вперед (то есть, что поток в конечном итоге будет делать что-то, что имеет наблюдаемый эффект, например, доступ к volatile
объект или делать ввод / вывод). Там нет упоминания о volatile
в сочетании с синхронизацией.
Таким образом, несинхронизированная оценка переменной, разделяемой между потоками, приводит к неопределенному поведению. Объявлено ли это volatile
или не имеет значения для этого неопределенного поведения.
Со страницы Википедии о барьерах памяти:
... Другие архитектуры, такие как Itanium, предоставляют отдельные барьеры памяти "приобретать" и "освобождать", которые обращают внимание на видимость операций чтения после записи с точки зрения читателя (приемника) или пишущего устройства (источника) соответственно,
Для меня это означает, что Itanium требует подходящего ограждения, чтобы сделать чтение / запись видимыми для других процессоров, но на самом деле это может быть сделано только в целях упорядочения. Вопрос, я думаю, действительно сводится к:
Существует ли архитектура, в которой процессор может никогда не обновлять свой локальный кеш, если ему не дано указание сделать это? Я не знаю ответа, но если вы зададите вопрос в этой форме, то кто-то другой может. В такой архитектуре ваш код потенциально входит в бесконечный цикл, где чтение *lock
всегда видит одно и то же значение.
С точки зрения общей законности C++, одного атомарного теста и набора в вашем примере недостаточно, поскольку он реализует только один забор, который позволит вам увидеть начальное состояние *lock
при входе в цикл while, но не видеть, когда он изменяется (что приводит к неопределенному поведению, поскольку вы читаете переменную, которая изменяется в другом потоке без синхронизации) - поэтому ответ на ваш вопрос (1.1/3) - нет.
С другой стороны, на практике ответ на (1.2/2) - "да" (учитывая изменчивую семантику GCC), если архитектура гарантирует согласованность кэша без явных ограничений памяти, что справедливо для x86 и, вероятно, для многих архитектур, но я не может дать однозначного ответа о том, верно ли это для всех архитектур, которые поддерживает GCC. Однако, как правило, неразумно полагаться на конкретное поведение кода, которое является технически неопределенным поведением в соответствии со спецификацией языка, особенно если возможно получить тот же результат без этого.
Кстати, учитывая что memory_order_relaxed
существует, кажется, есть небольшая причина не использовать его в этом случае, а не пытаться оптимизировать вручную с помощью неатомарных операций чтения, то есть изменить цикл while в вашем примере на:
while (atomic_load_explicit(lock, memory_order_relaxed)) {
cpu_relax();
}
Например, на x86_64 атомная нагрузка становится обычной mov
инструкция и оптимизированный вывод сборки в основном такие же, как и в вашем исходном примере.