Isso é absolutamente o que o C ++ define como uma corrida de dados que causa comportamento indefinido, mesmo que um compilador produza código que fez o que você esperava em alguma máquina de destino. Você precisa usar std::atomic
para obter resultados confiáveis, mas pode usá-lo memory_order_relaxed
se não se importar em reordenar. Veja abaixo alguns exemplos de código e saída asm usando fetch_add
.
Mas primeiro, a linguagem assembly faz parte da pergunta:
Como num ++ é uma instrução ( add dword [num], 1
), podemos concluir que num ++ é atômico nesse caso?
As instruções de destino da memória (que não sejam armazenamentos puros) são operações de leitura, modificação e gravação que ocorrem em várias etapas internas . Nenhum registro arquitetural é modificado, mas a CPU precisa reter os dados internamente enquanto os envia pela ALU . O arquivo de registro real é apenas uma pequena parte do armazenamento de dados, mesmo na CPU mais simples, com travas segurando as saídas de um estágio como entradas para outro estágio, etc., etc.
As operações de memória de outras CPUs podem se tornar visíveis globalmente entre a carga e a loja. add dword [num], 1
Ou seja, dois threads rodando em loop entrariam nas lojas um do outro. (Veja a resposta de @ Margaret para um bom diagrama). Após incrementos de 40k de cada um dos dois threads, o contador pode ter subido apenas ~ 60k (não 80k) no hardware x86 real com vários núcleos.
"Atômico", da palavra grega que significa indivisível, significa que nenhum observador pode ver a operação como etapas separadas. Acontecer fisicamente / eletricamente instantaneamente para todos os bits simultaneamente é apenas uma maneira de conseguir isso para uma carga ou armazenamento, mas isso nem é possível para uma operação de ALU. Entrei em muito mais detalhes sobre cargas puras e lojas puras na minha resposta ao Atomicity no x86 , enquanto essa resposta se concentra na leitura-modificação-gravação.
O lock
prefixo pode ser aplicado a muitas instruções de leitura-modificação-gravação (destino da memória) para tornar toda a operação atômica em relação a todos os observadores possíveis no sistema (outros núcleos e dispositivos DMA, não um osciloscópio conectado aos pinos da CPU). É por isso que existe. (Veja também estas perguntas e respostas ).
O mesmo lock add dword [num], 1
é atômico . Um núcleo de CPU executando essa instrução manteria a linha de cache fixada no estado Modificado em seu cache L1 privado, desde quando a carga lê os dados do cache até que o armazenamento retorne ao cache o resultado. Isso impede que qualquer outro cache do sistema tenha uma cópia da linha de cache em qualquer momento do carregamento para o armazenamento, de acordo com as regras do protocolo de coerência de cache MESI (ou as versões MOESI / MESIF usadas pelo AMD / CPUs Intel, respectivamente). Assim, operações por outros núcleos parecem ocorrer antes ou depois, não durante.
Sem o lock
prefixo, outro núcleo poderia se apropriar da linha de cache e modificá-la após nossa carga, mas antes de nossa loja, para que outra loja se tornasse visível globalmente entre nossa carga e loja. Várias outras respostas entendem isso errado e afirmam que, sem lock
você, você obteria cópias conflitantes da mesma linha de cache. Isso nunca pode acontecer em um sistema com caches coerentes.
(Se uma lock
instrução ed opera em memória que abrange duas linhas de cache, é preciso muito mais trabalho para garantir que as alterações em ambas as partes do objeto permaneçam atômicas à medida que se propagam a todos os observadores, para que nenhum observador possa ver o rasgo. precisa bloquear todo o barramento de memória até que os dados cheguem à memória. Não desalinhe suas variáveis atômicas!)
Observe que o lock
prefixo também transforma uma instrução em uma barreira de memória completa (como MFENCE ), interrompendo toda a reordenação em tempo de execução e, assim, fornecendo consistência sequencial. (Veja a excelente publicação no blog de Jeff Preshing . Suas outras publicações também são excelentes e explicam claramente muitas coisas boas sobre programação sem bloqueio , do x86 e outros detalhes de hardware às regras do C ++.)
Em uma máquina uniprocessadora ou em um processo de thread único, uma única instrução RMW é realmente atômica sem um lock
prefixo. A única maneira de outro código acessar a variável compartilhada é a CPU fazer uma alternância de contexto, o que não pode acontecer no meio de uma instrução. Portanto, uma planície dec dword [num]
pode sincronizar entre um programa de thread único e seus manipuladores de sinal ou em um programa de múltiplos threads executando em uma máquina de núcleo único. Veja a segunda metade da minha resposta em outra pergunta e os comentários abaixo, onde explico isso com mais detalhes.
Voltar para C ++:
É totalmente falso usar num++
sem informar ao compilador que você precisa compilar em uma única implementação de leitura-modificação-gravação:
;; Valid compiler output for num++
mov eax, [num]
inc eax
mov [num], eax
Isso é muito provável se você usar o valor de num
mais tarde: o compilador o manterá ativo em um registro após o incremento. Portanto, mesmo se você verificar como é num++
compilado por conta própria, a alteração do código ao redor pode afetá-lo.
(Se o valor não for necessário posteriormente, inc dword [num]
é preferível; as modernas CPUs x86 executam uma instrução RMW de destino da memória pelo menos com a mesma eficiência que usam três instruções separadas. Curiosidade: gcc -O3 -m32 -mtune=i586
na verdade, emitirá isso , porque o pipeline superescalar do Pentium P5 não decodifique instruções complexas para várias micro-operações simples, como fazem as microarquiteturas P6 e posteriores. Consulte as tabelas de instruções / guia de microarquitetura da Agner Fog para obter mais informações, ex86 marque o wiki para obter muitos links úteis (incluindo os manuais ISA x86 da Intel, disponíveis gratuitamente em PDF).
Não confunda o modelo de memória de destino (x86) com o modelo de memória C ++
A reordenação em tempo de compilação é permitida . A outra parte do que você obtém com o std :: atomic é o controle sobre a reordenação em tempo de compilação, para garantir que o seunum++
se torne globalmente visível somente após alguma outra operação.
Exemplo clássico: armazenando alguns dados em um buffer para outro encadeamento examinar e definindo um sinalizador. Mesmo que o x86 adquira carregamentos / lançamentos de graça, você ainda precisa informar ao compilador para não reordenar usando flag.store(1, std::memory_order_release);
.
Você pode esperar que esse código seja sincronizado com outros threads:
// flag is just a plain int global, not std::atomic<int>.
flag--; // This isn't a real lock, but pretend it's somehow meaningful.
modify_a_data_structure(&foo); // doesn't look at flag, and the compilers knows this. (Assume it can see the function def). Otherwise the usual don't-break-single-threaded-code rules come into play!
flag++;
Mas não vai. O compilador é livre para mover a flag++
chamada de função (se ele incluir a função ou souber que ela não está olhando flag
). Em seguida, ele pode otimizar completamente a modificação, porque flag
não é uniforme volatile
. (E não, C ++ volatile
não é um substituto útil para std :: atomic. Std :: atomic faz o compilador assumir que os valores na memória podem ser modificados de forma assíncrona volatile
, mas há muito mais que isso. Além disso, volatile std::atomic<int> foo
não é o mesmo que std::atomic<int> foo
, conforme discutido com @Richard Hodges.)
Definir corridas de dados em variáveis não atômicas como Comportamento indefinido é o que permite que o compilador ainda carregue cargas e afunde armazenamentos fora de loops, e muitas outras otimizações de memória às quais vários segmentos podem ter uma referência. (Consulte este blog do LLVM para obter mais informações sobre como o UB permite otimizações do compilador.)
Como mencionei, o prefixo x86lock
é uma barreira de memória completa; portanto, o uso num.fetch_add(1, std::memory_order_relaxed);
gera o mesmo código no x86 que num++
(o padrão é consistência sequencial), mas pode ser muito mais eficiente em outras arquiteturas (como o ARM). Mesmo no x86, o relaxado permite mais pedidos em tempo de compilação.
É o que o GCC realmente faz no x86, para algumas funções que operam em uma std::atomic
variável global.
Veja o código da fonte + assembly em formato bem formatado no Godbolt compiler explorer . Você pode selecionar outras arquiteturas de destino, incluindo ARM, MIPS e PowerPC, para ver que tipo de código de linguagem assembly você obtém dos atomics para esses destinos.
#include <atomic>
std::atomic<int> num;
void inc_relaxed() {
num.fetch_add(1, std::memory_order_relaxed);
}
int load_num() { return num; } // Even seq_cst loads are free on x86
void store_num(int val){ num = val; }
void store_num_release(int val){
num.store(val, std::memory_order_release);
}
// Can the compiler collapse multiple atomic operations into one? No, it can't.
# g++ 6.2 -O3, targeting x86-64 System V calling convention. (First argument in edi/rdi)
inc_relaxed():
lock add DWORD PTR num[rip], 1 #### Even relaxed RMWs need a lock. There's no way to request just a single-instruction RMW with no lock, for synchronizing between a program and signal handler for example. :/ There is atomic_signal_fence for ordering, but nothing for RMW.
ret
inc_seq_cst():
lock add DWORD PTR num[rip], 1
ret
load_num():
mov eax, DWORD PTR num[rip]
ret
store_num(int):
mov DWORD PTR num[rip], edi
mfence ##### seq_cst stores need an mfence
ret
store_num_release(int):
mov DWORD PTR num[rip], edi
ret ##### Release and weaker doesn't.
store_num_relaxed(int):
mov DWORD PTR num[rip], edi
ret
Observe como o MFENCE (uma barreira completa) é necessário após um armazenamento de consistência sequencial. O x86 é fortemente ordenado em geral, mas a reordenação do StoreLoad é permitida. Ter um buffer de armazenamento é essencial para um bom desempenho em uma CPU fora de ordem em pipeline. A Reordenação de Memória de Jeff Preshing Caught in the Act mostra as consequências de não usar o MFENCE, com código real para mostrar a reordenação acontecendo em hardware real.
Re: discussão nos comentários sobre a resposta de @Richard Hodges sobre os compiladores que mesclam num++; num-=2;
operações std :: atomic em uma num--;
instrução :
Perguntas e respostas separadas sobre o mesmo assunto: Por que os compiladores não mesclam redundantes std :: atomic write? , onde minha resposta reafirma muito do que escrevi abaixo.
Os compiladores atuais ainda não fazem isso (ainda), mas não porque não estão autorizados. C ++ WG21 / P0062R1: Quando os compiladores devem otimizar os átomos? discute a expectativa de muitos programadores de que os compiladores não farão otimizações "surpreendentes" e o que o padrão pode fazer para dar controle aos programadores. O N4455 discute muitos exemplos de coisas que podem ser otimizadas, incluindo este. Ele ressalta que inline e propagação constante podem introduzir coisas como as fetch_or(0)
que podem se transformar em apenas uma load()
(mas ainda adquirem e liberam semântica), mesmo quando a fonte original não possui operações atômicas obviamente redundantes.
Os motivos reais pelos quais os compiladores ainda não o fazem são: (1) ninguém escreveu o código complicado que permitiria ao compilador fazer isso com segurança (sem nunca errar) e (2) potencialmente viola o princípio de menos surpresa . O código sem bloqueio é difícil o suficiente para escrever corretamente em primeiro lugar. Portanto, não seja casual no uso de armas atômicas: elas não são baratas e não otimizam muito. std::shared_ptr<T>
Porém, nem sempre é fácil evitar operações atômicas redundantes , já que não há uma versão não atômica (embora uma das respostas aqui forneça uma maneira fácil de definir uma shared_ptr_unsynchronized<T>
para o gcc).
Voltando à num++; num-=2;
compilação como se fosse num--
: Compiladores podem fazer isso, a menos que num
seja volatile std::atomic<int>
. Se uma reordenação for possível, a regra como se permite que o compilador decida no tempo de compilação que sempre acontece dessa maneira. Nada garante que um observador possa ver os valores intermediários (o num++
resultado).
Ou seja, se a ordem em que nada se torna globalmente visível entre essas operações é compatível com os requisitos de ordem da fonte (de acordo com as regras C ++ para a máquina abstrata, não a arquitetura de destino), o compilador pode emitir um único em lock dec dword [num]
vez de lock inc dword [num]
/ lock sub dword [num], 2
.
num++; num--
não pode desaparecer, porque ainda possui um relacionamento Sincronizar com com outros threads que examinam num
, e é um carregamento de aquisição e um armazenamento de versão que não permite a reordenação de outras operações nesse segmento. Para x86, isso pode ser compilado em um MFENCE, em vez de em um lock add dword [num], 0
(ie num += 0
).
Conforme discutido no PR0062 , a fusão mais agressiva de operações atômicas não adjacentes em tempo de compilação pode ser ruim (por exemplo, um contador de progresso é atualizado apenas uma vez no final, em vez de cada iteração), mas também pode ajudar no desempenho sem desvantagens (por exemplo, pular o atômica inc / dec de ref conta quando uma cópia de a shared_ptr
é criada e destruída, se o compilador puder provar que shared_ptr
existe outro objeto durante toda a vida útil do temporário.)
Até a num++; num--
mesclagem pode prejudicar a implementação de um bloqueio quando um thread é desbloqueado e bloqueado imediatamente. Se ele nunca for realmente liberado no asm, mesmo os mecanismos de arbitragem de hardware não darão a outro thread a chance de abrir o bloqueio naquele momento.
Com o gcc6.2 atual e o clang3.9, você ainda obtém lock
operações separadas , mesmo memory_order_relaxed
no caso mais obviamente otimizável. ( Explorador do compilador Godbolt para que você possa ver se as versões mais recentes são diferentes.)
void multiple_ops_relaxed(std::atomic<unsigned int>& num) {
num.fetch_add( 1, std::memory_order_relaxed);
num.fetch_add(-1, std::memory_order_relaxed);
num.fetch_add( 6, std::memory_order_relaxed);
num.fetch_add(-5, std::memory_order_relaxed);
//num.fetch_add(-1, std::memory_order_relaxed);
}
multiple_ops_relaxed(std::atomic<unsigned int>&):
lock add DWORD PTR [rdi], 1
lock sub DWORD PTR [rdi], 1
lock add DWORD PTR [rdi], 6
lock sub DWORD PTR [rdi], 5
ret
add
é atômico?