Contexto: Tenho escrito um programa multithread que usa atomics extensivamente. Percebi que esses atomics são muito lentos, especialmente no ARM, porque o compilador inseriu muitas fences, às vezes até dentro de loops. Então, quero eliminar as desnecessárias usando ordens de memória.
Eu tropecei neste caso, mas não tenho certeza se é seguro usar uma carga relaxada ou não. Veja este exemplo simples de leitura de parâmetros:
typedef struct {
big_struct Data;
_Atomic bool bDataReadDone;
} worker_thread_parameter;
static int WorkerThreadFunction(void* Parameter) {
// Read Data
worker_thread_parameter* pWorkerParameter = Parameter;
big_struct Data = pWorkerParameter->Data;
// Notify that reading Data is done
// Use release store to ensure Data is read before this.
atomic_store_explicit(&pWorkerParameter->bDataReadDone, true, memory_order_release);
// Do something with Data
}
int main() {
thrd_t aWorkerThread[8];
for (size_t i = 0; i < 8; ++i) {
worker_thread_parameter WorkerParameter = { /* Data = something */, false };
thrd_create(&aWorkerThread[i], WorkerThreadFunction, &WorkerParameter);
// Wait for Data to be read
// Use relaxed load because this thread doesn't read Data anymore,
// so we don't need to synchronize with the flag.
while (!atomic_load_explicit(&WorkerParameter.bDataReadDone, memory_order_relaxed));
}
}
Ou este exemplo:
// Initialized before the threads are started
_Atomic bool bUsingData = true;
big_struct* pData = malloc(sizeof(*pData));
static int WorkerThread() {
Use(pData);
// Notify the cleaner thread to free Data
// Use release store to ensure Data is used before this.
atomic_store_explicit(&bUsingData, false, memory_order_release);
}
static int CleanerThread() {
// Use relaxed load because this thread doesn't read Data anymore,
// so we don't need to synchronize with the flag.
while (atomic_load_explicit(bUsingData, memory_order_relaxed));
free(pData);
}
E este exemplo:
_Atomic int X = 0;
_Atomic int Y = 0;
// Thread 1
atomic_store_explicit(&X, 99, memory_order_relaxed);
atomic_store_explicit(&Y, 1, memory_order_release);
// Thread 2
if (atomic_load_explicit(&Y, memory_order_relaxed)) {
atomic_store_explicit(&X, 100, memory_order_relaxed);
printf("%i", atomic_load_explicit(&X, memory_order_relaxed));
}
// Does thread 2 always prints 100?
Suas
relaxed
cargas não criam um happen-before com o release-store, então, no modelo de memória do padrão ISO para seu último exemplo, oX=100 (relaxed)
store poderia terminar antes doX=99 (relaxed)
store naX
ordem de modificação de , com o reload capaz de ver qualquer valor dependendo do tempo.Não acho que isso seja possível em hardware real, no entanto (ou seja, o exemplo 2 só poderia imprimir 100, ou nada se o
if
não for usado). Porque os armazenamentos não podem se comprometer com o cache coerente até que não sejam especulativos. Em uma CPU exec OoO, isso significa que todas as instruções anteriores já devem ter se aposentado do ROB (Reorder Buffer).As cargas podem se aposentar assim que forem conhecidas como não defeituosas (em ISAs com modelos de memória fracos como ARM, mas não x86), mas a ramificação para implementar o
if
tem que esperar pelo resultado da carga antes de poder executar para verificar a previsão. Em uma CPU em ordem, o exec especulativo após uma ramificação não aconteceria em primeiro lugar.Então, em hardware real, acho que uma
release
loja para ordenar as lojas umas em relação às outras é suficiente para seu último exemplo.É claro que ele
X.store(100, relaxed)
pode ser executado localmente (escrevendo seu armazenamento no buffer de armazenamento ), e o recarregamento pelo mesmo thread pode ler esse valor do buffer de armazenamento (encaminhamento de armazenamento) conforme a CPU especula noif
corpo, mas um armazenamento no buffer de armazenamento deste núcleo está mais tarde na ordem de modificação do que qualquer outro que já tenha sido confirmado no cache.Não tenho certeza se o encaminhamento de armazenamento cross-SMT pode causar efeitos interessantes aqui (por exemplo, no POWER ou NVidia ARMv7). Não creio; mesmo que o thread 2 esteja vendo valores X e Y por meio do encaminhamento de armazenamento de outro núcleo lógico, acho que esse núcleo lógico ainda tem que tratar seus próprios armazenamentos como
X
mais novos do que os de outros núcleos. Caso contrário, acquire não funcionaria.Curiosidade: o modelo de memória do Linux é baseado em barreiras explícitas, como
smp_rmb()
ser uma barreira de leitura de memória para memória compartilhada entre núcleos. (Ao contrário dermb()
ser uma barreira de leitura de memória, inclusive para acessos de E/S.)https://github.com/torvalds/linux/blob/master/tools/memory-model/Documentation/control-dependencies.txt documenta que uma dependência de controle (como a sua
if()
) garante a ordenação do LoadStore quando a carga é a condição de ramificação, como no seu caso. O modelo de memória do Linux é baseado em coisas que são verdadeiras em todos os ISAs com os quais ele se importa.Acredito que o "geralmente" seja por causa de um exemplo possível mais complexo, não por causa de algumas máquinas que não garantem esse exemplo, mas qualquer um que planeje depender dele deve ler o documento inteiro.
Então, sim, tenho quase certeza de que o conjunto para o qual seu C é compilado será seguro, mas isso não é garantido na máquina abstrata C.
A implementação para __atomic .. é específica da arquitetura.
Em x86/x86_64, toda a ordenação de memória criará o mesmo código (com um prefixo de bloqueio). Em ARM/ARM64, todas as implementações são diferentes. Minha experiência é que o __ATOMIC_SEQ_CST mais forte é o melhor. (Não preciso de instruções atômicas que podem falhar!) Tenho uma implementação de lista sem bloqueio segura para simultaneidade, funcionando corretamente com apenas __ATOMIC_SEQ_CST para __atomic_compare_exchange. Você também pode tentar usar
-mtune=cortex-XX -mno-outline-atomics
for gcc para obter o melhor desempenho de código atômico .