Comments 60
От мыслей о барьерах и слабой модели памяти вас не защитит и C# с Java :-)
Если для вас это проблема — не используйте lock-free. А многопоточное программирование само по себе очень сложная тема.
if (g_flag) f(g_data1, g_data2, g_data3);
Если бы было чтение или запись в эти переменные до проверки g_flag, то действительно была бы гонка, а с т.з. стандарта языка — UB.
«A store operation with this memory order performs the release operation: no reads or writes in the current thread can be reordered after this store. All writes in the current thread are visible in other threads that acquire the same atomic variable»
(взято отсюда: en.cppreference.com/w/cpp/atomic/memory_order)
the same atomic variable.
Т.е. упорядочиваются все операции чтения/записи в конкретную атомик переменную.
На уровне x86 CPU — это означает синхронизацию(очистка store-buffer) конкретной кеш линии с переменной, а не вообще всей памяти.
работает «случайно».
#include <atomic>
void test(int *ptr1, int*ptr2, std::atomic<bool> f)
{
*ptr1 = 1;
*ptr2 = 2;
f.store(true, std::memory_order_release);
}
test(int*, int*, std::atomic<bool>): // @test(int*, int*, std::atomic<bool>)
mov w8, #1
mov w9, #2
str w8, [x0]
str w9, [x1]
stlrb w8, [x2]
ret
STLRB — Store-release register byte
A store-release will be observed by each observer after that observer observes any loads or stores that appear in program order before the store-release, but says nothing about loads and stores appearing after the store-release.
Т.е. запись во флаг (STLRB) выполняется только после того как другие ядра увидят все чтения и записи выполненные до инструкции stlrb в порядке следования программы.
Но в примере выше, по факту это барьер памяти, который требует выдавить из буферов записи все перед тем как запишет сам.
На этой платформе это так, а как будет на остальных?
Пойду как почитаю про модель памяти в с++)
К тому-же в ARM на уровне команд реализована атомарная передача данных, для случаев когда объект на запись доступен всем.
Нет такого.
volatile — всегда сохранять в память
const volatile — всегда читать из памяти
Эти комбинации модификаторов сами по себе являются барьерами памяти. То-есть они будут безусловно выполнены, даже если есть валидная копия в регистре. Но это не спасает от оптимизатора — чтение/запись могут быть буквально где у годно. И тогда приходят на помощь операторы условия и цикла — способ выполнить участок кода в строго определённом месте.
Атомарная передача для ARM __STREXW(), и так далее.
Но на практике практически всегда работает, конечно.
И то, о чём вы говорите ни разу не помогает в обсуждаемом вопросе — на ARM чтение вашего const volatile точно так же может быть исполнено ПОСЛЕ того, как начнётся работа этим значением — это аппаратная особенность. Т.е. всё равно нужны платформо-зависимые барьеры чтения/записи, а значит, ничего не меняется — хоть оно двунаправленное, хоть однонапрвленное.
int var1 = ...
...
const int& var2 = var1;
Var2 просто может появляться в контексте в котором в неё нельзя писать, и всё. Тоже самое относиться и к классам и их методам.
Volatile — не разрешает кеширование переменной. Т.е. её чтение всегда должно производится из памяти. Это легко можно проверить посмотрев выход ассемблера.
Const volatile — заставляет всегда читать значение переменной из памяти и запрещает туда писать, но значение может бы изменено извне.
Модификатор volatile также не имеет никакого отношения к многопоточности и не может заставить компилятор вставлять барьеры и не управляет когерентностью кешей.
По станандарту изменение const-значения приводит к неопределнному поведению вне зависимости от volatile. Вот, например цитата из C++11 раздел 1.9 пункт 4 (повторяется в стандарте несколько раз):
Certain other operations are described in this International Standard as undefined (for example, the effect of attempting to modify a const object).Аналогичное правило есть во всех остальных версиях стандарта.
В результате компилятор имеет право кэшировать volatile const значения. Да, он обязан прочитать их в месте использовани и соблюсти семантику volatile, но только один раз.
Сюрприз-сюрприз.
const int var = 5; // Думаю всегда заоптимизируется
const int var1 = 5; // Скорее всего не будет оптимизирован
const int& var2 = &var1;
const MyClass var1(..., ..., ...); // Зависит от класса, но рассчитывать на это я бы не стал
const Huge& MyClass::GetHuge() const
{
return m_huge;
}
Huge MyClass::GetHuge()
{
return m_huge;
}
Я всегда пишу код соблюдающий const-корректность и это сильно помогает, но все же, думаю, лучше рассматривать const не как оптимизацию, а как выражение семантики неизменяемости с точки зрения внешнего кода. Т.е. мы гарантированно не можем поменять состояние объекта снаружи, но это не значит что он не может поменяться откуда-то еще.Но человек же утверждает что const отменяет volatile и вот это уже очень странно, т.к. такое поведение полностью нарушает логику кода, на мой взгляд.
int var1 = 1;
const int& var2 = var1;
var1 = 2; //var2 тоже станет равен 2 и это легально
А про const_cast это лишь к тому, что в языке столько инструментов для того, чтобы сломать const, что у компилятора нет практически никаких шансов чего-то там закэшировать. Уж точно он не может кэшировать значение, на которое ссылается константный указатель. Это значение может поменяться в любой момент. Вот сам указатель пусть кэширует сколько влезет.
Там указатели, а вы тут пример ссылки приводите
Указатель/ссылка здесь абсолютно эквивалентны. Заменяем & на * и пример остается ровно таким же.
Ваш пример ссылками некорректен
Я вам скинул ссылку на isocpp.org (где как раз про алиазинг речь), где черным по белому говорят, что это корректный код. Давайте тогда цитату из стандарт, если считаете это UB. Ваш цитата выше тут не подходит.
const int var = 5;
...
*const_cast<int&>(&var) = 6;
Volatile же как раз и нужен для того, что бы сказать компилятору что бы он не кешировал переменную. У вас же получается что const отменяет volatile. Вы можете привести ссылку на стандарт где бы было об этом прямо написано?
volatile влияет на const, не так как вы думаете. В почему-то много додумываете за стандарт. Для volatile будет соблюдён порядок выполнения относительно других volatile. Само чтение будет исполнено. Но результат может быть запомнен! Где тут «отмена» const? Без «volatile» компилятор вообще может переставить чтение в другое место или опустить его если значение не используется, так что ничего не отменяется. Но кэшировать значение компилятор может.
const volatile, кстати — неопределённое поведение
Это не свойства переменной, это модификатор памяти где она находится. Значение переменной, и конкретные байты в физической памяти, где лежит эта переменная — могут иметь разные модификаторы.
Есть очень нехорошая шутка «register volatile», за которую могут оторвать руки и ноги. А если не дойдёт с четвёртого раза, то и голову.
Кстати, раз тут вспомнили М1 — то там есть возможность нативно отключить для потока слабую модель памяти и использовать TSO ;-)
Вопрос, разве g_dataN не должны быть тоже атомарными (например для простых типов), что бы memory_order при g_flag.store() имел на них влияние?
Смотрите, тут есть два вопроса.
Один — это доступ к разделяемым данным. Атомарные операции над атомарными типами позволяют нескольким процессорам наблюдать определённые состояние атомарных величин. Естественно, если процессоры совместно, одновременно выполняют неатомарные операции с разделяемыми данными — они наблюдают их в неопределённом состоянии. Поэтому подразумевается, что атомарный g_flag используется для синхронизации доступа к неатомарным g_data. То есть, только один процессор в один момент времени — на протяжении «критической секции» — должен работать с g_data. В этом случае этот процессор будет уверенно наблюдать определённое поведение g_data: если туда записать 123, там так и останется 123 — ведь никто другой больше не трогает g_data.
Другой вопрос — это каким образом атомарные операции с g_flag гарантируют, что при входе в критическую секцию процессор увидит изменения, которые выполнялись с «не связанными» данными в g_data. Ведь как вы правильно заметили — это совершенно разные регионы памяти и не очевидно, почему атомарная операция по адресу &g_flag должна вдруг влиять на значения по адресу &g_data. Вопрос можно поставить и по-другому: будет ли g_flag влиять на g_some_other_data? Откуда процессор знает, что именно g_data связана с g_flag, а не что-то другое?
Ответ на этот вопрос в общем случае: процессор не знает. Порядок сюда привносят протоколы когерентности кеша. Ведь именно из-за наличия кешей один процессор может не видеть изменения, выполненные другим процессором. Протоколы когерентности определяют, как сигнализировать об изменениях так, чтобы их было видно, и чтобы процессоры в итоге видели более-менее последовательную, когерентную картину в своих кешах.
Наиболее примивная реализация «барьеров памяти»: атомарные операции влияют на всю память сразу. После атомарной операции с g_flag (или любым другим флагом) процессор увидит обновления в g_data (или любом другом месте памяти). Когда один процессор трогает атомарную переменную, то все другие процессоры обязаны сбросить все свои кеши и читать значения из памяти.
Более продвинутая реализация — это когда процессор синхронизирует изменения в кешах только если он атомарно прочитал именно переменную g_flag. Ещё более продвинутая — это когда вместе с переменной g_flag процессору можно сказать, что должно быть инвалидировано для других процессоров, когда они тронут g_flag.
Один процессор, находясь в критической секции записал в неатомарную переменную i=42. А когда это i реально окажется в ОЗУ?
В это время второй процессор в нетерпении перетаптывается на входе в критическую секцию и как только она освобождается — мигом читает значение этой переменной… из СВОЕГО кеша.
Или первый процессор, во время записи переменной, даже не скинув ещё данные в ОЗУ — сообщает остальным, что если у кого в кеше есть данные с этого адреса, то они протухли, ждите, сейчас скину обновление?
Вот именно это и решает протокол когерентности кешей: как сделать так, чтобы в этом случае второй процессор прочитал то, что записал первый, но при этом не делать так, чтобы любая запись в кеш одним процессором инвалидировала кеши всех остальных.
Для ARM не скажу, к сожалению, я в их деталях не силён. Например, x86 процессоры Intel используют протокол MESIF. В нём процессоры обмениваются информацией о том, что происходит с их кеш-линиями, и могут отправлять их друг другу в обход памяти. (Для того, чтобы i = 42
добралось до RAM, надо сначала пройти через L2 и L3 кеш.)
И действительно, как только один процессор пишет что-то в свою кеш-линию, где живёт i
, то аналогичные кеш-линии других процессоров переходят в состояние Invalid. Когда другой процессор хочет прочитать переменную, то он знает, что его кеш протух и надо идти его обновлять.
Как в эту картину входят атомарные переменные? Дело в том, что для эффективности другие процессоры получают информацию об инвалидированных кеш-линиях асинхронно. Поэтому если второй процессор будет просто читать из своего кеша, то он будет в курсе об инвалидации не сразу. Однако, он может выполнить «барьер чтения» — обработать очередь инвалидированных кеш-линий — и тогда при чтении переменной он будет в курсе, что значение в его кеше протухло.
Кроме того, запись из регистров в кеш-линию тоже происходит не напрямую. Записи обычно попадают в ещё одну очередь, а попадают в кеш этого же процессора уже оттуда. Роль «барьера записи» как раз в том, чтобы перелить все обновления в кеш (и разослать уведомления другим процессорам, где что протухло).
Таким образом, в процессе входа в критическую секцию второй процессор выполняет барьер чтения, и благодаря этому он оказывается в курсе, что его кеш-линия протухла (или не протухла, если ничего не поменялось). А эта информация гарантированно попала к нему потому, что первый процессор после записи выполнил барьер записи. Если барьеры не согласованы, то обновления могут и не дойти.
В моём представлении — барьер памяти это… таки «барьер», через который процессор не может перемещать определённые (или все — в зависимости от типа барьера) команды в процессе переупорядочивания операций, чем балуются процессоры для повышения эффективности работы.
А можно поподробнее, как барьеры памяти влияют на кэш?
Да, правильно: барьер не позволяет процессору перемещать операции через него. Ключевая идея здесь в том, что «повторное чтение значения из кеша» — это фактически операция «чтения из памяти», перемещённая далеко в прошлое, в тот момент, когда значение попало в кеш (и его считали из памяти таким, каким оно тогда было). Барьер чтения не даёт выполнить процессору такое «как бы перемещение», запрещая читать данные из кеша (если он инвалидирован; если же значение занесено в кеш уже после барьера — пожалуйста). Аналогично, барьер записи не позволяет процессору отложить запись в кеш куда-то в будущее, на потом, вынуждая записать данные сейчас.
Сейчас мне пора идти спать. Если всё ещё не понятно, я завтра постараюсь нарисовать схему, как это примерно работает.
Для ARM барьеры на кэш не влияют — они работают только внутри процессорного ядра. Когерентность кэша описывается отдельно и кэши ядер могут быть когерентными или некогерентными. Вплоть до того, что это это может быть отельной управляемой опцией шины/кэша для каждого из ядер — Snoop Control Logic (SCU).
Если кэши некогерентны, то нужно ещё кроме барьеров чтения/записи выдавать инструкции сброса/инвалидации кэша по нужным адресам — DCCSW, например.
Эффективная многоядерность на ARM этот ещё адок.
2. В процессоре кроме кэш памяти есть ещё очереди чтения/записи. Барьеры гарантируют, что эти очереди будут сброшены/очищены. Команды барьеров, например есть на архитектура вообще без кэша. Cortex-M3, например, в лице всяких STM32. Кэшей там нет, а команды барьеров есть и нужны. Без этих команд запись в порт будет выполена не во время исполня соответствующей инструкции, а когда-нибудь потом.
3. Управление кэшированием может осуществляться в MMU (зависит от архитектуры и реализации). Т.е. для некоторых регионов памяти кэширование может выключенено или частично выключено (всякие аппаратыне регистры, DMA и т.п.), но барьеры всё равно нужны в силу особенностей, описанных в пункте 2.
Получается запись (или чтение) атомарных переменных это невероятно дорого, так как по сути сбрасывается кэш на всех процессорах? А использование shared_ptr влияет на все потоки, так как там тоже атомарные переменные.
Спасибо за ответ! я пару лет назад пытался сымитировать такое спонтанное чтение, но не смог, я так понял что причина в была в x86 которая не даёт такой проблемы.
А использование shared_ptr влияет на все потоки, так как там тоже атомарные переменные.
Атомарный там только счётчик ссылок, поэтому синхронизация происходит только при копировании/разрушении shared_ptr. Доступ к объекту по указателю не является атомарным (и дорогим), поэтому в многопоточной среде требует внешних средств синхронизации.
На самом деле любая запись инвалидирует кеш у других процессоров. Но не сразу, а с задержкой. А вот запись + барьер инвалидирует кеш сразу же.
То есть, без барьеров вообще процессор может какое-то время читать из своего кеша старое значение, а параллельно с ним по этому же адресу три других процессора уже записали три своих значения. Со временем первый процессор конечно же увидит обновления: до него дойдёт информация об инвалидации, он обновит свой кеш.
А вот если у первого процессора перед каждым чтением стоит барьер чтения, то этот барьер будет каждый раз сбрасывать процессору кеш. У тех трёх пишущих процессоров после записи тоже должен стоять барьер записи, который гарантирует, что у последующих читателей сбросится кеш.
Особенность x86 в том, что его LOCK-префикс — это полный барьер: и на запись после операции, и на чтение перед. Поэтому там не только дорого читать с этим префиксом (что логично: вы только что возможно сбросили себе кеш), но и писать тоже — после записи у вас теперь возможно сброшенный кеш. Был бы барьер только на запись — пишущие процессоры могли бы дальше считать, что то, что они только что записали — это актуальное значение.
Вернее как, барьер сбрасывает не столько кеш — барьер не приводит к тому, что после барьера процессор будет всегда читать из памяти, даже если его кеш актуальный. Барьер актуализирует информацию о валидности кеша, сбрасывает «кеш кеша», так сказать.
Дороги не атомарные операции сами по себе. Дороги барьеры.
del
Если компилятор переставит местами инструкции записи, то флаг g_flag может получить значение true до того, как будут записаны все данные
Да что все о перестановках?
Достаточно иметь разные переменные на разных кэшлайнах, а потоки- на ядрах, имеющих собственный кэш. И пожалуйста- до ядра «читателя» разные переменные (линии кэша) доезжают в произвольном порядке.
И эти люди ругают меня за то, что я использую GoTo.
ARM и программирование без блокировок