Por que o volátil não é considerado útil em programação C ou C ++ multithread?

Como demonstrado nesta resposta que publiquei recentemente, pareço estar confuso sobre a utilidade (ou falta dela) de volatile em contextos de programação multi-thread.

Meu entendimento é o seguinte: sempre que uma variável pode ser alterada fora do stream de controle de uma parte do código que a acessa, essa variável deve ser declarada volatile . Manipuladores de sinais, registros de E / S e variables ​​modificadas por outro thread constituem essas situações.

Portanto, se você tiver um int foo global e foo for lido por um encadeamento e for configurado atomicamente por outro encadeamento (provavelmente usando uma instrução de máquina apropriada), o encadeamento de leitura verá essa situação da mesma maneira que vê uma variável ajustada por um sinal manipulador ou modificado por uma condição de hardware externo e, portanto, foo deve ser declarado volatile (ou, para situações multithreaded, acessado com carga protegida pela memory, que é provavelmente uma solução melhor).

Como e onde estou errado?

O problema com o volatile em um contexto multiencadeado é que ele não fornece todas as garantias que precisamos. Ele tem algumas propriedades que precisamos, mas não todas, então não podemos confiar apenas na volatile .

No entanto, os primitivos que teríamos que usar para as propriedades restantes também fornecem os que são volatile , portanto, é efetivamente desnecessário.

Para access seguro a dados compartilhados, precisamos garantir que:

  • o read / write realmente acontece (que o compilador não apenas armazena o valor em um registrador e adia a atualização da memory principal até muito mais tarde)
  • que não há reordenamento. Suponha que usamos uma variável volatile como sinalizador para indicar se alguns dados estão ou não prontos para serem lidos. Em nosso código, simplesmente definimos a bandeira depois de preparar os dados, então tudo parece bem. Mas e se as instruções forem reordenadas para que o sinalizador seja definido primeiro ?

volatile garante o primeiro ponto. Também garante que não haja reordenamento entre diferentes leituras / gravações voláteis . Todos volatile accesss de memory volatile ocorrerão na ordem em que são especificados. Isso é tudo o que precisamos para o que é volatile : manipular registros de E / S ou hardware mapeado por memory, mas isso não nos ajuda em código multithread, onde o object volatile é usado apenas para sincronizar o access a dados não voláteis. Esses accesss ainda podem ser reordenados em relação aos volatile .

A solução para evitar a reordenação é usar uma barreira de memory , que indica ao compilador e à CPU que nenhum access à memory pode ser reordenado nesse ponto . Colocar tais barreiras em torno de nosso access variável e volátil garante que até mesmo accesss não voláteis não sejam reordenados em todo o volátil, o que nos permite escrever código seguro para thread.

No entanto, as barreiras de memory também garantem que todas as leituras / gravações pendentes sejam executadas quando a barreira é alcançada, de modo que efetivamente nos fornece tudo o que precisamos por si só, tornando desnecessário a volatile . Podemos apenas remover o qualificador volatile inteiramente.

Desde C ++ 11, as variables ​​atômicas ( std::atomic ) nos dão todas as garantias relevantes.

Você também pode considerar isso na Documentação do Kernel do Linux .

Os programadores C frequentemente se tornaram voláteis para significar que a variável poderia ser alterada fora do atual segmento de execução; Como resultado, às vezes, eles são tentados a usá-lo no código do kernel quando estruturas de dados compartilhadas estão sendo usadas. Em outras palavras, eles são conhecidos por tratar tipos voláteis como uma espécie de variável atômica fácil, o que eles não são. O uso de volátil no código do kernel quase nunca está correto; este documento descreve por quê.

O ponto-chave a ser entendido em relação ao volátil é que seu objective é suprimir a otimização, que quase nunca é o que se quer realmente fazer. No kernel, é preciso proteger as estruturas de dados compartilhadas contra access simultâneo indesejado, que é uma tarefa muito diferente. O processo de proteção contra simultaneidade indesejada também evitará quase todos os problemas relacionados à otimização de maneira mais eficiente.

Como volátil, as primitivas do kernel que tornam o access simultâneo a dados seguros (spinlocks, exclusões mútuas, barreiras de memory, etc.) são projetadas para evitar a otimização indesejada. Se eles estão sendo usados ​​corretamente, não haverá necessidade de usar voláteis também. Se o volátil ainda for necessário, quase certamente haverá um erro no código em algum lugar. No código do kernel escrito corretamente, o volátil pode servir apenas para desacelerar as coisas.

Considere um bloco típico de código do kernel:

 spin_lock(&the_lock); do_something_on(&shared_data); do_something_else_with(&shared_data); spin_unlock(&the_lock); 

Se todo o código seguir as regras de bloqueio, o valor de shared_data não poderá ser alterado inesperadamente enquanto o the_lock estiver suspenso. Qualquer outro código que queira jogar com esses dados estará aguardando no bloqueio. As primitivas de spinlock agem como barreiras de memory – elas são escritas explicitamente para isso – o que significa que os accesss de dados não serão otimizados entre eles. Assim, o compilador pode pensar que sabe o que será em shared_data, mas a chamada spin_lock (), uma vez que atua como uma barreira de memory, forçará a esquecer tudo o que sabe. Não haverá problemas de otimização com accesss a esses dados.

Se shared_data fosse declarado volátil, o bloqueio ainda seria necessário. Mas o compilador também seria impedido de otimizar o access a shared_data dentro da seção crítica, quando sabemos que ninguém mais pode estar trabalhando com ele. Enquanto o bloqueio é mantido, shared_data não é volátil. Ao lidar com dados compartilhados, o bloqueio adequado torna a volatilidade desnecessária – e potencialmente prejudicial.

A class de armazenamento volátil era originalmente destinada a registradores de E / S mapeados em memory. Dentro do kernel, os accesss de registradores também devem ser protegidos por bloqueios, mas também não se deseja que o compilador “otimize” os accesss de registradores dentro de uma seção crítica. Mas, dentro do kernel, os accesss à memory de E / S são sempre feitos através de funções de access; acessar a memory de E / S diretamente através de pointers é desaprovado e não funciona em todas as arquiteturas. Esses acessadores são escritos para evitar otimização indesejada, portanto, mais uma vez, o volátil é desnecessário.

Outra situação em que alguém pode ser tentado a usar o volátil é quando o processador está ocupado – aguardando o valor de uma variável. O jeito certo de realizar uma espera movimentada é:

 while (my_variable != what_i_want) cpu_relax(); 

A chamada cpu_relax () pode reduzir o consumo de energia da CPU ou gerar um processador duplo com hyperthread; também serve como uma barreira de memory, então, mais uma vez, o volátil é desnecessário. Naturalmente, espera ocupada é geralmente um ato anti-social para começar.

Ainda existem algumas raras situações em que o volátil faz sentido no kernel:

  • As funções de access mencionadas acima podem usar estruturas voláteis em arquiteturas nas quais o access direto à memory de E / S funciona. Essencialmente, cada chamada de acessador se torna uma seção pouco crítica por si só e garante que o access aconteça conforme o esperado pelo programador.

  • O código de assembly embutido que altera a memory, mas que não possui outros efeitos colaterais visíveis, corre o risco de ser excluído pelo GCC. Adicionar a palavra-chave volátil às instruções asm impedirá essa remoção.

  • A variável jiffies é especial, pois pode ter um valor diferente toda vez que é referenciada, mas pode ser lida sem nenhum bloqueio especial. Portanto, os momentos podem ser voláteis, mas a adição de outras variables ​​desse tipo é fortemente desaprovada. O Jiffies é considerado um “legado estúpido” (palavras de Linus) a esse respeito; consertá-lo seria mais problema do que vale a pena.

  • Indicadores para estruturas de dados em memory coerente que podem ser modificadas por dispositivos de E / S podem, às vezes, ser legitimamente voláteis. Um buffer de anel usado por um adaptador de rede, onde esse adaptador altera pointers para indicar quais descritores foram processados, é um exemplo desse tipo de situação.

Para a maioria dos códigos, nenhuma das justificações acima para volatile se aplica. Como resultado, o uso do volátil provavelmente será visto como um bug e trará um exame adicional ao código. Desenvolvedores que são tentados a usar o volátil devem dar um passo para trás e pensar sobre o que realmente estão tentando realizar.

Eu não acho que você está errado – o volátil é necessário para garantir que o thread A verá o valor mudar, se o valor for alterado por algo diferente do thread A. Pelo que entendi, o volátil é basicamente uma maneira de dizer ao compilador “não armazene essa variável em um registrador, em vez disso, certifique-se de sempre ler / gravar a partir da memory RAM em todos os accesss”.

A confusão é porque a volatilidade não é suficiente para implementar várias coisas. Em particular, sistemas modernos usam múltiplos níveis de cache, CPUs modernas multi-core fazem algumas otimizações em tempo de execução, e compiladores modernos fazem algumas otimizações em tempo de compilation, e tudo isso pode resultar em vários efeitos colaterais aparecendo em um diferente ordem da ordem que você esperaria se apenas olhasse o código-fonte.

Portanto, a volatilidade é boa, desde que você tenha em mente que as mudanças ‘observadas’ na variável volátil podem não ocorrer no momento exato em que você pensa que elas ocorrerão. Especificamente, não tente usar variables ​​voláteis como uma forma de sincronizar ou ordenar operações em encadeamentos, porque isso não funcionará de maneira confiável.

Pessoalmente, o meu principal (apenas?) Uso para a bandeira volátil é como um booleano “pleaseGoAwayNow”. Se eu tiver um thread de trabalho que faz um loop continuamente, solicitarei que ele verifique o booleano volátil em cada iteração do loop e saia se o booleano for verdadeiro. O thread principal, em seguida, pode limpar com segurança o thread de trabalho, definindo o booleano como true e, em seguida, chamando pthread_join () para aguardar até que o thread de trabalho é ido.

Sua compreensão está realmente errada.

A propriedade, que as variables ​​voláteis têm, é “lê e escreve para esta variável são parte do comportamento perceptível do programa”. Isso significa que este programa funciona (dado hardware apropriado):

 int volatile* reg=IO_MAPPED_REGISTER_ADDRESS; *reg=1; // turn the fuel on *reg=2; // ignition *reg=3; // release int x=*reg; // fire missiles 

O problema é que essa não é a propriedade que queremos de algo seguro para thread.

Por exemplo, um contador thread-safe seria apenas (código linux-kernel-like, não sei o equivalente c ++ 0x):

 atomic_t counter; ... atomic_inc(&counter); 

Isso é atômico, sem uma barreira de memory. Você deve adicioná-los, se necessário. Adicionar volátil provavelmente não ajudaria, porque não relacionaria o access ao código próximo (por exemplo, append um elemento à lista que o contador está contando). Certamente, você não precisa ver o contador incrementado fora de seu programa, e as otimizações ainda são desejáveis, por exemplo.

 atomic_inc(&counter); atomic_inc(&counter); 

ainda pode ser otimizado para

 atomically { counter+=2; } 

se o otimizador for inteligente o suficiente (isso não altera a semântica do código).

volatile é útil (embora insuficiente) para implementar a construção básica de um mutex spinlock, mas uma vez que você tenha isso (ou algo superior), você não precisa de outro volatile .

A maneira típica de programação multithreaded não é proteger cada variável compartilhada no nível da máquina, mas sim introduzir variables ​​de proteção que guiam o stream do programa. Em vez de volatile bool my_shared_flag; Você devia ter

 pthread_mutex_t flag_guard_mutex; // contains something volatile bool my_shared_flag; 

Isso não apenas encapsula a “parte difícil”, como é fundamental: C não inclui operações atômicas necessárias para implementar um mutex; só tem volatile para fazer garantias extras sobre operações ordinárias .

Agora você tem algo parecido com isto:

 pthread_mutex_lock( &flag_guard_mutex ); my_local_state = my_shared_flag; // critical section pthread_mutex_unlock( &flag_guard_mutex ); pthread_mutex_lock( &flag_guard_mutex ); // may alter my_shared_flag my_shared_flag = ! my_shared_flag; // critical section pthread_mutex_unlock( &flag_guard_mutex ); 

my_shared_flag não precisa ser volátil, apesar de ser inutilizável, porque

  1. Outro segmento tem access a ele.
  2. Significando que uma referência a ela deve ter sido tomada em algum momento (com o operador & ).
    • (Ou uma referência foi levada para uma estrutura contendo)
  3. pthread_mutex_lock é uma function da biblioteca.
  4. O que significa que o compilador não pode dizer se pthread_mutex_lock alguma forma adquire essa referência.
  5. O que significa que o compilador deve assumir que pthread_mutex_lock modifica o sinalizador compartilhado !
  6. Portanto, a variável deve ser recarregada da memory. volatile , embora significativo neste contexto, é estranho.

Para que seus dados sejam consistentes em um ambiente simultâneo, você precisa de duas condições para aplicar:

1) Atomicidade ou seja, se eu ler ou gravar alguns dados na memory, então esses dados serão lidos / gravados em uma única passagem e não poderão ser interrompidos ou contidos devido, por exemplo, a uma alternância de contexto

2) Consistência, ou seja, a ordem de operações de leitura / gravação deve ser vista como sendo a mesma em vários ambientes simultâneos – seja em encadeamentos, máquinas, etc.

Volátil não se encheckbox em nenhuma das opções acima – ou, mais particularmente, o padrão c ou c ++ quanto ao comportamento volátil não inclui nenhum dos itens acima.

É ainda pior na prática, pois alguns compiladores (como o compilador intel Itanium) tentam implementar algum elemento de comportamento seguro de access simultâneo (ie garantindo cercas de memory), entretanto não há consistência nas implementações do compilador e o padrão não requer da implementação em primeiro lugar.

Marcar uma variável como volátil significa apenas que você está forçando o valor a ser liberado de e para a memory toda vez, o que, em muitos casos, apenas retarda seu código, já que basicamente você perdeu o desempenho do cache.

c # e java AFAIK corrigem isso fazendo com que volatile adira a 1) e 2) no entanto, o mesmo não pode ser dito para compiladores c / c ++, então, basicamente, faça isso como achar melhor.

Para uma discussão mais aprofundada (embora não imparcial) sobre o assunto, leia este

A FAQ de comp.programming.threads tem uma explicação clássica de Dave Butenhof:

Q56: Por que eu não preciso declarar variables ​​compartilhadas VOLATILE?

Estou preocupado, no entanto, com casos em que tanto o compilador quanto a biblioteca de threads cumprem suas respectivas especificações. Um compilador C em conformidade pode alocar globalmente alguma variável compartilhada (não volátil) para um registrador que é salvo e restaurado à medida que a CPU é passada de thread para thread. Cada thread terá seu próprio valor privado para essa variável compartilhada, que não é o que queremos de uma variável compartilhada.

De certa forma isso é verdade, se o compilador sabe o suficiente sobre os respectivos escopos da variável e as funções pthread_cond_wait (ou pthread_mutex_lock). Na prática, a maioria dos compiladores não tentará manter cópias de registro de dados globais através de uma chamada para uma function externa, porque é muito difícil saber se a rotina pode de alguma forma ter access ao endereço dos dados.

Então, sim, é verdade que um compilador que se conforma estritamente (mas muito agressivamente) ao ANSI C pode não funcionar com múltiplos threads sem volatilidade. Mas alguém deveria consertar melhor. Porque qualquer SYSTEM (isto é, pragmaticamente, uma combinação de kernel, bibliotecas e compilador C) que não fornece garantias de coerência de memory POSIX não CONFORME ao padrão POSIX. Período. O sistema NÃO PODE exigir que você use variables ​​voláteis compartilhadas para o comportamento correto, porque o POSIX requer apenas que as funções de synchronization POSIX sejam necessárias.

Então, se o seu programa quebrar porque você não usou o volátil, isso é um BUG. Pode não ser um bug no C, ou um bug na biblioteca de threads, ou um bug no kernel. Mas é um bug do SYSTEM, e um ou mais desses componentes terão que funcionar para consertá-lo.

Você não quer usar volátil porque, em qualquer sistema em que faça alguma diferença, será muito mais caro do que uma variável não volátil adequada. (ANSI C requer “pontos de seqüência” para variables ​​voláteis em cada expressão, enquanto POSIX exige apenas em operações de synchronization – um aplicativo de thread intensivo de computação verá substancialmente mais atividade de memory usando volátil e, afinal, é a atividade de memory que realmente te atrapalha.)

/ — [Dave Butenhof] ———————– [butenhof@zko.dec.com] — \
| Equipamentos Digitais Corporation 110 Spit Brook Rd ZKO2-3 / Q18 |
| 603.881.2218, FAX 603.881.0120 Nashua NH 03062-2698 |
—————– [Melhor convivência através da concorrência] —————- /

O Sr. Butenhof cobre muito do mesmo motivo neste posto da usenet :

O uso de “volátil” não é suficiente para garantir a visibilidade adequada da memory ou a synchronization entre os encadeamentos. O uso de um mutex é suficiente e, exceto pelo recurso a várias alternativas de código de máquina não portáteis (ou mais implicações sutis das regras de memory POSIX que são muito mais difíceis de aplicar em geral, conforme explicado no meu post anterior), O mutex é NECESSÁRIO.

Portanto, como Bryan explicou, o uso de volatile não faz nada além de impedir que o compilador faça otimizações úteis e desejáveis, não fornecendo nenhuma ajuda para tornar o código “seguro para threads”. Você é bem-vindo, é claro, declarar qualquer coisa que você queira como “volátil” – é um atributo legal de armazenamento ANSI C, afinal. Apenas não espere que isso resolva quaisquer problemas de synchronization de threads para você.

Tudo isso é igualmente aplicável ao C ++.

De acordo com o meu antigo padrão C, “o que constitui um access a um object que possui um tipo qualificado volátil é definido pela implementação” . Assim, os escritores do compilador C poderiam ter escolhido “volátil” para “access seguro ao encadeamento em um ambiente com vários processos” . Mas eles não fizeram.

Em vez disso, as operações necessárias para tornar um segmento de seção crítica seguro em um ambiente de memory compartilhada multi-processo multi-core foram adicionadas como novos resources definidos pela implementação. E, livres da exigência de que “volátil” fornecesse access atômico e ordenação de access em um ambiente com vários processos, os criadores do compilador priorizavam a redução de código em relação à semântica “volátil” dependente de implementação histórica.

Isso significa que coisas como semáforos “voláteis” em torno de seções de código críticas, que não funcionam em novos hardwares com novos compiladores, podem ter funcionado com compiladores antigos em hardware antigo, e exemplos antigos às vezes não estão errados, apenas antigos.

Isso é tudo o que “volátil” está fazendo: “Ei compilador, essa variável pode mudar EM QUALQUER MOMENTO (em qualquer marca do relógio) mesmo se NÃO houver INSTRUÇÕES LOCAIS atuando sobre ele. NÃO armazene esse valor em um registrador.”

É isso. Ele diz ao compilador que seu valor é, bem, volátil – esse valor pode ser alterado a qualquer momento pela lógica externa (outro thread, outro processo, o Kernel, etc.). Existe mais ou menos apenas para suprimir as otimizações do compilador que silenciosamente armazenam em cache um valor em um registrador que é inerentemente inseguro para qualquer cache.

Você pode encontrar artigos como “Dr. Dobbs” que o tom volátil como alguma panacéia para a programação multi-threaded. Sua abordagem não é totalmente desprovida de mérito, mas tem a falha fundamental de responsabilizar os usuários de um object por sua segurança de thread, que tende a ter os mesmos problemas que outras violações de encapsulamento.