No C ++ 11, normalmente nunca use volatile
para segmentação, apenas para o MMIO
Mas TL: DR, ele "funciona" como atômico mo_relaxed
no hardware com caches coerentes (ou seja, tudo); é suficiente parar os compiladores que mantêm vars nos registros. atomic
nã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_relaxed
nunca 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
, volatile
era 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::atomic
com mo_relaxed
obsoletos volatile
para fins de segmentação. (exceto talvez para solucionar erros de otimização perdida atomic<double>
em alguns compiladores .)
A implementação interna dos std::atomic
compiladores convencionais (como gcc e clang) não é usada apenas volatile
internamente; 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_now
sinalizador 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 volatile
deve 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::atomic
com os obsoletos mo_relaxed volatile
para segmentação.
(O padrão ISO C ++ é bastante vago, apenas dizendo que os volatile
acessos 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 volatile
leituras 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_now
sinalizador é 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_stuff
porque 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_now
havia 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, volatile
portanto, 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, volatile
não oferece garantia de falta de rasgo. Eu ignorei essa distinção aqui bool
porque é um problema nas implementações normais. Mas isso também é parte do motivo pelo qual volatile
ainda 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_now
aguarda o outro thread sair. Ou mesmo que aguarde até que o exit_now=true
armazenamento 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> flag
commo_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, atomic
també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 volatile
faz 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_cst
pedido 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 atomic
de 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_relaxed
vez 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 volatile
acesso 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 = flag
ou 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 release
loja 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::thread
atravé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::thread
threads 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 ish
barreiras de dmb sy
memó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::thread
em 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 volatile
em 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 volatile
e relaxed
são quase tão fraco como mo_relaxed
permite.)