Em geral, para int num
, num++
(ou ++num
), como uma operação de leitura-modificação-gravação, não é atômica . Mas muitas vezes vejo compiladores, por exemplo o GCC , gerar o seguinte código ( tente aqui ):
Como a linha 5, que corresponde a num++
uma instrução, podemos concluir que num++
é atômica nesse caso?
E, nesse caso, significa que o gerado num++
pode ser usado em cenários simultâneos (multiencadeados) sem qualquer risco de corrida de dados (ou seja, não precisamos fazê-lo, por exemplo, std::atomic<int>
e impor os custos associados, pois é atômica de qualquer maneira)?
ATUALIZAR
Observe que esta questão não é se o incremento é atômico (não é e foi e é a linha de abertura da questão). É se pode ser em cenários específicos, ou seja, se a natureza de uma instrução pode, em certos casos, ser explorada para evitar a sobrecarga do lock
prefixo. E, como a resposta aceita menciona na seção sobre máquinas uniprocessadoras, bem como esta resposta , explicam a conversa em seus comentários e outros, ela pode (embora não com C ou C ++).
add
é atômico?std::atomic<int>
.add
instrução, outro núcleo pode roubar esse endereço de memória do cache desse núcleo e modificá-lo. Em uma CPU x86, aadd
instrução precisa de umlock
prefixo se o endereço precisar ser bloqueado no cache durante a operação.Respostas:
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á-lomemory_order_relaxed
se não se importar em reordenar. Veja abaixo alguns exemplos de código e saída asm usandofetch_add
.Mas primeiro, a linguagem assembly faz parte da pergunta:
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, semlock
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íciedec 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: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 seu
num++
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:
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á olhandoflag
). Em seguida, ele pode otimizar completamente a modificação, porqueflag
não é uniformevolatile
. (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íncronavolatile
, mas há muito mais que isso. Além disso,volatile std::atomic<int> foo
não é o mesmo questd::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 x86
lock
é uma barreira de memória completa; portanto, o usonum.fetch_add(1, std::memory_order_relaxed);
gera o mesmo código no x86 quenum++
(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.
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 umanum--;
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 umaload()
(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 umashared_ptr_unsynchronized<T>
para o gcc).Voltando à
num++; num-=2;
compilação como se fossenum--
: Compiladores podem fazer isso, a menos quenum
sejavolatile 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 (onum++
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 delock 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 examinamnum
, 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 umlock add dword [num], 0
(ienum += 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 queshared_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 , mesmomemory_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.)fonte
mov eax, 1
xadd [num], eax
(sem prefixo de bloqueio) para implementar o pós-incrementonum++
, mas não é isso que os compiladores fazem.... e agora vamos permitir otimizações:
OK, vamos dar uma chance:
resultado:
outro segmento de observação (mesmo ignorando os atrasos na sincronização do cache) não tem oportunidade de observar as alterações individuais.
comparado a:
onde o resultado é:
Agora, cada modificação é: -
A atomicidade não está apenas no nível da instrução, ela envolve todo o pipeline do processador, através dos caches, à memória e vice-versa.
Mais informações
Em relação ao efeito de otimizações de atualizações de
std::atomic
s.O padrão c ++ possui a regra 'como se', pela qual é permitido ao compilador reordenar o código e até reescrever o código, desde que o resultado tenha exatamente os mesmos efeitos observáveis (incluindo efeitos colaterais), como se ele tivesse simplesmente executado seu código . código.
A regra como se é conservadora, principalmente envolvendo atômicos.
considerar:
Como não há bloqueios mutex, atômicos ou quaisquer outras construções que influenciam o seqüenciamento entre segmentos, eu diria que o compilador é livre para reescrever essa função como um NOP, por exemplo:
Isso ocorre porque no modelo de memória c ++, não há possibilidade de outro encadeamento observar o resultado do incremento. É claro que seria diferente se
num
fossevolatile
(pode influenciar o comportamento do hardware). Mas, neste caso, esta função será a única função que modifica essa memória (caso contrário, o programa está mal formado).No entanto, este é um jogo diferente:
num
é um atômico. As alterações devem ser observáveis em outros segmentos que estão assistindo. As alterações feitas pelos próprios threads (como definir o valor para 100 entre o incremento e o decremento) terão efeitos de longo alcance no valor eventual de num.Aqui está uma demonstração:
saída de amostra:
fonte
add dword [rdi], 1
é atômico (sem o prefixo). A carga é atômica e a loja é atômica, mas nada impede que outro encadeamento modifique os dados entre a carga e a loja. Portanto, a loja pode passar por uma modificação feita por outro segmento. Consulte jfdube.wordpress.com/2011/11/30/understanding-atomic-operations . Além disso, os artigos livres de bloqueio de Jeff Preshing são extremamente bons e ele menciona o problema básico de RMW nesse artigo de introdução.lock
num++
enum--
. Se você puder encontrar uma seção no padrão que exija isso, isso resolveria isso. Tenho certeza de que isso exige apenas que nenhum observador possa ver uma reordenação errada, o que não exige um rendimento lá. Então, acho que é apenas uma questão de qualidade de implementação.Sem muitas complicações, uma instrução como esta
add DWORD PTR [rbp-4], 1
é muito semelhante ao estilo CISC.Ele realiza três operações: carrega o operando da memória, incrementa-o, armazena o operando de volta na memória.
Durante essas operações, a CPU adquire e libera o barramento duas vezes, entre qualquer outro agente também pode adquiri-lo e isso viola a atomicidade.
X é incrementado apenas uma vez.
fonte
A instrução add não é atômica. Ele faz referência à memória e dois núcleos de processador podem ter cache local diferente dessa memória.
IIRC a variante atômica da instrução add é chamada lock xadd
fonte
lock xadd
implementa C ++ std :: atomicfetch_add
, retornando o valor antigo. Se você não precisar disso, o compilador usará as instruções de destino da memória normal com umlock
prefixo.lock add
oulock inc
.add [mem], 1
ainda não seria atômico em uma máquina SMP sem cache, veja meus comentários em outras respostas.É perigoso tirar conclusões com base na montagem gerada pela "engenharia reversa". Por exemplo, você parece ter compilado seu código com a otimização desativada; caso contrário, o compilador jogaria fora essa variável ou carregaria 1 diretamente nela sem chamar
operator++
. Como o assembly gerado pode mudar significativamente, com base em sinalizadores de otimização, CPU de destino etc., sua conclusão é baseada na areia.Além disso, sua ideia de que uma instrução de montagem significa que uma operação é atômica também está errada. Isso
add
não será atômico em sistemas com várias CPUs, mesmo na arquitetura x86.fonte
Mesmo se o seu compilador sempre o emitisse como uma operação atômica, o acesso a
num
partir de qualquer outro encadeamento simultaneamente constituiria uma corrida de dados de acordo com os padrões C ++ 11 e C ++ 14 e o programa teria um comportamento indefinido.Mas é pior que isso. Primeiro, como foi mencionado, a instrução gerada pelo compilador ao incrementar uma variável pode depender do nível de otimização. Em segundo lugar, o compilador pode reordenar outros acessos à memória
++num
senum
não for atômico, por exemploMesmo se assumirmos otimisticamente que
++ready
é "atômico" e que o compilador gera o loop de verificação conforme necessário (como eu disse, é UB e, portanto, o compilador é livre para removê-lo, substitua-o por um loop infinito etc.), o O compilador ainda pode mover a atribuição do ponteiro ou, pior ainda, a inicialização dovector
para um ponto após a operação de incremento, causando caos no novo encadeamento. Na prática, eu não ficaria surpreso se um compilador de otimização removesse completamente aready
variável e o loop de verificação, pois isso não afeta o comportamento observável sob as regras da linguagem (em oposição às suas esperanças particulares).De fato, na conferência Meeting C ++ do ano passado, ouvi de dois desenvolvedores de compiladores que eles implementam com prazer otimizações que fazem com que programas multithread ingenuamente escritos se comportem mal, desde que as regras da linguagem o permitam, mesmo se houver uma pequena melhoria no desempenho em programas escritos corretamente.
Por fim, mesmo se você não se importava com a portabilidade e seu compilador era magicamente bom, a CPU que você está usando é muito provavelmente do tipo CISC superescalar e dividirá as instruções em micro-ops, reordenará e / ou executará especulativamente, até certo ponto, limitado pela sincronização de primitivas, como (na Intel) o
LOCK
prefixo ou a cerca de memória, para maximizar as operações por segundo.Para resumir uma longa história, as responsabilidades naturais da programação com thread-safe são:
Se você quiser fazer do seu jeito, pode funcionar em alguns casos, mas entenda que a garantia é nula e você será o único responsável por quaisquer resultados indesejados . :-)
PS: Exemplo corretamente escrito:
Isso é seguro porque:
ready
não podem ser otimizadas de acordo com as regras de idioma.++ready
acontece antes da verificação queready
não é zero e outras operações não podem ser reordenadas em torno dessas operações. Isso ocorre porque++ready
a verificação é sequencialmente consistente , que é outro termo descrito no modelo de memória C ++ e que proíbe essa reordenação específica. Portanto, o compilador não deve reordenar as instruções e também deve informar à CPU que não deve, por exemplo, adiar a gravaçãovec
para após o incremento deready
. Sequencialmente consistente é a garantia mais forte em relação aos átomos no padrão da linguagem. Garantias menores (e teoricamente mais baratas) estão disponíveis, por exemplo, através de outros métodos destd::atomic<T>
, mas estes são definitivamente apenas para especialistas e podem não ser muito otimizados pelos desenvolvedores do compilador, porque raramente são usados.fonte
ready
, provavelmente seria compiladowhile (!ready);
em algo mais parecidoif(!ready) { while(true); }
. Voto positivo: uma parte essencial do std :: atomic está mudando a semântica para assumir modificações assíncronas a qualquer momento. Ter o UB normalmente é o que permite que os compiladores elevem cargas e afundem as lojas de loops.Em uma máquina x86 de núcleo único, uma
add
instrução geralmente será atômica em relação a outro código na CPU 1 . Uma interrupção não pode dividir uma única instrução no meio.A execução fora de ordem é necessária para preservar a ilusão de instruções executadas uma de cada vez em ordem em um único núcleo, para que qualquer instrução executada na mesma CPU aconteça completamente antes ou completamente após a adição.
Os sistemas x86 modernos são multicore, portanto o caso especial do uniprocessador não se aplica.
Se alguém tiver como alvo um pequeno PC incorporado e não tiver planos de mover o código para outra coisa, a natureza atômica da instrução "add" poderá ser explorada. Por outro lado, plataformas nas quais as operações são inerentemente atômicas estão se tornando cada vez mais escassas.
(Porém, isso não ajuda você se estiver escrevendo em C ++. Os compiladores não têm a opção
num++
de compilar em um add ou xadd de destino de memória sem umlock
prefixo. Eles podem optar por carregarnum
em um registro e armazenar o resultado do incremento com uma instrução separada e provavelmente fará isso se você usar o resultado.)Nota de rodapé 1: O
lock
prefixo existia mesmo no 8086 original porque os dispositivos de E / S operam simultaneamente com a CPU; os drivers em um sistema de núcleo único precisamlock add
incrementar atomicamente um valor na memória do dispositivo, se o dispositivo também puder modificá-lo ou com relação ao acesso ao DMA.fonte
Naquela época em que os computadores x86 tinham uma CPU, o uso de uma única instrução garantia que as interrupções não dividissem a leitura / modificação / gravação e, se a memória também não fosse usada como um buffer DMA, era atômica (e O C ++ não mencionou threads no padrão, portanto isso não foi abordado).
Quando era raro ter um processador duplo (por exemplo, Pentium Pro de dois soquetes) em uma área de trabalho do cliente, eu efetivamente usava isso para evitar o prefixo LOCK em uma máquina de núcleo único e melhorar o desempenho.
Hoje, isso ajudaria apenas contra vários encadeamentos configurados com a mesma afinidade de CPU; portanto, os encadeamentos com os quais você está preocupado só entrariam em jogo com o intervalo de tempo expirando e executando o outro encadeamento na mesma CPU (núcleo). Isso não é realista.
Com os modernos processadores x86 / x64, a instrução única é dividida em várias micro ops e, além disso, a leitura e gravação de memória são armazenadas em buffer. Portanto, threads diferentes executados em CPUs diferentes não apenas verão isso como não atômico, mas também poderão obter resultados inconsistentes com relação ao que ele lê da memória e ao que ele supõe que outros threads tenham lido até aquele momento: você precisa adicionar cercas de memória para restaurar a integridade comportamento.
fonte
a = 1; b = a;
carregue corretamente o 1 que você acabou de armazenar.Não. Https://www.youtube.com/watch?v=31g0YE61PLQ (esse é apenas um link para a cena "Não" de "The Office")
Você concorda que isso seria uma saída possível para o programa:
saída de amostra:
Nesse caso, o compilador é livre para tornar essa a única saída possível para o programa, da maneira que o compilador desejar. ou seja, um main () que apenas coloca 100s.
Esta é a regra "como se".
E, independentemente da saída, você pode pensar na sincronização de encadeamentos da mesma maneira - se o encadeamento A faz
num++; num--;
e o encadeamento B lênum
repetidamente, uma possível intercalação válida é que o encadeamento B nunca lê entrenum++
enum--
. Como essa intercalação é válida, o compilador é livre para tornar essa a única intercalação possível. E basta remover totalmente o incr / decr.Existem algumas implicações interessantes aqui:
(ou seja, imagine que outro segmento atualize uma interface de usuário da barra de progresso com base
progress
)O compilador pode transformar isso em:
provavelmente isso é válido. Mas provavelmente não é o que o programador estava esperando :-(
O comitê ainda está trabalhando nessas coisas. Atualmente, "funciona" porque os compiladores não otimizam muito os átomos. Mas isso está mudando.
E mesmo se
progress
também fosse volátil, isso ainda seria válido:: - /
fonte
volatile
objetos atômicos, quando ele não quebrar quaisquer outras regras. Dois documentos de discussão de padrões discutem exatamente isso (links no comentário de Richard ), um usando o mesmo exemplo de contador de progresso. Portanto, é um problema de qualidade de implementação até que o C ++ padronize maneiras de evitá-lo.lock
a todas as operações. Ou alguma combinação de compilador + uniprocessador em que nem a reordenação (ou seja, "os bons e velhos tempos") tudo é atômica. Mas qual é o sentido disso? Você não pode realmente confiar nisso. A menos que você saiba que é o sistema para o qual está escrevendo. (Mesmo assim, melhor seria que atômica <int> não acrescenta ops extras nesse sistema Então você ainda deve escrever código padrão ....)And just remove the incr/decr entirely.
não está certo. Ainda é uma operação de aquisição e lançamentonum
. No x86,num++;num--
poderia compilar apenas o MFENCE, mas definitivamente não é nada. (A menos que a análise do programa inteiro do compilador possa provar que nada é sincronizado com a modificação de num e que não importa se alguns armazenamentos anteriores são atrasados até depois dos carregamentos posteriores a isso.) Por exemplo, se este foi um desbloqueio e re -caso de uso -lock-imediatamente, você ainda tem duas seções críticas separadas (talvez usando mo_relaxed), não uma grande.Sim mas...
Atômica não é o que você queria dizer. Você provavelmente está perguntando a coisa errada.
O incremento é certamente atômico . A menos que o armazenamento esteja desalinhado (e como você deixou o alinhamento com o compilador, não está), ele está necessariamente alinhado em uma única linha de cache. Com poucas instruções especiais de streaming sem armazenamento em cache, toda e qualquer gravação passa pelo cache. Linhas de cache completas estão sendo lidas e gravadas atomicamente, nunca algo diferente.
Os dados menores que o cache são, é claro, também gravados atomicamente (já que a linha de cache ao redor é).
É thread-safe?
Essa é uma pergunta diferente e há pelo menos duas boas razões para responder com um "Não!" .
Primeiro, existe a possibilidade de que outro núcleo possa ter uma cópia dessa linha de cache em L1 (L2 e superior geralmente é compartilhada, mas L1 é normalmente por núcleo!) E modifica esse valor simultaneamente. É claro que isso também acontece atomicamente, mas agora você tem dois valores "corretos" (corretamente, atomicamente modificados) - qual é o verdadeiramente correto agora?
A CPU resolverá de alguma forma, é claro. Mas o resultado pode não ser o que você espera.
Segundo, há pedidos de memória, ou palavras diferentes acontecem antes das garantias. A coisa mais importante sobre as instruções atômicas não é tanto que elas são atômicas . Está ordenando.
Você tem a possibilidade de impor uma garantia de que tudo o que acontece em termos de memória é realizado em alguma ordem garantida e bem definida, na qual você tem uma garantia "aconteceu antes". Essa ordem pode ser tão "relaxada" (leia-se: absolutamente nenhuma) ou tão rigorosa quanto você precisa.
Por exemplo, você pode definir um ponteiro para algum bloco de dados (por exemplo, os resultados de algum cálculo) e liberar atomicamente o sinalizador "os dados estão prontos". Agora, quem adquirir essa bandeira será levado a pensar que o ponteiro é válido. E, de fato, sempre será um ponteiro válido, nunca algo diferente. Isso ocorre porque a gravação no ponteiro aconteceu antes da operação atômica.
fonte
O fato de a saída de um único compilador, em uma arquitetura específica da CPU, com as otimizações desativadas (já que o gcc nem compila
++
aoadd
otimizar em um exemplo rápido e sujo ), parece implicar que o incremento dessa maneira é atômico, não significa que seja compatível com o padrão ( você causaria um comportamento indefinido ao tentar acessarnum
em um encadeamento) e está errado de qualquer maneira, porque nãoadd
é atômico no x86.Observe que atomics (usando o
lock
prefixo da instrução) é relativamente pesado no x86 ( consulte esta resposta relevante ), mas ainda notavelmente menor que um mutex, o que não é muito apropriado nesse caso de uso.Os resultados a seguir são obtidos do clang ++ 3.8 ao compilar com
-Os
.Incrementando um int por referência, a maneira "regular":
Isso compila em:
Incrementando um int passado por referência, da maneira atômica:
Este exemplo, que não é muito mais complexo do que o normal, apenas
lock
adiciona o prefixo àincl
instrução - mas cuidado, como afirmado anteriormente, isso não é barato. Só porque a montagem parece curta não significa que é rápida.fonte
Quando seu compilador usa apenas uma única instrução para o incremento e sua máquina é de thread único, seu código está seguro. ^^
fonte
Tente compilar o mesmo código em uma máquina que não seja x86 e você verá rapidamente resultados de montagem muito diferentes.
O motivo
num++
parece ser atômico porque, em máquinas x86, o incremento de um número inteiro de 32 bits é, de fato, atômico (supondo que nenhuma recuperação de memória ocorra). Mas isso não é garantido pelo padrão c ++, nem é provável que seja o caso de uma máquina que não usa o conjunto de instruções x86. Portanto, esse código não é seguro para várias plataformas contra as condições da corrida.Você também não tem uma garantia forte de que esse código esteja protegido das Condições de Corrida, mesmo em uma arquitetura x86, porque o x86 não configura cargas e armazena na memória, a menos que seja especificamente instruído a fazê-lo. Portanto, se vários encadeamentos tentaram atualizar essa variável simultaneamente, eles podem acabar incrementando valores em cache (desatualizados)
O motivo, então, que temos
std::atomic<int>
e assim por diante é que, quando você estiver trabalhando com uma arquitetura em que a atomicidade dos cálculos básicos não é garantida, você terá um mecanismo que forçará o compilador a gerar código atômico.fonte
add
realmente garantido atômico? Eu não ficaria surpreso se os incrementos de registro fossem atômicos, mas isso não é útil; para tornar o incremento do registro visível para outro encadeamento, ele precisa estar na memória, o que exigiria instruções adicionais para carregá-lo e armazená-lo, removendo a atomicidade. Meu entendimento é que é por isso que olock
prefixo existe para instruções; o único atômico útiladd
se aplica à memória não referenciada e usa olock
prefixo para garantir que a linha de cache fique bloqueada durante a operação .add
é atômico, mas deixei claro que isso não implica que o código seja seguro para as condições de corrida, porque as mudanças não se tornam visíveis globalmente imediatamente.