Um num ++ pode ser atômico para ‘int num’?

Em geral, para int num , num++ (ou ++num ), como uma operação read-modify-write, não é atômica . Mas muitas vezes vejo compiladores, por exemplo, o GCC , gerar o seguinte código para ele ( tente aqui ):

Digite a descrição da imagem aqui

Como a linha 5, que corresponde a num++ é uma instrução, podemos concluir que num++ é atômico nesse caso?

E se assim for, significa que o num++ gerado pode ser usado em cenários simultâneos (multi-thread) sem qualquer perigo de corridas de dados (ou seja, não precisamos torná-lo, por exemplo, std::atomic e impor os custos associados, desde que é atômico de qualquer maneira)?

ATUALIZAR

Note que esta questão não é se o incremento é atômico (não é e essa 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 prefixo de lock . E, como a resposta aceita menciona na seção sobre máquinas de uniprocessador, bem como esta resposta , a conversa em seus comentários e outros explicam, pode (embora não com C ou C ++).

Isso é absolutamente o que o C ++ define como uma Corrida de Dados que causa o 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 você pode usá-lo com memory_order_relaxed se não se importar com a reordenação. Veja abaixo alguns exemplos de código e saída asm usando fetch_add .


Mas primeiro, a parte da linguagem assembly da pergunta:

Como num ++ é uma instrução ( add dword [num], 1 ), podemos concluir que num ++ é atômico neste caso?

As instruções de destino da memory (diferentes de armazenamentos puros) são operações de leitura-modificação-gravação que ocorrem em várias etapas internas . Nenhum registro de arquitetura é modificado, mas a CPU precisa manter os dados internamente enquanto os envia através de sua ALU . O arquivo de registro real é apenas uma pequena parte do armazenamento de dados dentro da CPU mais simples, com travas contendo saídas de um estágio como inputs para outro estágio, etc., etc.

Operações de memory de outras CPUs podem se tornar globalmente visíveis entre o carregamento e o armazenamento. Ou seja, dois threads em execução add dword [num], 1 em um loop entraria 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) em hardware x86 multi-core real.


“Atômico”, da palavra grega que significa indivisível, significa que nenhum observador pode ver a operação como etapas separadas. Acontecer física / 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 da ULA. Entrei em mais detalhes sobre cargas puras e armazenamentos puros na minha resposta ao Atomicity on x86 , enquanto essa resposta se concentra em read-modify-write.

O prefixo de lock pode ser aplicado a muitas instruções de leitura-modificação-gravação (destino da memory) para tornar toda a operação atômica em relação a todos os possíveis observadores 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 este Q & A ).

Então, lock add dword [num], 1 é atômico . Um núcleo de CPU que executa essa instrução manteria a linha de cache fixada no estado Modified em seu cache L1 privado de quando a carga lê dados do cache até que o armazenamento confirme seu resultado de volta no cache. Isso impede que qualquer outro cache no sistema tenha uma cópia da linha de cache em qualquer ponto, de load para store, de acordo com as regras do protocolo de coerência de cache MESI (ou as versões MOESI / MESIF dele usadas pelo AMD multi-core / CPUs Intel, respectivamente). Assim, as operações de outros núcleos parecem acontecer antes ou depois, não durante.

Sem o prefixo de lock , outro núcleo poderia apropriar-se da linha de cache e modificá-la depois de nossa carga, mas antes de nossa loja, para que outra loja se tornasse globalmente visível entre nossa carga e a loja. Várias outras respostas entendem isso errado e afirmam que, sem lock você obteria cópias conflitantes da mesma linha de cache. Isso nunca pode acontecer em um sistema com caches coerentes.

(Se uma instrução locked opera na memory que abrange duas linhas de cache, é preciso muito mais trabalho para garantir que as alterações em ambas as partes do object permaneçam atômicas à medida que elas se propagam para todos os observadores, de modo que nenhum observador possa ver tearing. pode ter que bloquear todo o barramento de memory até que os dados cheguem à memory. Não desalface suas variables ​​atômicas!)

Observe que o prefixo de lock também transforma uma instrução em uma barreira de memory completa (como MFENCE ), interrompendo todo o reordenamento de tempo de execução e, portanto, fornecendo consistência sequencial. (Veja o excelente post de Jeff Preshing . Seus outros posts também são excelentes, e explicam claramente muitas coisas boas sobre programação livre de bloqueio , do x86 e outros detalhes de hardware para as regras do C ++.)


Em uma máquina de uniprocessador, ou em um processo de encadeamento único, uma única instrução RMW é realmente atômica sem um prefixo de lock . A única maneira de outro código acessar a variável compartilhada é que a CPU faça uma alternância de contexto, o que não pode acontecer no meio de uma instrução. Portanto, uma dec dword [num] simples dec dword [num] pode sincronizar entre um programa single-threaded e seus manipuladores de sinais, ou em um programa multi-thread rodando em uma máquina single-core. Veja a segunda metade da minha resposta em outra pergunta , e os comentários sob ela, onde eu explico isso em mais detalhes.


Voltar para C ++:

É totalmente falso usar num++ sem dizer ao compilador que você precisa dele para compilar para uma única implementação read-modify-write:

 ;; Valid compiler output for num++ mov eax, [num] inc eax mov [num], eax 

Isto é muito provável se você usar o valor de num depois: o compilador irá mantê-lo em um registrador após o incremento. Portanto, mesmo que você verifique como o num++ compila sozinho, alterar o código circundante pode afetá-lo.

(Se o valor não for necessário mais tarde, o inc dword [num] é preferido; os processadores x86 modernos executarão uma instrução RMW de destino de memory pelo menos tão eficientemente quanto usando três instruções separadas. Fato gcc -O3 -m32 -mtune=i586 : gcc -O3 -m32 -mtune=i586 realmente emitirá isso , porque o pipeline superescalar (Pentium) P5 não decodificou instruções complexas para múltiplas microoperações simples como fazem as microarquiteturas P6 e posteriores. Consulte o guia de microarquitetura / tabelas de instruções do Agner Fog para obter mais informações e a tag x86 wiki para muitos links úteis (incluindo os manuais ISA x86 da Intel, que estão disponíveis gratuitamente como PDF)).


Não confunda o modelo de memory de destino (x86) com o modelo de memory C ++

O reordenamento em tempo de compilation é permitido . A outra parte do que você obtém com std :: atomic é o controle sobre a reordenação em tempo de compilation, para assegurar que seu num++ fique globalmente visível somente após alguma outra operação.

Exemplo clássico: Armazenando alguns dados em um buffer para outro encadeamento, depois definindo um sinalizador. Mesmo que o x86 adquira cargas / liberações de graça, você ainda precisa dizer ao compilador para não reordenar usando flag.store(1, std::memory_order_release); .

Você pode estar esperando que esse código seja sincronizado com outros threads:

 // flag is just a plain int global, not std::atomic. 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 está livre para mover o flag++ toda a chamada de function (se ele ingressa a function ou sabe que ela não olha para a flag ). Então pode otimizar a modificação completamente, porque a flag não é nem volatile . (E não, C ++ volatile não é um substituto útil para std :: atomic. Std :: atomic faz com que o compilador assuma que valores na memory podem ser modificados de forma assíncrona similar a volatile , mas há muito mais do que isso. volatile std::atomic foo não é o mesmo que std::atomic foo , como discutido com @Richard Hodges.)

Definir corridas de dados em variables ​​não atômicas como Comportamento Indefinido é o que permite que o compilador ainda carregue e afete armazenamentos de loops e muitas outras otimizações para memory às quais vários encadeamentos possam ter uma referência. (Veja este blog do LLVM para saber mais sobre como o UB permite otimizações do compilador.)


Como mencionei, o prefixo de lock x86 é uma barreira de memory completa, portanto, usando num.fetch_add(1, std::memory_order_relaxed); gera o mesmo código em x86 como num++ (o padrão é a consistência sequencial), mas pode ser muito mais eficiente em outras arquiteturas (como o ARM). Mesmo no x86, o relaxado permite um reordenamento mais compacto.

Isso é o que o GCC faz no x86, para algumas funções que operam em uma variável global std::atomic .

Veja o código de idioma da fonte + assembly formatado bem no explorador do compilador Godbolt . Você pode selecionar outras arquiteturas de destino, incluindo ARM, MIPS e PowerPC, para ver que tipo de código de linguagem assembly obtém de atomics para esses destinos.

 #include  std::atomic 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 MFENCE (uma barreira completa) é necessária após um armazenamento de consistência seqüencial. O x86 é fortemente ordenado em geral, mas o reordenamento do StoreLoad é permitido. Ter um buffer de armazenamento é essencial para um bom desempenho em uma CPU fora de ordem com pipeline. A Reordenação de Memória de Jeff Preshing, Caught in the Act, mostra as conseqüências de não usar MFENCE, com código real para mostrar a reordenação acontecendo em hardware real.


Re: discussão em comentários sobre a resposta de @Richard Hodges sobre compiladores que mesclam std :: atomic num++; num-=2; num++; num-=2; operações em um num--; instrução :

Um Q & A separado sobre este mesmo assunto: Por que os compiladores não mesclam gravações std :: atômicas redundantes? , onde minha resposta reafirma muito do que escrevi abaixo.

Os compiladores atuais na verdade não fazem isso (ainda), mas não porque não são permitidos. C ++ WG21 / P0062R1: Quando os compiladores devem otimizar os átomos? discute a expectativa de que muitos programadores tenham compiladores que não farão otimizações “surpreendentes” e o que o padrão pode fazer para dar controle aos programadores. N4455 discute muitos exemplos de coisas que podem ser otimizadas, incluindo esta. Ele aponta que inlining e constante-propagação podem introduzir coisas como fetch_or(0) que pode ser capaz de se transformar em apenas um load() (mas ainda assim adquirir e liberar a semântica), mesmo quando a fonte original não tem qualquer obviamente operações atômicas redundantes.

Os verdadeiros motivos pelos quais os compiladores não o fazem (ainda) 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 menor surpresa . Código livre de 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. Nem sempre é fácil evitar operações atômicas redundantes com std::shared_ptr , já que não existe uma versão não atômica (embora uma das respostas aqui forneça uma maneira fácil de definir um shared_ptr_unsynchronized para gcc ).


Voltando ao num++; num-=2; num++; num-=2; compilando como se fosse num-- : Compiladores estão autorizados a fazer isso, a menos que num seja volatile std::atomic . Se uma reordenação for possível, a regra como-se permite que o compilador decida em tempo de compilation que isso sempre acontece dessa maneira. Nada garante que um observador possa ver os valores intermediários (o resultado num++ ).

Ou seja, se a ordenação em que nada se torna globalmente visível entre essas operações for compatível com os requisitos de ordenação da fonte (de acordo com as regras C ++ da máquina abstrata, não da arquitetura de destino), o compilador pode emitir um único lock dec dword [num] em vez de lock inc dword [num] / lock sub dword [num], 2 .

num++; num-- num++; num-- não pode desaparecer, porque ele ainda tem um relacionamento Sincroniza com com outros segmentos que olham para num , e é tanto uma aquisição de carga e um armazenamento de lançamento que não permite a reordenação de outras operações neste segmento. Para x86, isso pode ser capaz de compilar para um MFENCE, em vez de um lock add dword [num], 0 (ou seja, num += 0 ).

Como discutido no PR0062 , uma fusão mais agressiva de ops atômicas não adjacentes no tempo de compilation pode ser ruim (por exemplo, um contador de progresso só é atualizado uma vez no final em vez de cada iteração), mas também pode ajudar no desempenho sem desvantagens atomic inc / dec of ref conta quando uma cópia de um shared_ptr é criada e destruída, se o compilador puder provar que outro object shared_ptr existe por toda a vida útil do temporário.)

Mesmo num++; num-- num++; num-- fusão poderia ferir a justiça de uma implementação de bloqueio quando um segmento desbloqueia e bloqueia novamente imediatamente. Se ele nunca for liberado no conjunto, mesmo mecanismos de arbitragem de hardware não darão a outro thread uma chance de pegar o bloqueio nesse ponto.


Com o gcc6.2 e o clang3.9 atuais, você ainda obtém operações de lock separadas, mesmo com memory_order_relaxed no caso mais obviamente otimizado. ( Explorador do compilador Godbolt, para que você possa ver se as versões mais recentes são diferentes).

 void multiple_ops_relaxed(std::atomic& 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&): 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 

… e agora vamos ativar otimizações:

 f(): rep ret 

OK, vamos dar uma chance:

 void f(int& num) { num = 0; num++; --num; num += 6; num -=5; --num; } 

resultado:

 f(int&): mov DWORD PTR [rdi], 0 ret 

outro thread de observação (mesmo ignorando atrasos de synchronization de cache) não tem oportunidade de observar as alterações individuais.

comparado a:

 #include  void f(std::atomic& num) { num = 0; num++; --num; num += 6; num -=5; --num; } 

onde o resultado é:

 f(std::atomic&): mov DWORD PTR [rdi], 0 mfence lock add DWORD PTR [rdi], 1 lock sub DWORD PTR [rdi], 1 lock add DWORD PTR [rdi], 6 lock sub DWORD PTR [rdi], 5 lock sub DWORD PTR [rdi], 1 ret 

Agora, cada modificação é:

  1. observável em outro segmento, e
  2. respeitoso de modificações semelhantes acontecendo em outros tópicos.

A atomicidade não é apenas no nível de instrução, envolve todo o pipeline do processador, através dos caches, para a memory e vice-versa.

Mais informações

Quanto ao efeito de otimizações de atualizações de std::atomic s.

O padrão c ++ tem a regra ‘como se’, pela qual é permitido ao compilador reordenar código, e até mesmo rewrite o código, desde que o resultado tenha exatamente os mesmos efeitos observáveis (incluindo efeitos colaterais) como se tivesse simplesmente executado seu código. código.

A regra do como se é conservadora, particularmente envolvendo átomos.

considerar:

 void incdec(int& num) { ++num; --num; } 

Como não há bloqueios de mutex, atomics ou quaisquer outras construções que influenciem o sequenciamento entre threads, eu diria que o compilador está livre para rewrite esta function como um NOP, por exemplo:

 void incdec(int&) { // nada } 

Isso ocorre porque, no modelo de memory c ++, não há possibilidade de outro encadeamento observar o resultado do incremento. Naturalmente, seria diferente se num fosse volatile (pode influenciar o comportamento do hardware). Mas neste caso, esta function será a única function que modifica esta memory (senão o programa é mal formado).

No entanto, este é um jogo diferente:

 void incdec(std::atomic& num) { ++num; --num; } 

num é atômico. Alterações a ele devem ser observáveis ​​para outros segmentos que estão assistindo. As alterações que esses próprios segmentos fazem (como definir o valor como 100 entre o incremento e o decremento) terão efeitos muito abrangentes no valor eventual de num.

Aqui está uma demonstração:

 #include  #include  int main() { for (int iter = 0 ; iter < 20 ; ++iter) { std::atomic num = { 0 }; std::thread t1([&] { for (int i = 0 ; i < 10000000 ; ++i) { ++num; --num; } }); std::thread t2([&] { for (int i = 0 ; i < 10000000 ; ++i) { num = 100; } }); t2.join(); t1.join(); std::cout << num << std::endl; } } 

exemplo de saída:

 99 99 99 99 99 100 99 99 100 100 100 100 99 99 100 99 99 100 100 99 

Sem muitas complicações, uma instrução como add DWORD PTR [rbp-4], 1 é muito estilo CISC.

Ele realiza três operações: carregar o operando da memory, incrementá-lo, armazenar o operando de volta na memory.
Durante essas operações, a CPU adquire e libera o barramento duas vezes, entre qualquer outro agente pode adquiri-lo também e isso viola a atomicidade.

 AGENT 1 AGENT 2 load X inc C load X inc C store X store X 

X é incrementado apenas uma vez.

A instrução add não é atômica. Ele faz referência à memory e dois núcleos de processador podem ter cache local diferente dessa memory.

IIRC a variante atômica da instrução add é chamada de bloqueio xadd

Como a linha 5, que corresponde a num ++, é uma instrução, podemos concluir que num ++ é atômico nesse caso?

É perigoso tirar conclusões com base na assembly gerada por “engenharia reversa”. Por exemplo, você parece ter compilado seu código com a otimização desativada, caso contrário, o compilador teria descartado essa variável ou carregado 1 diretamente para ela sem invocar o operator++ . Como o assembly gerado pode mudar significativamente, com base em sinalizadores de otimização, CPU de destino, etc., sua conclusão é baseada em sand.

Além disso, sua ideia de que uma instrução de assembly significa que uma operação é atômica também está errada. Este add não será atômico em sistemas multi-CPU, mesmo na arquitetura x86.

Mesmo se o seu compilador sempre emitisse isto como uma operação atômica, acessar num de qualquer outro segmento concorrentemente 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 accesss à memory em torno de ++num se num não for atômico, por exemplo

 int main() { std::unique_ptr> vec; int ready = 0; std::thread t{[&] { while (!ready); // use "vec" here }); vec.reset(new std::vector()); ++ready; t.join(); } 

Mesmo se assumirmos que o ++ready é “atômico”, e que o compilador gera o loop de verificação conforme necessário (como eu disse, é o UB e, portanto, o compilador está livre para removê-lo, substituí-lo por um loop infinito etc.) ), o compilador ainda pode mover a atribuição de ponteiro, ou pior ainda, a boot do vector para um ponto após a operação de incremento, causando o caos no novo encadeamento. Na prática, eu não ficaria surpreso se um compilador otimizador removesse a variável ready e o loop de verificação completamente, já que isso não afeta o comportamento observável sob regras de linguagem (em oposição às suas esperanças privadas).

De fato, na conferência Meeting C ++ do ano passado, ouvi de dois desenvolvedores de compiladores que eles implementam otimizações que tornam os programas multi-thread ingenuamente gravados mal comportados, contanto que as regras de linguagem o permitam, mesmo que uma pequena melhora no desempenho seja vista em programas escritos corretamente.

Por fim, mesmo que você não se importe com a portabilidade, e seu compilador tenha sido legalmente agradável, a CPU que você está usando é muito provável de um tipo CISC superescalar e irá quebrar as instruções em micro-operações, reordená-las e / ou executá-las especulativamente. até certo ponto limitado apenas pela synchronization de primitivos, como (na Intel) o prefixo LOCK ou cercas de memory, para maximizar as operações por segundo.

Para encurtar a história, as responsabilidades naturais da programação de thread-safe são:

  1. Seu dever é escrever código que tenha um comportamento bem definido sob regras de linguagem (e, em particular, o modelo de memory padrão da linguagem).
  2. O dever do seu compilador é gerar código de máquina que tenha o mesmo comportamento bem definido (observável) sob o modelo de memory da arquitetura de destino.
  3. O dever do seu processador é executar este código para que o comportamento observado seja compatível com o modelo de memory de sua própria arquitetura.

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 escrito corretamente:

 int main() { std::unique_ptr> vec; std::atomic ready{0}; // NOTE the use of the std::atomic template std::thread t{[&] { while (!ready); // use "vec" here }); vec.reset(new std::vector()); ++ready; t.join(); } 

Isso é seguro porque:

  1. As verificações ready não podem ser otimizadas de acordo com as regras de idioma.
  2. O ++ready acontece – antes da verificação que vê ready como não zero, e outras operações não podem ser reordenadas em torno dessas operações. Isso ocorre porque ++ready e a verificação são consistentes sequencialmente , que é outro termo descrito no modelo de memory C ++ e que proíbe essa reordenação específica. Portanto, o compilador não deve reordenar as instruções, e deve também dizer à CPU que não deve, por exemplo, adiar a gravação para vec após o incremento de ready . Seqüencialmente consistente é a garantia mais forte em relação aos átomos no padrão de linguagem. Garantias menores (e teoricamente mais baratas) estão disponíveis, por exemplo, através de outros methods de std::atomic , mas estes são definitivamente apenas para especialistas, e podem não ser muito otimizados pelos desenvolvedores do compilador, porque eles são raramente usados.

On a single-core x86 machine, an add instruction will generally be atomic with respect to other code on the CPU 1 . An interrupt can’t split a single instruction down the middle.

Out-of-order execution is required to preserve the illusion of instructions executing one at a time in order within a single core, so any instruction running on the same CPU will either happen completely before or completely after the add.

Modern x86 systems are multi-core, so the uniprocessor special case doesn’t apply.

If one is targeting a small embedded PC and has no plans to move the code to anything else, the atomic nature of the “add” instruction could be exploited. On the other hand, platforms where operations are inherently atomic are becoming more and more scarce.

(This doesn’t help you if you’re writing in C++, though. Compilers don’t have an option to require num++ to compile to a memory-destination add or xadd without a lock prefix. They could choose to load num into a register and store the increment result with a separate instruction, and will likely do that if you use the result.)


Footnote 1: The lock prefix existed even on original 8086 because I/O devices operate concurrently with the CPU; drivers on a single-core system need lock add to atomically increment a value in device memory if the device can also modify it, or with respect to DMA access.

Back in the day when x86 computers had one CPU, the use of a single instruction ensured that interrupts would not split the read/modify/write and if the memory would not be used as a DMA buffer too, it was atomic in fact (and C++ did not mention threads in the standard so this wasn’t addresses).

When it was rare to have a dual core (Pentium Pro) on a customer desktop, I effectively used this to avoid the LOCK prefix on a single core machine and improve performance.

Today, it would only help against multiple threads that were all set to the same CPU affinity, so the threads you are worried about would only come into play via time slice expiring and running the other thread on the same CPU (core). That is not realistic.

With modern x86/x64 processors, the single instruction is broken up into several micro ops and furthermore the memory reading and writing is buffered. So different threads running on different CPUs will not only see this as non-atomic but may see inconsistent results concerning what it reads from memory and what it assumes other threads have read to that point in time: you need to add memory fenses to restore sane behavior.

No. https://www.youtube.com/watch?v=31g0YE61PLQ (That’s just a link to the “No” scene from “The Office”)

Do you agree that this would be a possible output for the program:

sample output:

 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 100 

If so, then the compiler is free to make that the only possible output for the program, in whichever way the compiler wants. ie a main() that just puts out 100s.

This is the “as-if” rule.

And regardless of output, you can think of thread synchronization the same way – if thread A does num++; num--; and thread B reads num repeatedly, then a possible valid interleaving is that thread B never reads between num++ and num-- . Since that interleaving is valid, the compiler is free to make that the only possible interleaving. And just remove the incr/decr entirely.

There are some interesting implications here:

 while (working()) progress++; // atomic, global 

(ie imagine some other thread updates a progress bar UI based on progress )

Can the compiler turn this into:

 int local = 0; while (working()) local++; progress += local; 

probably that is valid. But probably not what the programmer was hoping for 🙁

The committee is still working on this stuff. Currently it “works” because compilers don’t optimize atomics much. But that is changing.

And even if progress was also volatile, this would still be valid:

 int local = 0; while (working()) local++; while (local--) progress++; 

: – /

Yes, but…

Atomic is not what you meant to say. You’re probably asking the wrong thing.

The increment is certainly atomic . Unless the storage is misaligned (and since you left alignment to the compiler, it is not), it is necessarily aligned within a single cache line. Short of special non-caching streaming instructions, each and every write goes through the cache. Complete cache lines are being atomically read and written, never anything different.
Smaller-than-cacheline data is, of course, also written atomically (since the surrounding cache line is).

Is it thread-safe?

This is a different question, and there are at least two good reasons to answer with a definite “No!” .

First, there is the possibility that another core might have a copy of that cache line in L1 (L2 and upwards is usually shared, but L1 is normally per-core!), and concurrently modifies that value. Of course that happens atomically, too, but now you have two “correct” (correctly, atomically, modified) values — which one is the truly correct one now?
The CPU will sort it out somehow, of course. But the result may not be what you expect.

Second, there is memory ordering, or worded differently happens-before guarantees. The most important thing about atomic instructions is not so much that they are atomic . It’s ordering.

You have the possibility of enforcing a guarantee that everything that happens memory-wise is realized in some guaranteed, well-defined order where you have a “happened before” guarantee. This ordering may be as “relaxed” (read as: none at all) or as strict as you need.

For example, you can set a pointer to some block of data (say, the results of some calculation) and then atomically release the “data is ready” flag. Now, whoever acquires this flag will be led into thinking that the pointer is valid. And indeed, it will always be a valid pointer, never anything different. That’s because the write to the pointer happened-before the atomic operation.

That a single compiler’s output, on a specific CPU architecture, with optimizations disabled (since gcc doesn’t even compile ++ to add when optimizing in a quick&dirty example ), seems to imply incrementing this way is atomic doesn’t mean this is standard-compliant (you would cause undefined behavior when trying to access num in a thread), and is wrong anyways, because add is not atomic in x86.

Note that atomics (using the lock instruction prefix) are relatively heavy on x86 ( see this relevant answer ), but still remarkably less than a mutex, which isn’t very appropriate in this use-case.

Following results are taken from clang++ 3.8 when compiling with -Os .

Incrementing an int by reference, the “regular” way :

 void inc(int& x) { ++x; } 

This compiles into :

 inc(int&): incl (%rdi) retq 

Incrementing an int passed by reference, the atomic way :

 #include  void inc(std::atomic& x) { ++x; } 

This example, which is not much more complex than the regular way, just gets the lock prefix added to the incl instruction – but caution, as previously stated this is not cheap. Just because assembly looks short doesn’t mean it’s fast.

 inc(std::atomic&): lock incl (%rdi) retq 

When your compiler uses only a single instruction for the increment and your machine is single-threaded, your code is safe. ^^

Try compiling the same code on a non-x86 machine, and you’ll quickly see very different assembly results.

The reason num++ appears to be atomic is because on x86 machines, incrementing a 32-bit integer is, in fact, atomic (assuming no memory retrieval takes place). But this is neither guaranteed by the c++ standard, nor is it likely to be the case on a machine that doesn’t use the x86 instruction set. So this code is not cross-platform safe from race conditions.

You also don’t have a strong guarantee that this code is safe from Race Conditions even on an x86 architecture, because x86 doesn’t set up loads and stores to memory unless specifically instructed to do so. So if multiple threads tried to update this variable simultaneously, they may end up incrementing cached (outdated) values

The reason, then, that we have std::atomic and so on is so that when you’re working with an architecture where the atomicity of basic computations is not guaranteed, you have a mechanism that will force the compiler to generate atomic code.