No C ++ 11, normalmente nunca use volatilepara segmentação, apenas para o MMIO
Mas TL: DR, ele "funciona" como atômico mo_relaxedno hardware com caches coerentes (ou seja, tudo); é suficiente parar os compiladores que mantêm vars nos registros. atomicnão precisa de barreiras de memória para criar atomicidade ou visibilidade entre encadeamentos, apenas para fazer com que o encadeamento atual aguarde antes / depois de uma operação para criar pedidos entre os acessos desse encadeamento a diferentes variáveis. mo_relaxednunca precisa de barreiras, basta carregar, armazenar ou RMW.
Para atômicos autônomos com volatile(e inline-asm para barreiras) nos velhos tempos anteriores ao C ++ 11 std::atomic, volatileera a única maneira boa de fazer algumas coisas funcionarem . Mas isso dependia de muitas suposições sobre como as implementações funcionavam e nunca foi garantido por nenhum padrão.
Por exemplo, o kernel do Linux ainda usa seus próprios átomos enrolados manualmente volatile, mas suporta apenas algumas implementações específicas de C (GNU C, clang e talvez ICC). Em parte, isso se deve às extensões do GNU C e à sintaxe e semântica inline do asm, mas também porque depende de algumas suposições sobre como os compiladores funcionam.
É quase sempre a escolha errada para novos projetos; você pode usar std::atomic(with std::memory_order_relaxed) para fazer com que um compilador emita o mesmo código de máquina eficiente que você poderia usar volatile. std::atomiccom mo_relaxedobsoletos volatilepara fins de segmentação. (exceto talvez para solucionar erros de otimização perdida atomic<double>em alguns compiladores .)
A implementação interna dos std::atomiccompiladores convencionais (como gcc e clang) não é usada apenas volatileinternamente; compiladores expõem diretamente funções atômicas de carga, armazenamento e RMW. (por exemplo, embutidos no GNU C__atomic que operam em objetos "simples".)
Volátil é utilizável na prática (mas não faça isso)
Dito isto, volatileé útil na prática para coisas como um exit_nowsinalizador em todas as implementações C ++ existentes em CPUs reais, por causa de como as CPUs funcionam (caches coerentes) e por suposições compartilhadas sobre como volatiledeve funcionar. Mas não muito mais, e não é recomendado. O objetivo desta resposta é explicar como as implementações existentes de CPUs e C ++ realmente funcionam. Se você não se importa com isso, tudo o que precisa saber é que, std::atomiccom os obsoletos mo_relaxed volatilepara segmentação.
(O padrão ISO C ++ é bastante vago, apenas dizendo que os volatileacessos devem ser avaliados estritamente de acordo com as regras da máquina abstrata C ++, não otimizada. Dado que implementações reais usam o espaço de endereço da memória da máquina para modelar o espaço de endereço C ++, isso significa que volatileleituras e atribuições precisam ser compiladas para carregar / armazenar instruções para acessar a representação de objeto na memória.)
Como outra resposta aponta, um exit_nowsinalizador é um caso simples de comunicação entre threads que não precisa de sincronização : não está publicando se o conteúdo da matriz está pronto ou algo parecido. Apenas uma loja que é notada imediatamente por uma carga ausente não otimizada em outro encadeamento.
// global
bool exit_now = false;
// in one thread
while (!exit_now) { do_stuff; }
// in another thread, or signal handler in this thread
exit_now = true;
Sem volátil ou atômica, a regra como se e a suposição de que nenhum UB de corrida de dados permite que um compilador o otimize para asm que verifica apenas o sinalizador uma vez , antes de inserir (ou não) um loop infinito. É exatamente o que acontece na vida real para compiladores reais. (E geralmente otimiza muito do_stuffporque o loop nunca sai, portanto, qualquer código posterior que possa ter usado o resultado não é alcançável se inserirmos o loop).
// Optimizing compilers transform the loop into asm like this
if (!exit_now) { // check once before entering loop
while(1) do_stuff; // infinite loop
}
O programa multithreading travou no modo otimizado, mas é executado normalmente em -O0 é um exemplo (com descrição da saída asm do GCC) de como exatamente isso acontece com o GCC no x86-64. Também programação MCU - a otimização do C ++ O2 é interrompida enquanto o loop na eletrônica.SE mostra outro exemplo.
Normalmente, queremos otimizações agressivas que o CSE e içam cargas fora dos loops, inclusive para variáveis globais.
Antes do C ++ 11, volatile bool exit_nowhavia uma maneira de fazer isso funcionar conforme o esperado (em implementações normais do C ++). Mas no C ++ 11, o UB de corrida de dados ainda se aplica, volatileportanto, não é realmente garantido pelo padrão ISO que funcione em qualquer lugar, mesmo assumindo caches coerentes de HW.
Observe que, para tipos mais amplos, volatilenão oferece garantia de falta de rasgo. Eu ignorei essa distinção aqui boolporque é um problema nas implementações normais. Mas isso também é parte do motivo pelo qual volatileainda está sujeito à UB de corrida de dados, em vez de ser equivalente a um atômico relaxado.
Observe que "conforme pretendido" não significa que o thread exit_nowaguarda o outro thread sair. Ou mesmo que aguarde até que o exit_now=truearmazenamento volátil seja visível globalmente antes de continuar com as operações posteriores neste encadeamento. ( atomic<bool>com o padrão mo_seq_cst, esperaria antes que qualquer seq_cst mais tarde fosse carregado. Em muitos ISAs, você obteria uma barreira completa após a loja).
O C ++ 11 fornece uma maneira não UB que compila o mesmo
Um sinalizador "continue executando" ou "sair agora" deve ser usado std::atomic<bool> flagcommo_relaxed
Usando
flag.store(true, std::memory_order_relaxed)
while( !flag.load(std::memory_order_relaxed) ) { ... }
fornecerá exatamente o mesmo ASM (sem instruções de barreira caras) que você obteria volatile flag.
Além de não rasgar, atomictambém oferece a capacidade de armazenar em um thread e carregar em outro sem UB, para que o compilador não possa elevar a carga de um loop. (A suposição de que não há UB de corrida de dados é o que permite as otimizações agressivas que desejamos para objetos não-atômicos e não-voláteis.) Esse recurso atomic<T>é praticamente o mesmo que o volatilefaz para cargas e armazenamentos puros.
atomic<T>também faça +=e assim por diante em operações atômicas de RMW (significativamente mais caras que uma carga atômica em um temporário, opere e, em seguida, em um armazenamento atômico separado. Se você não quiser um RMW atômico, escreva seu código com um temporário local).
Com o seq_cstpedido padrão que você obteria while(!flag), ele também adiciona garantias de pedido. acessos não atômicos e para outros acessos atômicos.
(Em teoria, o padrão ISO C ++ não descarta a otimização de átomos em tempo de compilação. Mas, na prática, os compiladores não, porque não há como controlar quando isso não seria bom. Existem alguns casos em volatile atomic<T>que nem ser suficiente controle sobre otimização de atomics se compiladores fez otimizar, então por enquanto compiladores não. Veja por que não fazer compiladores fundir std :: redundante gravações atômicas? Note que wg21 / p0062 recomenda contra o uso volatile atomicde código atual para se proteger contra a otimização de atômica.)
volatile realmente funciona para isso em CPUs reais (mas ainda não o usa)
mesmo com modelos de memória com ordem fraca (não x86) . Mas na verdade não use, use atomic<T>em mo_relaxedvez disso !! O objetivo desta seção é abordar conceitos errôneos sobre como as CPUs reais funcionam, não para justificar volatile. Se você estiver escrevendo código sem bloqueio, provavelmente se preocupa com o desempenho. Compreender caches e os custos da comunicação entre threads geralmente é importante para um bom desempenho.
CPUs reais têm caches coerentes / memória compartilhada: depois que uma loja de um núcleo se torna globalmente visível, nenhum outro núcleo pode carregar um valor obsoleto. (Veja também Myths Programmers Believe on CPU Caches, que fala um pouco sobre os voláteis Java, equivalentes ao C ++ atomic<T>com ordem de memória seq_cst.)
Quando digo carregar , quero dizer uma instrução asm que acessa a memória. É isso que um volatileacesso garante e não é a mesma coisa que a conversão de valor em valor de uma variável C ++ não atômica / não volátil. (por exemplo, local_tmp = flagou while(!flag)).
A única coisa que você precisa derrotar são as otimizações em tempo de compilação que não são recarregadas após a primeira verificação. Qualquer carga + verificação em cada iteração é suficiente, sem qualquer pedido. Sem sincronização entre esse encadeamento e o encadeamento principal, não faz sentido falar sobre quando exatamente ocorreu a loja ou a ordem do carregamento da carga. outras operações no loop. Somente quando é visível para esta discussão é o que importa. Quando você vê o sinalizador exit_now definido, você sai. A latência entre núcleos em um xeon x86 típico pode ser algo como 40ns entre núcleos físicos separados .
Em teoria: threads C ++ em hardware sem caches coerentes
Não vejo como isso possa ser remotamente eficiente, com apenas ISO C ++ puro, sem exigir que o programador faça alterações explícitas no código-fonte.
Em teoria, você poderia ter uma implementação C ++ em uma máquina que não fosse assim, exigindo liberações explícitas geradas pelo compilador para tornar as coisas visíveis para outros threads em outros núcleos . (Ou para leituras para não usar uma cópia talvez obsoleta). O padrão C ++ não torna isso impossível, mas o modelo de memória do C ++ é projetado para ser eficiente em máquinas coerentes de memória compartilhada. Por exemplo, o padrão C ++ fala sobre "coerência de leitura e leitura", "coerência de gravação e leitura", etc. Uma observação no padrão aponta até a conexão com o hardware:
http://eel.is/c++draft/intro.races#19
[Nota: Os quatro requisitos de coerência precedentes proíbem efetivamente a reordenação do compilador de operações atômicas em um único objeto, mesmo se as duas operações forem cargas relaxadas. Isso efetivamente torna a garantia de coerência do cache fornecida pela maioria dos hardwares disponíveis para operações atômicas em C ++. - nota final]
Não existe um mecanismo para uma releaseloja apenas liberar a si própria e alguns intervalos de endereços selecionados: ela precisaria sincronizar tudo, porque não saberia o que os outros encadeamentos poderiam querer ler se sua carga de aquisição visse essa loja de lançamento (formando um Seqüência de liberação que estabelece uma relação de antes do acontecimento entre os segmentos, garantindo que as operações não atômicas anteriores realizadas pelo segmento de gravação sejam seguras de ler. A menos que ele tenha gravado mais após o repositório de lançamentos ...) ser realmente esperto ao provar que apenas algumas linhas de cache precisavam ser liberadas.
Relacionado: minha resposta em Mov + mfence é seguro no NUMA? entra em detalhes sobre a inexistência de sistemas x86 sem memória compartilhada coerente. Também relacionado: Carrega e armazena a reordenação no ARM para saber mais sobre cargas / lojas no mesmo local.
Não são eu acho clusters com memória não-coerente compartilhada, mas eles não são máquinas de sistema de imagem única. Cada domínio de coerência executa um kernel separado, portanto você não pode executar threads de um único programa C ++ nele. Em vez disso, você executa instâncias separadas do programa (cada uma com seu próprio espaço de endereço: ponteiros em uma instância não são válidos na outra).
Para que eles se comuniquem entre si por liberações explícitas, você normalmente usaria o MPI ou outra API de passagem de mensagens para fazer o programa especificar quais intervalos de endereços precisam ser liberados.
O hardware real não é executado std::threadatravés dos limites de coerência do cache:
Existem alguns chips ARM assimétricos, com espaço de endereço físico compartilhado, mas não domínios de cache compartilhável interno. Portanto, não coerente. (por exemplo, fio comentário um núcleo A8 e um Cortex-M3 como TI Sitara AM335x).
Mas kernels diferentes rodariam nesses núcleos, não uma única imagem do sistema que pudesse executar threads nos dois núcleos. Não conheço nenhuma implementação de C ++ que execute std::threadthreads nos núcleos da CPU sem caches coerentes.
Para o ARM especificamente, o GCC e o clang geram código, assumindo que todos os threads sejam executados no mesmo domínio compartilhável interno. De fato, o manual do ARMv7 ISA diz
Essa arquitetura (ARMv7) é escrita com a expectativa de que todos os processadores que usam o mesmo sistema operacional ou hipervisor estejam no mesmo domínio de compartilhabilidade interna
Portanto, a memória compartilhada não coerente entre domínios separados é apenas uma coisa para o uso explícito específico do sistema de regiões de memória compartilhada para comunicação entre diferentes processos em diferentes kernels.
Consulte também esta discussão do CoreCLR sobre a geração de código usando dmb ishbarreiras de dmb symemória ( barreira compartilhável interna) vs. (sistema) nesse compilador.
Faço a afirmação de que nenhuma implementação C ++ para outro ISA é executado std::threadem núcleos com caches não coerentes. Não tenho provas de que não exista essa implementação, mas parece altamente improvável. A menos que você esteja direcionando uma parte exótica específica de HW que funcione dessa maneira, seu pensamento sobre desempenho deve assumir coerência de cache semelhante a MESI entre todos os threads. (De preferência, use atomic<T>de maneira a garantir a correção!)
Caches coerentes simplificam
Porém, em um sistema com vários núcleos com caches coerentes, implementar um armazenamento de versão significa apenas encomendar commit no cache para os armazenamentos desse encadeamento, sem fazer nenhuma liberação explícita. ( https://preshing.com/20120913/acquire-and-release-semantics/ e https://preshing.com/20120710/memory-barriers-are-like-source-control-operations/ ). (E uma carga de aquisição significa solicitar acesso ao cache no outro núcleo).
Uma instrução de barreira de memória apenas bloqueia as cargas e / ou armazena o encadeamento atual até que o buffer de armazenamento seja drenado; isso sempre acontece o mais rápido possível por conta própria. ( Uma barreira de memória garante que a coerência do cache foi concluída? Aborda esse equívoco). Portanto, se você não precisar fazer pedidos, basta avisar a visibilidade em outros threads mo_relaxed. (E assim é volatile, mas não faça isso.)
Consulte também mapeamentos C / C ++ 11 para processadores
Curiosidade: no x86, toda loja asm é uma loja de lançamento, porque o modelo de memória x86 é basicamente seq-cst mais um buffer de loja (com encaminhamento de loja).
Buffer semi-relacionado re: store, visibilidade global e coerência: o C ++ 11 garante muito pouco. A maioria dos ISAs reais (exceto o PowerPC) garante que todos os threads possam concordar com a ordem de aparência de dois armazenamentos por outros dois threads. (Na terminologia formal do modelo de memória da arquitetura do computador, eles são "atômica com várias cópias").
Outro equívoco é que são necessárias instruções de cerca de memória ASM para liberar o buffer de loja para outros núcleos para ver nossas lojas em tudo . Na verdade, o buffer de armazenamento está sempre tentando se drenar (comprometer-se com o cache L1d) o mais rápido possível, caso contrário, seria preenchido e paralisado a execução. O que uma barreira / barreira completa faz é interromper o encadeamento atual até que o buffer da loja seja drenado , para que nossas cargas posteriores apareçam na ordem global após as lojas anteriores.
(x86 está fortemente ordenou meios modelo de memória asm que volatileem x86 pode acabar dando-lhe mais perto mo_acq_rel, só que em tempo de compilação reordenação com variáveis não-atômicas ainda pode acontecer. Mas a maioria dos não-x86 têm modelos de memória fraca ordenada por isso volatilee relaxedsão quase tão fraco como mo_relaxedpermite.)