A palavra-chave volatile do C ++ introduz um limite de memória?

86

Eu entendo que volatileinforma ao compilador que o valor pode ser alterado, mas para realizar essa funcionalidade, o compilador precisa introduzir um limite de memória para fazê-lo funcionar?

Do meu entendimento, a sequência de operações em objetos voláteis não pode ser reordenada e deve ser preservada. Isso parece implicar que algumas barreiras de memória são necessárias e que não há realmente uma maneira de contornar isso. Estou correto em dizer isso?


Há uma discussão interessante nesta questão relacionada

Jonathan Wakely escreve :

... Acessos a variáveis ​​voláteis distintas não podem ser reordenados pelo compilador, desde que ocorram em expressões completas separadas ... certo que volátil é inútil para segurança de thread, mas não pelas razões que ele deu. Não é porque o compilador pode reordenar os acessos a objetos voláteis, mas porque a CPU pode reordená-los. Operações atômicas e barreiras de memória evitam que o compilador e a CPU reordenem

Ao que David Schwartz responde nos comentários :

... Não há diferença, do ponto de vista do padrão C ++, entre o compilador fazer algo e o compilador emitir instruções que fazem com que o hardware faça algo. Se a CPU pode reordenar os acessos aos voláteis, o padrão não exige que sua ordem seja preservada. ...

... O padrão C ++ não faz nenhuma distinção sobre o que faz o reordenamento. E você não pode argumentar que a CPU pode reordená-los sem nenhum efeito observável, então tudo bem - o padrão C ++ define sua ordem como observável. Um compilador é compatível com o padrão C ++ em uma plataforma se ele gerar código que faça a plataforma fazer o que o padrão exige. Se o padrão requer que os acessos aos voláteis não sejam reordenados, então a plataforma que os reordena não é compatível. ...

Meu ponto é que se o padrão C ++ proíbe o compilador de reordenar acessos a voláteis distintos, na teoria de que a ordem de tais acessos faz parte do comportamento observável do programa, então também requer que o compilador emita código que proíbe a CPU de fazer então. O padrão não diferencia entre o que o compilador faz e o que o código gerado pelo compilador faz com que a CPU faça.

O que resulta em duas perguntas: algum deles está "certo"? O que as implementações reais realmente fazem?

Nathan Doromal
fonte
9
Significa principalmente que o compilador não deve manter essa variável em um registro. Cada atribuição e leitura no código-fonte deve corresponder a acessos à memória no código binário.
Basile Starynkevitch,
1
Suspeito que o ponto é que qualquer limite de memória seria ineficaz se o valor fosse armazenado em um registro interno. Acho que você ainda precisa tomar outras medidas de proteção em uma situação simultânea.
Galik
Pelo que eu sei, volátil é usado para variáveis ​​que podem ser alteradas por hardware (geralmente usado com microcontroladores). Significa simplesmente que a leitura da variável não pode ser feita em uma ordem diferente e não pode ser otimizada. Porém, isso é C, mas deve ser o mesmo em ++.
Mast
1
@Mast Ainda estou para ver um compilador que evita que as leituras das volatilevariáveis ​​sejam otimizadas pelos caches da CPU. Ou todos esses compiladores não estão em conformidade ou o padrão não significa o que você pensa que significa. (O padrão não distingue entre o que o compilador faz e o que o compilador faz a CPU fazer. O trabalho do compilador é emitir código que, quando executado, está em conformidade com o padrão.)
David Schwartz

Respostas:

58

Em vez de explicar o que volatilesignifica, permita-me explicar quando você deve usar volatile.

  • Quando dentro de um manipulador de sinal. Porque escrever em uma volatilevariável é praticamente a única coisa que o padrão permite que você faça de dentro de um manipulador de sinal. Desde C ++ 11 você pode usar std::atomicpara esse propósito, mas apenas se o atômico não tiver bloqueio.
  • Ao lidar com de setjmp acordo com a Intel .
  • Ao lidar diretamente com hardware e você deseja garantir que o compilador não otimize suas leituras ou gravações.

Por exemplo:

volatile int *foo = some_memory_mapped_device;
while (*foo)
    ; // wait until *foo turns false

Sem o volatileespecificador, o compilador pode otimizar completamente o loop. O volatileespecificador diz ao compilador que ele não pode presumir que 2 leituras subsequentes retornem o mesmo valor.

Observe que volatilenão tem nada a ver com threads. O exemplo acima não funcionará se houver um thread diferente gravando *fooporque não há operação de aquisição envolvida.

Em todos os outros casos, o uso de volatiledeve ser considerado não portátil e não mais passar na revisão de código, exceto ao lidar com compiladores pré-C ++ 11 e extensões de compilador (como a /volatile:mschave msvc , que é habilitada por padrão no X86 / I64).

Stefan
fonte
5
É mais estrito do que "não pode assumir que 2 leituras subsequentes retornem o mesmo valor". Mesmo que você leia apenas uma vez e / ou descarte o (s) valor (es), a leitura precisa ser feita.
philipxy
1
O uso em manipuladores de sinais e setjmpsão as duas garantias que o padrão faz. Por outro lado, a intenção , pelo menos no início, era oferecer suporte a IO mapeado em memória. O que em alguns processadores pode exigir uma cerca ou um membar.
James Kanze
@philipxy Exceto que ninguém sabe o que "ler" significa. Por exemplo, ninguém acredita que uma leitura real da memória deva ser feita - nenhum compilador que conheço tenta contornar os caches da CPU nos volatileacessos.
David Schwartz
@JamesKanze: Não é bem assim. Para manipuladores de sinal, o padrão diz que durante o tratamento de sinal apenas std :: sig_atomic_t e objetos atômicos sem bloqueio têm valores definidos. Mas também diz que os acessos a objetos voláteis são efeitos colaterais observáveis.
philipxy
1
@DavidSchwartz Alguns pares de arquitetura de compilador mapeiam a sequência de acessos especificada pelo padrão para efeitos reais e programas de trabalho acessam voláteis para obter esses efeitos. O fato de que alguns desses pares não têm mapeamento ou um mapeamento trivial e inútil é relevante para a qualidade das implementações, mas não para o ponto em questão.
philipxy
25

A palavra-chave volatile do C ++ introduz um limite de memória?

Um compilador C ++ que está em conformidade com a especificação não é necessário para introduzir um limite de memória. Seu compilador particular pode; encaminhe sua pergunta para os autores do seu compilador.

A função de "volatile" em C ++ não tem nada a ver com threading. Lembre-se de que o objetivo de "volátil" é desabilitar as otimizações do compilador para que a leitura de um registro que está mudando devido a condições exógenas não seja otimizada. Um endereço de memória que está sendo escrito por um thread diferente em uma CPU diferente é um registro que está mudando devido a condições exógenas? Não. Novamente, se alguns autores do compilador escolheram tratar endereços de memória sendo gravados por threads diferentes em CPUs diferentes como se fossem registros que mudam devido a condições exógenas, esse é o problema; eles não são obrigados a fazê-lo. Nem são necessárias - mesmo que introduz uma cerca memória - para, por exemplo, garantir que todos os fios vê uma consistente ordenação de leituras e gravações voláteis.

Na verdade, volatile é praticamente inútil para threading em C / C ++. A melhor prática é evitá-lo.

Além disso: as barreiras de memória são um detalhe de implementação de arquiteturas de processador específicas. Em C #, onde volatile é explicitamente projetado para multithreading, a especificação não diz que meias fences serão introduzidas, porque o programa pode estar sendo executado em uma arquitetura que não tem fences em primeiro lugar. Em vez disso, novamente, a especificação oferece certas garantias (extremamente fracas) sobre quais otimizações serão evitadas pelo compilador, tempo de execução e CPU para colocar certas restrições (extremamente fracas) em como alguns efeitos colaterais serão ordenados. Na prática, essas otimizações são eliminadas pelo uso de meias barreiras, mas esse é um detalhe de implementação sujeito a alterações no futuro.

O fato de que você se preocupa com a semântica de volátil em qualquer idioma no que diz respeito ao multithreading indica que você está pensando em compartilhar memória entre threads. Considere simplesmente não fazer isso. Isso torna seu programa muito mais difícil de entender e muito mais provável de conter bugs sutis e impossíveis de reproduzir.

Eric Lippert
fonte
19
"volatile é praticamente inútil em C / C ++." De modo nenhum! Você tem uma visão do mundo centrada no modo de usuário e na área de trabalho ... mas a maior parte do código C e C ++ é executado em sistemas embarcados onde o volátil é muito necessário para E / S mapeada em memória.
Ben Voigt
12
E a razão pela qual o acesso volátil é preservado não é simplesmente porque as condições exógenas podem alterar os locais da memória. O próprio acesso pode desencadear outras ações. Por exemplo, é muito comum que uma leitura avance um FIFO ou cancele um sinalizador de interrupção.
Ben Voigt
3
@BenVoigt: Era inútil para lidar efetivamente com problemas de threading.
Eric Lippert,
4
@DavidSchwartz O padrão obviamente não pode garantir como funciona o IO mapeado em memória. Mas o IO mapeado por memória é o motivo pelo qual volatilefoi introduzido no padrão C. Ainda assim, como o padrão não pode especificar coisas como o que realmente acontece em um "acesso", ele diz que "O que constitui um acesso a um objeto que tem um tipo qualificado por volátil é definido pela implementação." Muitas implementações hoje não fornecem uma definição útil de um acesso, o que IMHO viola o espírito do padrão, mesmo que esteja em conformidade com a letra.
James Kanze
8
Essa edição é uma melhoria definitiva, mas sua explicação ainda está muito focada na "memória pode ser alterada exogenamente". volatilesemânticas são mais fortes do que isso, o compilador tem que gerar todos os acessos solicitados (1.9 / 8, 1.9 / 12), não simplesmente garantir que mudanças exógenas sejam eventualmente detectadas (1.10 / 27). No mundo da E / S mapeada por memória, uma leitura de memória pode ter uma lógica arbitrária associada, como um getter de propriedade. Você não otimizaria chamadas para getters de propriedade de acordo com as regras que você definiu volatile, nem o padrão permite.
Ben Voigt
13

O que David está esquecendo é o fato de que o padrão C ++ especifica o comportamento de vários threads interagindo apenas em situações específicas e todo o resto resulta em comportamento indefinido. Uma condição de corrida envolvendo pelo menos uma gravação é indefinida se você não usar variáveis ​​atômicas.

Consequentemente, o compilador tem todo o direito de renunciar a qualquer instrução de sincronização, já que sua CPU só notará a diferença em um programa que exibe um comportamento indefinido devido à falta de sincronização.

Voo
fonte
5
Muito bem explicado, obrigado. O padrão apenas define a sequência de acessos aos voláteis como observável , desde que o programa não tenha comportamento indefinido .
Jonathan Wakely,
4
Se o programa tem uma disputa de dados, o padrão não faz requisitos sobre o comportamento observável do programa. Não se espera que o compilador acrescente barreiras aos acessos voláteis de forma a evitar corridas de dados presentes no programa, esse é o trabalho do programador, seja usando barreiras explícitas ou operações atômicas.
Jonathan Wakely
Por que você acha que estou esquecendo isso? Que parte do meu argumento você acha que invalida? Concordo 100% que o compilador tem o direito de abrir mão de qualquer sincronização.
David Schwartz
2
Isso está simplesmente errado ou, pelo menos, ignora o essencial. volatilenão tem nada a ver com tópicos; seu propósito original era oferecer suporte a IO mapeado em memória. E, pelo menos em alguns processadores, o suporte de IO mapeado em memória exigiria barreiras. (Compiladores não fazem isso, mas esse é um problema diferente.)
James Kanze
@JamesKanze volatiletem muito a ver com threads: volatilelida com a memória que pode ser acessada sem que o compilador saiba que pode ser acessada, e cobre muitos usos do mundo real de dados compartilhados entre threads em uma CPU específica.
curioso
12

Em primeiro lugar, os padrões C ++ não garantem as barreiras de memória necessárias para ordenar adequadamente as leituras / gravações que não são atômicas. variáveis voláteis são recomendadas para uso com MMIO, tratamento de sinais, etc. Na maioria das implementações, volatile não é útil para multi-threading e geralmente não é recomendado.

Em relação à implementação de acessos voláteis, esta é a escolha do compilador.

Este artigo , que descreve o comportamento do gcc , mostra que você não pode usar um objeto volátil como uma barreira de memória para ordenar uma sequência de gravações na memória volátil.

Com relação ao comportamento do icc , achei esta fonte dizendo também que volátil não garante pedidos de acesso à memória.

O compilador Microsoft VS2013 tem um comportamento diferente. Esta documentação explica como volatile reforça a semântica Release / Acquire e permite que objetos voláteis sejam usados ​​em bloqueios / releases em aplicativos multithread.

Outro aspecto que precisa ser levado em consideração é que o mesmo compilador pode ter um comportamento diferente. a volátil, dependendo da arquitetura de hardware de destino . Este post sobre o compilador MSVS 2013 afirma claramente as especificações da compilação com volátil para plataformas ARM.

Então, minha resposta para:

A palavra-chave volatile do C ++ introduz um limite de memória?

seria: Não garantido, provavelmente não, mas alguns compiladores podem fazer isso. Você não deve confiar no fato de que sim.

VAndrei
fonte
2
Isso não impede a otimização, apenas evita que o compilador altere cargas e armazenamentos além de certas restrições.
Dietrich Epp de
Não está claro o que você está dizendo. Você está dizendo que acontece de ser o caso em alguns compiladores não especificados que volatileimpede o compilador de reordenar cargas / armazenamentos? Ou você está dizendo que o padrão C ++ exige isso? E se for o último, você pode responder ao meu argumento em contrário citado na pergunta original?
David Schwartz
@DavidSchwartz O padrão impede um reordenamento (de qualquer fonte) de acessos através de um volatilelvalue. Como deixa a definição de "acesso" para a implementação, no entanto, isso não nos compra muito se a implementação não se importar.
James Kanze
Acho que algumas versões dos compiladores MSC implementaram a semântica de fence para volatile, mas não há nenhum fence no código gerado pelo compilador no Visual Studios 2012.
James Kanze
@JamesKanze O que basicamente significa que o único comportamento portátil de volatileé aquele especificamente enumerado pelo padrão. ( setjmp, sinais e assim por diante.)
David Schwartz
7

O compilador apenas insere um limite de memória na arquitetura Itanium, até onde eu sei.

A volatilepalavra-chave é realmente melhor usada para mudanças assíncronas, por exemplo, manipuladores de sinal e registros mapeados na memória; geralmente é a ferramenta errada para usar em programação multithread.

Dietrich Epp
fonte
1
Tipo de. 'o compilador' (msvc) insere um limite de memória quando uma arquitetura diferente de ARM é direcionada e a opção / volatile: ms é usada (o padrão). Consulte msdn.microsoft.com/en-us/library/12a04hfd.aspx . Outros compiladores não inserem cercas em variáveis ​​voláteis que eu saiba. O uso de volátil deve ser evitado, a menos que se trate diretamente com hardware, manipuladores de sinal ou compiladores que não estejam em conformidade com c ++ 11.
Stefan,
@Stefan No. volatileé extremamente útil para muitos usos que nunca lidam com hardware. Sempre que desejar que a implementação gere um código de CPU que siga de perto o código C / C ++, use volatile.
curioso
7

Depende de qual compilador é "o compilador". O Visual C ++ faz isso, desde 2005. Mas o padrão não exige isso, portanto, alguns outros compiladores não.

Ben Voigt
fonte
O VC ++ 2012 não parecem inserir uma cerca: int volatile i; int main() { return i; }gera um principal com exatamente duas instruções: mov eax, i; ret 0;.
James Kanze
@JamesKanze: Qual versão, exatamente? E você está usando alguma opção de compilação não padrão? Estou contando com a documentação (primeira versão afetada) e (versão mais recente) , que definitivamente mencionam adquirir e liberar semântica.
Ben Voigt
cl /helpdiz a versão 18.00.21005.1. O diretório em que está é C:\Program Files (x86)\Microsoft Visual Studio 12.0\VC. O cabeçalho da janela de comando diz VS 2013. Então, com relação à versão ... As únicas opções que usei foram /c /O2 /Fa. (Sem o /O2, ele também configura o frame da pilha local. Mas ainda não há instrução de fence.)
James Kanze
@JamesKanze: Eu estava mais interessado na arquitetura, por exemplo, "Microsoft (R) C / C ++ Otimizando Compilador Versão 18.00.30723 para x64" Talvez não haja barreira porque x86 e x64 têm garantias de coerência de cache bastante fortes em seu modelo de memória para começar ?
Ben Voigt
Talvez. Eu realmente não sei. O fato de eu ter feito isso mainpara que o compilador pudesse ver todo o programa e saber que não havia outras threads, ou pelo menos nenhum outro acesso à variável antes da minha (portanto, não poderia haver problemas de cache) poderia afetar isso também, mas de alguma forma, eu duvido.
James Kanze
5

Isso é em grande parte da memória e baseado no pré-C ++ 11, sem threads. Mas tendo participado das discussões sobre threading no comitê, posso dizer que nunca houve uma intenção do comitê que volatilepudesse ser usada para sincronização entre threads. A Microsoft propôs, mas a proposta não foi aceita.

A principal especificação de volatileé que o acesso a um volátil representa um "comportamento observável", assim como o IO. Da mesma forma, o compilador não pode reordenar ou remover IO específico, ele não pode reordenar ou remover acessos a um objeto volátil (ou mais corretamente, acessos por meio de uma expressão lvalue com tipo qualificado volátil). A intenção original do volátil era, na verdade, oferecer suporte a IO mapeado em memória. O "problema" com isso, no entanto, é que é a implementação definida o que constitui um "acesso volátil". E muitos compiladores o implementam como se a definição fosse "uma instrução que lê ou grava na memória foi executada". Que é uma definição legal, embora inútil, se a implementação especificar. (Ainda não encontrei a especificação real de nenhum compilador.

Indiscutivelmente (e é um argumento que eu aceito), isso viola a intenção do padrão, uma vez que a menos que o hardware reconheça os endereços como IO mapeado na memória e iniba qualquer reordenamento, etc., você não pode nem usar volátil para IO mapeado na memória, pelo menos nas arquiteturas Sparc ou Intel. No entanto, nenhum dos comilers que examinei (Sun CC, g ++ e MSC) produz qualquer instrução fence ou membar. (Na época em que a Microsoft propôs estender as regras volatile, acho que alguns de seus compiladores implementaram sua proposta e emitiram instruções de fence para acessos voláteis. Não verifiquei o que os compiladores recentes fazem, mas não me surpreenderia se dependesse em alguma opção de compilador. A versão que verifiquei - acho que era VS6.0 - não emitia fences, no entanto.

James Kanze
fonte
Por que você acabou de dizer que o compilador não pode reordenar ou remover acessos a objetos voláteis? Certamente, se os acessos são comportamentos observáveis, então certamente é precisamente igualmente importante evitar que a CPU, os buffers de postagem de gravação, o controlador de memória e tudo o mais os reordene também.
David Schwartz
@DavidSchwartz Porque é isso que o padrão diz. Certamente, de um ponto de vista prático, o que os compiladores que eu verifiquei fazem é totalmente inútil, mas as palavras-de-fuinha são o suficiente para que ainda possam alegar conformidade (ou poderiam, se realmente documentassem).
James Kanze
1
@DavidSchwartz: Para E / S com mapeamento de memória exclusivo (ou mutex) para periféricos, a volatilesemântica é perfeitamente adequada. Geralmente, esses periféricos relatam suas áreas de memória como não armazenáveis ​​em cache, o que ajuda no reordenamento no nível do hardware.
Ben Voigt
@BenVoigt Eu me perguntei sobre isso: a ideia de que o processador de alguma forma "sabe" que o endereço com o qual está lidando é IO mapeado em memória. Até onde eu sei, Sparcs não tem nenhum suporte para isso, então isso ainda tornaria Sun CC e g ++ em um Sparc inutilizável para IO mapeado em memória. (Quando eu pesquisei isso, eu estava interessado principalmente em um Sparc.)
James Kanze
@JamesKanze: Pela pouca pesquisa que fiz, parece que o Sparc tem intervalos de endereços dedicados para "visualizações alternativas" de memória que não podem ser armazenadas em cache. Contanto que seus pontos de acesso voláteis na ASI_REAL_IOparte do espaço de endereço, acho que você deve estar bem. (Altera NIOS usa uma técnica semelhante, com bits altos do endereço controlando o desvio de MMU; tenho certeza que há outros também)
Ben Voigt
5

Não precisa. Volátil não é um primitivo de sincronização. Ele apenas desativa as otimizações, ou seja, você obtém uma sequência previsível de leituras e gravações dentro de um thread na mesma ordem prescrita pela máquina abstrata. Mas ler e escrever em threads diferentes não têm ordem em primeiro lugar, não faz sentido falar em preservar ou não preservar sua ordem. A ordem entre os theads pode ser estabelecida por primitivos de sincronização, você obtém UB sem eles.

Um pouco de explicação sobre as barreiras de memória. Uma CPU típica possui vários níveis de acesso à memória. Há um pipeline de memória, vários níveis de cache, RAM etc.

As instruções do Membar liberam o pipeline. Eles não alteram a ordem em que as leituras e escritas são executadas, apenas força a execução das mais importantes em um determinado momento. É útil para programas multithread, mas não muito em contrário.

Cache (s) normalmente são automaticamente coerentes entre CPUs. Se quisermos ter certeza de que o cache está sincronizado com a RAM, é necessário liberar o cache. É muito diferente de um membar.

n.m.
fonte
1
Então você está dizendo que o padrão C ++ diz que volatileapenas desativa as otimizações do compilador? Isso não faz sentido. Qualquer otimização que o compilador pode fazer pode, pelo menos em princípio, ser igualmente bem feita pela CPU. Portanto, se o padrão diz que apenas desabilita as otimizações do compilador, isso significa que não fornecerá nenhum comportamento em que se possa confiar em código portátil. Mas isso obviamente não é verdade porque o código portátil pode confiar em seu comportamento em relação aos setjmpsinais e.
David Schwartz
1
@DavidSchwartz Não, o padrão não diz isso. Desativar otimizações é apenas o que é comumente feito para implementar o padrão. O padrão exige que o comportamento observável aconteça na mesma ordem exigida pela máquina abstrata. Quando a máquina abstrata não requer nenhuma ordem, a implementação é livre para usar qualquer ordem ou nenhuma ordem. O acesso a variáveis ​​voláteis em threads diferentes não é ordenado, a menos que uma sincronização adicional seja aplicada.
n. 'pronomes' m.
1
@DavidSchwartz Peço desculpas pelo texto impreciso. O padrão não exige que as otimizações sejam desativadas. Não tem nenhuma noção de otimização. Em vez disso, ele especifica o comportamento que, na prática, requer que os compiladores desabilitem certas otimizações de forma que a sequência observável de leituras e gravações seja compatível com o padrão.
n. 'pronomes' m.
1
Exceto que não exige isso, porque o padrão permite implementações para definir "sequência observável de leituras e gravações" como quiserem. Se as implementações escolherem definir sequências observáveis ​​de forma que as otimizações tenham que ser desativadas, elas o farão. Se não, não. Você obtém uma sequência previsível de leituras e gravações se, e somente se, a implementação decidiu fornecê-la a você.
David Schwartz
1
Não, a implementação precisa definir o que constitui um único acesso. A seqüência de tais acessos é prescrita pela máquina abstrata. Uma implementação deve preservar a ordem. A norma diz explicitamente que "volátil é uma dica para a implementação para evitar otimização agressiva envolvendo o objeto", embora em uma parte não normativa, mas a intenção é clara.
n. 'pronomes' m.
4

O compilador precisa introduzir uma barreira de memória em torno dos volatileacessos se, e somente se, isso for necessário para fazer os usos volatileespecificados no trabalho padrão (setjmp manipuladores de sinal e assim por diante) naquela plataforma particular.

Observe que alguns compiladores vão muito além do que é exigido pelo padrão C ++ para torná volatile-los mais poderosos ou úteis nessas plataformas. O código portátil não deve depender volatilede nada além do que está especificado no padrão C ++.

David Schwartz
fonte
2

Eu sempre uso volátil em rotinas de serviço de interrupção, por exemplo, o ISR (frequentemente código de montagem) modifica alguma localização de memória e o código de nível superior que roda fora do contexto de interrupção acessa a localização de memória através de um ponteiro para volátil.

Eu faço isso para RAM, bem como para E / S mapeada em memória.

Com base na discussão aqui, parece que este ainda é um uso válido de volátil, mas não tem nada a ver com vários threads ou CPUs. Se o compilador de um microcontrolador "sabe" que não pode haver nenhum outro acesso (por exemplo, tudo está no chip, sem cache e há apenas um núcleo), eu pensaria que uma barreira de memória não está implícita de forma alguma, o compilador só precisa evitar certas otimizações.

À medida que empilhamos mais coisas no "sistema" que executa o código-objeto, quase todas as apostas são canceladas, pelo menos foi assim que li esta discussão. Como um compilador poderia cobrir todas as bases?

Andrew Queisser
fonte
0

Eu acho que a confusão em torno do reordenamento volátil e de instrução decorre das 2 noções de reordenamento que as CPUs fazem:

  1. Execução fora de ordem.
  2. Sequência de leitura / gravação de memória vista por outras CPUs (reordenação de forma que cada CPU possa ver uma sequência diferente).

Volátil afeta como um compilador gera o código assumindo a execução de thread único (isso inclui interrupções). Isso não implica nada sobre instruções de barreira de memória, mas impede que um compilador execute certos tipos de otimizações relacionadas a acessos de memória.
Um exemplo típico é recuperar um valor da memória, em vez de usar um armazenado em cache em um registro.

Execução fora de ordem

As CPUs podem executar instruções fora de ordem / especulativamente, desde que o resultado final possa ter acontecido no código original. As CPUs podem realizar transformações que não são permitidas em compiladores porque os compiladores só podem realizar transformações corretas em todas as circunstâncias. Em contraste, as CPUs podem verificar a validade dessas otimizações e retirá-las caso se revelem incorretas.

Sequência de leitura / gravação de memória vista por outras CPUs

O resultado final de uma seqüência de instruções, a ordem efetiva, deve estar de acordo com a semântica do código gerado por um compilador. No entanto, a ordem de execução real escolhida pela CPU pode ser diferente. A ordem efetiva vista em outras CPUs (cada CPU pode ter uma visão diferente) pode ser restringida por barreiras de memória.
Não tenho certeza do quanto a ordem efetiva e a real podem diferir, porque não sei até que ponto as barreiras de memória podem impedir que as CPUs realizem uma execução fora de ordem.

Fontes:

Pawel Batko
fonte
0

Enquanto eu trabalhava em um tutorial em vídeo para download on-line para desenvolvimento de gráficos 3D e mecanismo de jogo trabalhando com OpenGL moderno. Nós usamos volatileem uma de nossas classes. O site do tutorial pode ser encontrado aqui e o vídeo que trabalha com a volatilepalavra-chave é encontrado no Shader Enginevídeo da série 98. Esses trabalhos não são meus, mas são credenciados Marek A. Krzeminski, MASce este é um trecho da página de download do vídeo.

"Uma vez que agora podemos ter nossos jogos executados em vários segmentos, é importante sincronizar os dados entre os segmentos de forma adequada. Neste vídeo, mostro como criar uma classe de bloqueio volátil para garantir que as variáveis ​​voláteis sejam devidamente sincronizadas ..."

E se você estiver inscrito no site dele e tiver acesso aos vídeos dele, ele faz referência a este artigo sobre o uso de Volatilecommultithreading programação.

Aqui está o artigo do link acima: http://www.drdobbs.com/cpp/volatile-the-multithreaded-programmers-b/184403766

volatile: o melhor amigo do programador multithreaded

Por Andrei Alexandrescu, 01 de fevereiro de 2001

A palavra-chave volatile foi criada para evitar otimizações do compilador que podem tornar o código incorreto na presença de certos eventos assíncronos.

Não quero estragar seu humor, mas esta coluna aborda o temido tópico da programação multithread. Se - como diz o capítulo anterior do Generic - a programação segura de exceções é difícil, é brincadeira de criança comparada à programação multithread.

Programas que usam vários threads são notoriamente difíceis de escrever, provar que estão corretos, depurar, manter e domar em geral. Programas multithread incorretos podem ser executados por anos sem uma falha, apenas para executar inesperadamente descontroladamente porque alguma condição de tempo crítica foi atendida.

Desnecessário dizer que um programador que escreve código multithread precisa de toda a ajuda que puder obter. Esta coluna se concentra em condições de corrida - uma fonte comum de problemas em programas multithread - e fornece insights e ferramentas sobre como evitá-los e, surpreendentemente, fazer com que o compilador trabalhe duro para ajudá-lo com isso.

Apenas uma pequena palavra-chave

Embora os padrões C e C ++ sejam notavelmente silenciosos quando se trata de threads, eles fazem uma pequena concessão ao multithreading, na forma da palavra-chave volatile.

Assim como sua contraparte mais conhecida const, volatile é um modificador de tipo. Ele deve ser usado em conjunto com variáveis ​​que são acessadas e modificadas em diferentes threads. Basicamente, sem voláteis, escrever programas multithread se torna impossível ou o compilador desperdiça grandes oportunidades de otimização. Uma explicação está em ordem.

Considere o seguinte código:

class Gadget {
public:
    void Wait() {
        while (!flag_) {
            Sleep(1000); // sleeps for 1000 milliseconds
        }
    }
    void Wakeup() {
        flag_ = true;
    }
    ...
private:
    bool flag_;
};

O objetivo de Gadget :: Wait acima é verificar a variável flag_ member a cada segundo e retornar quando essa variável foi definida como true por outro thread. Pelo menos é isso que seu programador pretendia, mas, infelizmente, Wait está incorreto.

Suponha que o compilador descubra que Sleep (1000) é uma chamada para uma biblioteca externa que não pode modificar a variável de membro flag_. Em seguida, o compilador conclui que pode armazenar flag_ em um registro e usar esse registro em vez de acessar a memória on-board mais lenta. Esta é uma excelente otimização para código de thread único, mas, neste caso, prejudica a correção: depois de chamar Wait for algum objeto Gadget, embora outro thread chame Wakeup, Wait fará um loop para sempre. Isso ocorre porque a mudança de flag_ não será refletida no registro que armazena flag_. A otimização é muito ... otimista.

Armazenar variáveis ​​em cache em registradores é uma otimização muito valiosa que se aplica na maior parte do tempo, então seria uma pena desperdiçá-la. C e C ++ oferecem a oportunidade de desabilitar explicitamente esse armazenamento em cache. Se você usar o modificador volátil em uma variável, o compilador não armazenará em cache essa variável em registradores - cada acesso atingirá a localização real da memória dessa variável. Portanto, tudo o que você precisa fazer para que a combinação de espera / despertar do gadget funcione é qualificar flag_ apropriadamente:

class Gadget {
public:
    ... as above ...
private:
    volatile bool flag_;
};

A maioria das explicações sobre a lógica e o uso de volatile param aqui e aconselham você a qualificar de forma volátil os tipos primitivos que você usa em vários threads. No entanto, há muito mais que você pode fazer com o volatile, porque ele faz parte do maravilhoso sistema de tipos do C ++.

Usando volátil com tipos definidos pelo usuário

Você pode qualificar por volatilidade não apenas os tipos primitivos, mas também os tipos definidos pelo usuário. Nesse caso, volatile modifica o tipo de maneira semelhante a const. (Você também pode aplicar const e volatile ao mesmo tipo simultaneamente.)

Ao contrário de const, volatile discrimina entre tipos primitivos e tipos definidos pelo usuário. Ou seja, ao contrário das classes, os tipos primitivos ainda suportam todas as suas operações (adição, multiplicação, atribuição, etc.) quando qualificados por volatilidade. Por exemplo, você pode atribuir um int não volátil a um int volátil, mas não pode atribuir um objeto não volátil a um objeto volátil.

Vamos ilustrar como funciona o volatile em tipos definidos pelo usuário em um exemplo.

class Gadget {
public:
    void Foo() volatile;
    void Bar();
    ...
private:
    String name_;
    int state_;
};
...
Gadget regularGadget;
volatile Gadget volatileGadget;

Se você acha que volátil não é tão útil com objetos, prepare-se para alguma surpresa.

volatileGadget.Foo(); // ok, volatile fun called for
                  // volatile object
regularGadget.Foo();  // ok, volatile fun called for
                  // non-volatile object
volatileGadget.Bar(); // error! Non-volatile function called for
                  // volatile object!

A conversão de um tipo não qualificado em sua contraparte volátil é trivial. No entanto, assim como com const, você não pode fazer a viagem de volta de volátil para não qualificado. Você deve usar um elenco:

Gadget& ref = const_cast<Gadget&>(volatileGadget);
ref.Bar(); // ok

Uma classe qualificada por volátil dá acesso apenas a um subconjunto de sua interface, um subconjunto que está sob o controle do implementador da classe. Os usuários podem obter acesso total à interface desse tipo apenas usando um const_cast. Além disso, assim como a constância, a volatilidade se propaga da classe para seus membros (por exemplo, volatileGadget.name_ e volatileGadget.state_ são variáveis ​​voláteis).

volátil, seções críticas e condições de corrida

O dispositivo de sincronização mais simples e usado com mais frequência em programas multithread é o mutex. Um mutex expõe as primitivas Acquire e Release. Depois de chamar Acquire em algum encadeamento, qualquer outro encadeamento chamando Acquire será bloqueado. Posteriormente, quando esse thread chamar Release, exatamente um thread bloqueado em uma chamada Acquire será liberado. Em outras palavras, para um determinado mutex, apenas um thread pode obter o tempo do processador entre uma chamada para Acquire e uma chamada para Release. O código de execução entre uma chamada para Acquire e uma chamada para Release é chamado de seção crítica. (A terminologia do Windows é um pouco confusa porque chama o mutex de seção crítica, enquanto "mutex" é, na verdade, um mutex entre processos. Seria bom se eles fossem chamados de mutex de thread e mutex de processo.)

Mutexes são usados ​​para proteger dados contra condições de corrida. Por definição, uma condição de corrida ocorre quando o efeito de mais threads nos dados depende de como os threads estão agendados. As condições da corrida aparecem quando dois ou mais tópicos competem para usar os mesmos dados. Como os threads podem interromper uns aos outros em momentos arbitrários no tempo, os dados podem ser corrompidos ou mal interpretados. Conseqüentemente, as alterações e, às vezes, os acessos aos dados devem ser protegidos cuidadosamente com seções críticas. Na programação orientada a objetos, isso geralmente significa que você armazena um mutex em uma classe como uma variável de membro e o usa sempre que acessar o estado dessa classe.

Programadores experientes em multithread podem ter bocejado ao ler os dois parágrafos acima, mas seu propósito é fornecer um treino intelectual, porque agora faremos o link com a conexão volátil. Fazemos isso traçando um paralelo entre o mundo dos tipos C ++ e o mundo da semântica de threading.

  • Fora de uma seção crítica, qualquer thread pode interromper qualquer outro a qualquer momento; não há controle, portanto, as variáveis ​​acessíveis a partir de vários threads são voláteis. Isso está de acordo com a intenção original de volatile - impedir que o compilador armazene inadvertidamente valores usados ​​por vários threads de uma vez.
  • Dentro de uma seção crítica definida por um mutex, apenas um thread tem acesso. Consequentemente, dentro de uma seção crítica, o código em execução tem semântica de thread único. A variável controlada não é mais volátil - você pode remover o qualificador volátil.

Em suma, os dados compartilhados entre os threads são conceitualmente voláteis fora de uma seção crítica e não voláteis dentro de uma seção crítica.

Você entra em uma seção crítica bloqueando um mutex. Você remove o qualificador volátil de um tipo aplicando um const_cast. Se conseguirmos colocar essas duas operações juntas, criamos uma conexão entre o sistema de tipos do C ++ e a semântica de threading de um aplicativo. Podemos fazer o compilador verificar as condições de corrida para nós.

LockingPtr

Precisamos de uma ferramenta que coleta uma aquisição mutex e um const_cast. Vamos desenvolver um template de classe LockingPtr que você inicializa com um objeto volátil obj e um mutex mtx. Durante sua vida útil, um LockingPtr mantém o mtx adquirido. Além disso, LockingPtr oferece acesso ao objeto volatile-stripped. O acesso é oferecido na forma de ponteiro inteligente, por meio de operador-> e operador *. O const_cast é executado dentro de LockingPtr. O elenco é semanticamente válido porque LockingPtr mantém o mutex adquirido por toda a sua vida.

Primeiro, vamos definir o esqueleto de uma classe Mutex com a qual LockingPtr funcionará:

class Mutex {
public:
    void Acquire();
    void Release();
    ...    
};

Para usar LockingPtr, você implementa Mutex usando as estruturas de dados nativas e funções primitivas do seu sistema operacional.

LockingPtr é modelado com o tipo da variável controlada. Por exemplo, se você deseja controlar um widget, use um LockingPtr que inicializa com uma variável do tipo widget volátil.

A definição de LockingPtr é muito simples. LockingPtr implementa um ponteiro inteligente não sofisticado. Ele se concentra exclusivamente na coleta de um const_cast e uma seção crítica.

template <typename T>
class LockingPtr {
public:
    // Constructors/destructors
    LockingPtr(volatile T& obj, Mutex& mtx)
      : pObj_(const_cast<T*>(&obj)), pMtx_(&mtx) {    
        mtx.Lock();    
    }
    ~LockingPtr() {    
        pMtx_->Unlock();    
    }
    // Pointer behavior
    T& operator*() {    
        return *pObj_;    
    }
    T* operator->() {   
        return pObj_;   
    }
private:
    T* pObj_;
    Mutex* pMtx_;
    LockingPtr(const LockingPtr&);
    LockingPtr& operator=(const LockingPtr&);
};

Apesar de sua simplicidade, LockingPtr é uma ajuda muito útil na escrita de código multithread correto. Você deve definir objetos que são compartilhados entre threads como voláteis e nunca usar const_cast com eles - sempre use objetos automáticos LockingPtr. Vamos ilustrar isso com um exemplo.

Digamos que você tenha dois threads que compartilham um objeto vetorial:

class SyncBuf {
public:
    void Thread1();
    void Thread2();
private:
    typedef vector<char> BufT;
    volatile BufT buffer_;
    Mutex mtx_; // controls access to buffer_
};

Dentro de uma função de thread, você simplesmente usa um LockingPtr para obter acesso controlado à variável de membro buffer_:

void SyncBuf::Thread1() {
    LockingPtr<BufT> lpBuf(buffer_, mtx_);
    BufT::iterator i = lpBuf->begin();
    for (; i != lpBuf->end(); ++i) {
        ... use *i ...
    }
}

O código é muito fácil de escrever e entender - sempre que você precisar usar buffer_, você deve criar um LockingPtr apontando para ele. Depois de fazer isso, você terá acesso a toda a interface do vetor.

A parte boa é que se você cometer um erro, o compilador irá apontá-lo:

void SyncBuf::Thread2() {
    // Error! Cannot access 'begin' for a volatile object
    BufT::iterator i = buffer_.begin();
    // Error! Cannot access 'end' for a volatile object
    for ( ; i != lpBuf->end(); ++i ) {
        ... use *i ...
    }
}

Você não pode acessar nenhuma função de buffer_ até que você aplique um const_cast ou use LockingPtr. A diferença é que LockingPtr oferece uma maneira ordenada de aplicar const_cast a variáveis ​​voláteis.

LockingPtr é extremamente expressivo. Se você só precisa chamar uma função, pode criar um objeto LockingPtr temporário sem nome e usá-lo diretamente:

unsigned int SyncBuf::Size() {
return LockingPtr<BufT>(buffer_, mtx_)->size();
}

Voltar para tipos primitivos

Vimos como a volatilidade protege objetos contra o acesso não controlado e como o LockingPtr fornece uma maneira simples e eficaz de escrever código thread-safe. Vamos agora retornar aos tipos primitivos, que são tratados de forma diferente por voláteis.

Vamos considerar um exemplo em que vários threads compartilham uma variável do tipo int.

class Counter {
public:
    ...
    void Increment() { ++ctr_; }
    void Decrement() { —ctr_; }
private:
    int ctr_;
};

Se Increment e Decrement forem chamados de threads diferentes, o fragmento acima está cheio de erros. Primeiro, ctr_ deve ser volátil. Em segundo lugar, mesmo uma operação aparentemente atômica como ++ ctr_ é na verdade uma operação de três estágios. A memória em si não tem recursos aritméticos. Ao incrementar uma variável, o processador:

  • Lê essa variável em um registro
  • Incrementa o valor no registro
  • Grava o resultado de volta na memória

Esta operação de três etapas é chamada de RMW (Read-Modify-Write). Durante a parte Modify de uma operação RMW, a maioria dos processadores libera o barramento de memória para permitir que outros processadores acessem a memória.

Se naquele momento outro processador realizar uma operação RMW na mesma variável, temos uma condição de corrida: a segunda gravação sobrescreve o efeito da primeira.

Para evitar isso, você pode confiar, novamente, no LockingPtr:

class Counter {
public:
    ...
    void Increment() { ++*LockingPtr<int>(ctr_, mtx_); }
    void Decrement() { —*LockingPtr<int>(ctr_, mtx_); }
private:
    volatile int ctr_;
    Mutex mtx_;
};

Agora o código está correto, mas sua qualidade é inferior quando comparado ao código do SyncBuf. Por quê? Porque com Counter, o compilador não irá avisá-lo se você acessar ctr_ por engano diretamente (sem bloqueá-lo). O compilador compila ++ ctr_ se ctr_ for volátil, embora o código gerado seja simplesmente incorreto. O compilador não é mais seu aliado, e apenas sua atenção pode ajudá-lo a evitar condições de corrida.

O que você deve fazer então? Simplesmente encapsule os dados primitivos que você usa em estruturas de nível superior e use voláteis com essas estruturas. Paradoxalmente, é pior usar volatile diretamente com os embutidos, apesar do fato de que inicialmente essa era a intenção de uso de volatile!

Funções de membro voláteis

Até agora, tivemos classes que agregam membros de dados voláteis; agora, vamos pensar em projetar classes que, por sua vez, farão parte de objetos maiores e serão compartilhados entre threads. É aqui que as funções de membro voláteis podem ser de grande ajuda.

Ao projetar sua classe, você qualifica de forma volátil apenas as funções de membro que são thread-safe. Você deve presumir que o código externo chamará as funções voláteis de qualquer código a qualquer momento. Não se esqueça: volátil é igual a código multithreaded grátis e nenhuma seção crítica; não volátil é igual a cenário de thread único ou dentro de uma seção crítica.

Por exemplo, você define uma classe Widget que implementa uma operação em duas variantes - uma thread-safe e outra rápida e desprotegida.

class Widget {
public:
    void Operation() volatile;
    void Operation();
    ...
private:
    Mutex mtx_;
};

Observe o uso de sobrecarga. Agora o usuário de Widget pode invocar Operation usando uma sintaxe uniforme para objetos voláteis e obter segurança de thread ou para objetos regulares e obter velocidade. O usuário deve ter cuidado ao definir os objetos Widget compartilhados como voláteis.

Ao implementar uma função de membro volátil, a primeira operação geralmente é bloqueá-la com um LockingPtr. Em seguida, o trabalho é feito usando o irmão não volátil:

void Widget::Operation() volatile {
    LockingPtr<Widget> lpThis(*this, mtx_);
    lpThis->Operation(); // invokes the non-volatile function
}

Resumo

Ao escrever programas multithread, você pode usar o volátil a seu favor. Você deve seguir as seguintes regras:

  • Defina todos os objetos compartilhados como voláteis.
  • Não use volátil diretamente com tipos primitivos.
  • Ao definir classes compartilhadas, use funções de membro volátil para expressar segurança de encadeamento.

Se você fizer isso e usar o componente genérico simples LockingPtr, poderá escrever código thread-safe e se preocupar muito menos com as condições de corrida, porque o compilador se preocupará com você e apontará diligentemente os pontos em que você está errado.

Alguns projetos em que estive envolvido usam volatile e LockingPtr com grande efeito. O código é limpo e compreensível. Lembro-me de alguns deadlocks, mas prefiro deadlocks a condições de corrida porque são muito mais fáceis de depurar. Praticamente não houve problemas relacionados às condições de corrida. Mas então você nunca sabe.

Reconhecimentos

Muito obrigado a James Kanze e Sorin Jianu que ajudaram com ideias perspicazes.


Andrei Alexandrescu é gerente de desenvolvimento da RealNetworks Inc. (www.realnetworks.com), com sede em Seattle, WA, e autor do livro aclamado Modern C ++ Design. Ele pode ser contatado em www.moderncppdesign.com. Andrei também é um dos instrutores destacados do Seminário C ++ (www.gotw.ca/cpp_seminar).

Este artigo pode estar um pouco desatualizado, mas dá uma boa ideia para um excelente uso do uso do modificador volátil com no uso de programação multithread para ajudar a manter os eventos assíncronos enquanto o compilador verifica as condições de corrida para nós. Isso pode não responder diretamente à pergunta original do OP sobre a criação de um limite de memória, mas escolho postar isso como uma resposta para os outros, como uma excelente referência para um bom uso de volátil ao trabalhar com aplicativos multithread.

Francis Cugler
fonte
0

A palavra-chave volatilesignifica essencialmente que a leitura e a gravação de um objeto devem ser executadas exatamente como escritas pelo programa, e não otimizadas de forma alguma . O código binário deve seguir o código C ou C ++: uma carga onde é lido, uma loja onde há uma gravação.

Isso também significa que nenhuma leitura deve resultar em um valor previsível: o compilador não deve assumir nada sobre uma leitura, mesmo imediatamente após uma gravação no mesmo objeto volátil:

volatile int i;
i = 1;
int j = i; 
if (j == 1) // not assumed to be true

volatilepode ser a ferramenta mais importante na caixa de ferramentas "C é uma linguagem assembly de alto nível" .

Se declarar um objeto volátil é suficiente para garantir o comportamento do código que lida com mudanças assíncronas depende da plataforma: diferentes CPUs fornecem diferentes níveis de sincronização garantida para leituras e gravações de memória normais. Você provavelmente não deve tentar escrever esse código multithreading de baixo nível, a menos que seja um especialista na área.

Os primitivos atômicos fornecem uma boa visão de alto nível de objetos para multithreading que torna mais fácil raciocinar sobre o código. Quase todos os programadores devem usar primitivos atômicos ou primitivos que fornecem exclusões mútuas, como mutexes, bloqueios de leitura e gravação, semáforos ou outros primitivos de bloqueio.

cara curioso
fonte