Se houver dois threads acessando uma variável global, muitos tutoriais dizem tornar a variável volátil para impedir que o compilador armazene em cache a variável em um registro e, portanto, não seja atualizado corretamente. No entanto, dois threads que acessam uma variável compartilhada são algo que exige proteção através de um mutex, não é? Mas nesse caso, entre o encadeamento e liberação do mutex, o código está em uma seção crítica, onde apenas um encadeamento pode acessar a variável. Nesse caso, a variável não precisa ser volátil?
Portanto, qual é o uso / objetivo do volátil em um programa multiencadeado?
c++
multithreading
concurrency
atomic
volatile
David Preston
fonte
fonte
Respostas:
Resposta curta e rápida :
volatile
é (quase) inútil para programação de aplicativos multithread, independente de plataforma. Não fornece nenhuma sincronização, não cria cercas de memória, nem garante a ordem de execução das operações. Não torna as operações atômicas. Não torna seu código magicamente seguro para threads.volatile
pode ser a instalação mais incompreendida em todo o C ++. Veja isto , isto e isto para obter mais informações sobrevolatile
Por outro lado,
volatile
tem algum uso que pode não ser tão óbvio. Ele pode ser usado da mesma maneira que seria usadoconst
para ajudar o compilador a mostrar onde você pode estar cometendo um erro ao acessar algum recurso compartilhado de maneira não protegida. Esse uso é discutido por Alexandrescu neste artigo . No entanto, isso é basicamente usando o sistema do tipo C ++ de uma maneira que muitas vezes é vista como um artifício e pode evocar o comportamento indefinido.volatile
foi projetado especificamente para ser usado na interface com hardware mapeado na memória, manipuladores de sinal e instruções de código de máquina setjmp. Isso tornavolatile
diretamente aplicável à programação no nível de sistemas, em vez da programação normal no nível de aplicativos.O padrão C ++ de 2003 não diz que
volatile
se aplica qualquer tipo de semântica de aquisição ou liberação em variáveis. De fato, o Padrão é completamente silencioso em todos os assuntos de multithreading. No entanto, plataformas específicas aplicam a semântica Adquirir e Liberar emvolatile
variáveis.[Atualização para C ++ 11]
O C ++ 11 Padrão agora faz Reconhecer multithreading diretamente no modelo de memória ea lanuage, e fornece serviços de biblioteca para lidar com isso de uma maneira de plataforma independente. No entanto, a semântica de
volatile
ainda não mudou.volatile
ainda não é um mecanismo de sincronização. Bjarne Stroustrup diz o mesmo no TCPPPL4E:[/ Fim da atualização]
O acima exposto aplica-se à própria linguagem C ++, conforme definido pelo Padrão de 2003 (e agora o Padrão de 2011). Algumas plataformas específicas, no entanto, adicionam funcionalidade ou restrições adicionais ao que
volatile
faz. Por exemplo, em MSVC 2010 (pelo menos) Adquirir e liberar semântica não se aplicam a determinadas operaçõesvolatile
variáveis. Do MSDN :No entanto, você pode observar o fato de que, se você seguir o link acima, há algum debate nos comentários sobre se a aquisição / liberação de semântica realmente se aplica neste caso.
fonte
volatile
ele, é porque ficou sobre os ombros de pessoas que costumavamvolatile
implementar bibliotecas de threading.volatile
realmente faz em C ++. O que @John disse está correto , fim da história. Não tem nada a ver com código de aplicativo x código de biblioteca ou "programadores oniscientes" comuns "versus" divinos "".volatile
é desnecessário e inútil para sincronização entre threads. As bibliotecas de encadeamento não podem ser implementadas em termos devolatile
; de qualquer maneira, ele precisa confiar nos detalhes específicos da plataforma e, quando você depende deles, não precisa maisvolatile
.(Nota do editor: em C ++ 11
volatile
não é a ferramenta certa para este trabalho e ainda possui UB de corrida de dados. Usestd::atomic<bool>
comstd::memory_order_relaxed
cargas / armazenamentos para fazer isso sem UB. Em implementações reais, ele será compilado da mesma formavolatile
que adicionei. uma resposta com mais detalhes e também abordando os conceitos errôneos nos comentários de que a memória com ordem fraca pode ser um problema para este caso de uso: todas as CPUs do mundo real têm memória compartilhada coerente, portantovolatile
, trabalhará com isso em implementações reais em C ++. faça isso.Alguma discussão nos comentários parece estar falando sobre outros casos de uso onde você iria precisar de algo mais forte do que atomics relaxado. Essa resposta já aponta que
volatile
você não solicita.)O volátil é ocasionalmente útil pelo seguinte motivo: este código:
é otimizado pelo gcc para:
O que obviamente está incorreto se o sinalizador for gravado no outro thread. Observe que, sem essa otimização, o mecanismo de sincronização provavelmente funciona (dependendo do outro código, algumas barreiras de memória podem ser necessárias) - não há necessidade de um mutex em 1 produtor - 1 cenário de consumidor.
Caso contrário, a palavra-chave volátil é muito estranha para ser utilizável - ela não fornece nenhuma garantia de pedidos de memória por acessos voláteis e não voláteis e não fornece operações atômicas - ou seja, você não recebe ajuda do compilador com palavras-chave voláteis, exceto o cache de registro desabilitado .
fonte
volatile
não impede que os acessos à memória sejam reordenados.volatile
acessos não será reordenada com respeito a cada um dos outros, mas eles fornecem nenhuma garantia sobre a reordenação em relação ao não-volatile
objetos, e assim, eles são basicamente inútil como bandeiras também.volatile
.while (work_left) { do_piece_of_work(); if (cancel) break;}
, se o cancelamento é reordenado dentro do loop, a lógica ainda é válido Eu tinha um pedaço de código que trabalhou semelhante: se a thread principal quer terminar, ele define o sinalizador para outros tópicos, mas isso não acontece ....No C ++ 11, normalmente nunca use
volatile
para segmentação, apenas para o MMIOMas 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 ++ 11std::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
(withstd::memory_order_relaxed
) para fazer com que um compilador emita o mesmo código de máquina eficiente que você poderia usarvolatile
.std::atomic
commo_relaxed
obsoletosvolatile
para fins de segmentação. (exceto talvez para solucionar erros de otimização perdidaatomic<double>
em alguns compiladores .)A implementação interna dos
std::atomic
compiladores convencionais (como gcc e clang) não é usada apenasvolatile
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 umexit_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 comovolatile
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_relaxedvolatile
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 quevolatile
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.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).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 aquibool
porque é um problema nas implementações normais. Mas isso também é parte do motivo pelo qualvolatile
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 oexit_now=true
armazenamento volátil seja visível globalmente antes de continuar com as operações posteriores neste encadeamento. (atomic<bool>
com o padrãomo_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 recursoatomic<T>
é praticamente o mesmo que ovolatile
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ê obteriawhile(!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 usovolatile 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>
emmo_relaxed
vez disso !! O objetivo desta seção é abordar conceitos errôneos sobre como as CPUs reais funcionam, não para justificarvolatile
. 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
ouwhile(!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:
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
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 dedmb 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, useatomic<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 pertomo_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 issovolatile
erelaxed
são quase tão fraco comomo_relaxed
permite.)fonte
atomic
poderia levar a threads diferentes com valores diferentes para a mesma variável no cache . / facepalm. No cache, não, no CPU registra sim (com variáveis não atômicas); As CPUs usam cache coerente. Eu gostaria que outras perguntas sobre o SO não estivessem cheias de explicações paraatomic
esses conceitos errôneos sobre como as CPUs funcionam. (Porque isso é uma coisa útil para entender por motivos de desempenho, e também ajuda a explicar por que as regras atômicas do ISO C ++ são escritos como eles são.)Certa vez, um entrevistador que também acreditava que a volatilidade é inútil argumentou comigo que a otimização não causaria problemas e estava se referindo a diferentes núcleos com linhas de cache separadas e tudo isso (realmente não entendia exatamente o que ele estava se referindo). Mas esse pedaço de código, quando compilado com -O3 em g ++ (g ++ -O3 thread.cpp -lpthread), mostra um comportamento indefinido. Basicamente, se o valor for definido antes da verificação do tempo, ele funcionará bem e, caso contrário, ele entrará em um loop sem se preocupar em buscar o valor (que na verdade foi alterado pelo outro encadeamento). Basicamente, acredito que o valor de checkValue só é buscado uma vez no registro e nunca é verificado novamente sob o mais alto nível de otimização. Se definido como true antes da busca, funciona bem e, se não, entra em loop. Por favor, corrija-me se estiver errado.
fonte
volatile
? Sim, esse código é UB - mas também é UBvolatile
.Você precisa de volátil e possivelmente bloqueio.
volátil informa ao otimizador que o valor pode mudar de forma assíncrona,
lerá sinalizador toda vez em volta do loop.
Se você desativar a otimização ou tornar todas as variáveis voláteis, um programa se comportará da mesma maneira, mas mais lentamente. volátil significa apenas 'Eu sei que você pode ter acabado de ler e saber o que diz, mas se eu disser, leia, então leia.
O bloqueio faz parte do programa. Portanto, a propósito, se você estiver implementando semáforos, entre outras coisas, eles deverão ser voláteis. (Não tente, é difícil, provavelmente precisará de um pequeno montador ou do novo material atômico, e isso já foi feito.)
fonte
volatile
não é realmente útil, mesmo neste caso. Mas a espera ocupada é uma técnica ocasionalmente útil.volatile
significa "não reordenar". Você espera que isso signifique que as lojas se tornem visíveis globalmente (para outros segmentos) na ordem do programa. Isso é o queatomic<T>
commemory_order_release
ouseq_cst
dá-lhe. Masvolatile
apenas oferece a garantia de que não haja reordenação em tempo de compilação : cada acesso aparecerá no asm na ordem do programa. Útil para um driver de dispositivo. E útil para interação com um manipulador de interrupção, depurador ou manipulador de sinal no núcleo / encadeamento atual, mas não para interagir com outros núcleos.volatile
na prática, é suficiente para verificar umkeep_running
sinalizador como você está fazendo aqui: CPUs reais sempre têm caches coerentes que não requerem liberação manual. Mas não há nenhuma razão para recomendarvolatile
maisatomic<T>
commo_relaxed
; você terá o mesmo asm.