Обычный глобальный char *ptr
должен не считаться atomi c. Иногда это может работать, особенно с отключенной оптимизацией, но вы можете заставить компилятор сделать безопасный и эффективный оптимизированный asm за счет использования современных языковых функций, чтобы сообщить ему, что вам нужна атомарность.
Используйте C11 stdatomic.h
или GNU C __atomic
builtins . И посмотрите Почему целочисленное присваивание естественно выровненной переменной atomi c на x86? - да, базовые операции asm выполняются atomi c «бесплатно», но вам нужно управлять генератором кода компилятора чтобы получить нормальное поведение для многопоточности.
См. также LWN: Кто боится большого плохого оптимизирующего компилятора? - странные эффекты использования простых переменных включают в себя несколько действительно плохих хорошо известных вещей, но также более непонятные вещи, такие как придуманные загрузки, чтение переменной более одного раза, если компилятор решает оптимизировать локальный tmp и дважды загружать общую переменную вместо загрузки ее в регистр. Использование барьеров компилятора asm("" ::: "memory")
может быть недостаточным для преодоления этого, в зависимости от того, где вы их поместили.
Поэтому используйте правильные хранилища и загрузки atomi c, которые сообщают компилятору, что вы хотите: Обычно для их чтения вам также следует использовать atomi c load.
#include <stdatomic.h> // C11 way
_Atomic char *c11_shared_var; // all access to this is atomic, functions needed only if you want weaker ordering
void foo(){
atomic_store_explicit(&c11_shared_var, newval, memory_order_relaxed);
}
char *plain_shared_var; // GNU C
// This is a plain C var. Only specific accesses to it are atomic; be careful!
void foo() {
__atomic_store_n(&plain_shared_var, newval, __ATOMIC_RELAXED);
}
Использование __atomic_store_n
в простой переменной - это функциональность, которую предоставляет C ++ 20 atomic_ref
. Если несколько потоков обращаются к переменной в течение всего времени, в течение которого она должна существовать, вы также можете просто использовать C11 stdatomi c, потому что каждый доступ должен быть atomi c (не оптимизирован в регистр или что-то еще). Если вы хотите, чтобы компилятор загрузился один раз и повторно использовал это значение, сделайте char *tmp = c11_shared_var;
(или atomic_load_explicit
, если вы хотите получить только вместо seq_cst; дешевле на нескольких ISA, отличных от x86).
Помимо отсутствия разрыва (атомарность загрузки или сохранения asm), другие ключевые части _Atomic foo *
:
Компилятор будет Предположим, что другие потоки могли изменить содержимое памяти (например, volatile
фактически подразумевает), в противном случае предположение об отсутствии UB-гонки данных позволит компилятору поднимать нагрузки из циклов. Без этого устранение мертвого хранилища могло бы сделать только одно хранилище в конце al oop, не обновляя значение несколько раз.
На практике обычно люди кусаются на стороне чтения, см. Многопоточная программа застряла в оптимизированном режиме, но нормально работает в -O0 - например, while(!flag){}
становится if(!flag) infinite_loop;
с включенной оптимизацией.
Заказ по другой код. например, вы можете использовать memory_order_release
, чтобы убедиться, что другие потоки, которые видят обновление указателя, также видят все изменения в данных, на которые указывает. (На x86 это так же просто, как упорядочивание во время компиляции, никаких дополнительных барьеров для получения / выпуска не требуется, только для seq_cst. По возможности избегайте seq_cst; mfence
или lock
ed операции выполняются медленно.)
Гарантия , что хранилище будет компилироваться в одну инструкцию asm. Вы бы зависели от этого. На практике это действительно происходит с разумными компиляторами, хотя вполне возможно, что компилятор может решить использовать rep movsb
для копирования нескольких смежных указателей, и что на какой-то машине где-то может быть микрокодированная реализация, которая делает некоторые хранилища меньше 8 байтов.
(Этот режим сбоя маловероятен; ядро Linux полагается на volatile
компиляцию загрузки / сохранения в одну инструкцию с G CC / clang для своих внутренних встроенных функций. Но если вы просто использовали asm("" ::: "memory")
, чтобы убедиться, что хранилище произошло с переменной, отличной от volatile
, есть шанс.)
Кроме того, что-то вроде ptr++
будет компилироваться в atomi c RMW операция как lock add qword [mem], 4
, а не отдельная загрузка и сохранение, как volatile
. (См. Может ли num ++ быть atomi c вместо int num? для получения дополнительной информации об atomi c RMW). Избегайте этого, если он вам не нужен, он будет медленнее. например, atomic_store_explicit(&ptr, ptr + 1, mo_release);
- загрузки seq_cst дешевы на x86-64, а хранилища seq_cst - нет.
Также обратите внимание, что барьеры памяти не могут создавать атомарность (отсутствие разрывов), они могут только создавать упорядочивание по отношению к другим операциям.
На практике x86-64 ABI действительно имеют alignof(void*) = 8
поэтому все объекты-указатели должны быть выровнены естественным образом (за исключением структуры __attribute__((packed))
, которая нарушает ABI, поэтому вы можете использовать на них __atomic_store_n
. Она должна компилироваться в соответствии с вашими потребностями (обычное хранилище, без накладных расходов) и соответствовать Требования asm должны быть atomi c.
См. также Когда использовать volatile с многопоточностью? - вы можете свернуть свои собственные атомики с volatile
и барьерами памяти asm, но не Т. е. Ядро Linux делает это, но это требует больших усилий и практически никакой выгоды, особенно для программы пользовательского пространства.
Дополнительное примечание: часто повторяющееся заблуждение состоит в том, что volatile
или _Atomic
необходимы, чтобы избежать чтения устаревших значений из кеша . Это не случай.
Все машины, на которых выполняются потоки C11 на нескольких ядрах, имеют согласованные кеши, не нуждающиеся в явных sh инструкциях читателя или записывающего устройства. Обычные инструкции загрузки или сохранения, например x86 mov
. Ключ состоит в том, чтобы не позволять компилятору сохранять значения разделяемой переменной в регистрах CPU (которые являются частными для потоков). Обычно он может выполнить эту оптимизацию из-за предположения об отсутствии неопределенного поведения гонки данных. Регистры - это не то же самое, что кэш ЦП L1d; управление тем, что находится в регистрах по сравнению с памятью, осуществляется компилятором, в то время как оборудование синхронизирует кеш c. См. Когда использовать volatile с многопоточностью? для получения дополнительных сведений о том, почему когерентных кешей достаточно, чтобы volatile
работал как memory_order_relaxed
.
См. Программа многопоточности застряла в оптимизированной режим, но обычно работает в -O0 для примера.