"Volátil" garante alguma coisa no código C portátil para sistemas com vários núcleos?

12

Depois de olhar para um monte de outras questões e suas respostas , fico com a impressão de que não há um acordo generalizado sobre o que a palavra-chave "volátil" em C significa exatamente.

Mesmo o próprio padrão não parece suficientemente claro para que todos concordem com o que isso significa .

Entre outros problemas:

  1. Parece fornecer garantias diferentes, dependendo do seu hardware e do seu compilador.
  2. Isso afeta as otimizações do compilador, mas não as otimizações de hardware; portanto, em um processador avançado que faz suas próprias otimizações em tempo de execução, não é claro se o compilador pode impedir a otimização que você deseja impedir. (Alguns compiladores geram instruções para impedir algumas otimizações de hardware em alguns sistemas, mas isso não parece ser padronizado de forma alguma.)

Para resumir o problema, parece (depois de ler muito) que "volátil" garante algo como: O valor será lido / gravado não apenas de / para um registro, mas pelo menos no cache L1 do núcleo, na mesma ordem em que as leituras / gravações aparecem no código. Mas isso parece inútil, já que a leitura / gravação de / para um registro já é suficiente no mesmo encadeamento, enquanto a coordenação com o cache L1 não garante mais nada em relação à coordenação com outros encadeamentos. Não consigo imaginar quando seria importante sincronizar apenas com o cache L1.

USO 1
O único uso amplamente aceito e volátil de sistemas voláteis parece ser para sistemas antigos ou embutidos em que determinados locais de memória são mapeados por hardware para funções de E / S, como um pouco na memória que controla (diretamente, no hardware) uma luz , ou um pouco na memória que informa se uma tecla do teclado está pressionada ou não (porque está conectada pelo hardware diretamente à tecla).

Parece que o "uso 1" não ocorre no código portátil cujos destinos incluem sistemas com vários núcleos.

USE 2
Não é muito diferente de "use 1" a memória que pode ser lida ou gravada a qualquer momento por um manipulador de interrupções (que pode controlar uma luz ou armazenar informações de uma tecla). Mas já para isso, temos o problema de que, dependendo do sistema, o manipulador de interrupções pode ser executado em um núcleo diferente com seu próprio cache de memória e "volátil" não garante a coerência do cache em todos os sistemas.

Portanto, o "uso 2" parece estar além do que o "volátil" pode oferecer.

USO 3
O único outro uso indiscutível que vejo é impedir a otimização incorreta dos acessos por meio de diferentes variáveis ​​apontando para a mesma memória que o compilador não percebe que é a mesma memória. Mas isso provavelmente é indiscutível porque as pessoas não estão falando sobre isso - eu só vi uma menção a isso. E eu pensei que o padrão C já reconheceu que ponteiros "diferentes" (como argumentos diferentes para uma função) podem apontar para o mesmo item ou itens próximos, e já especifiquei que o compilador deve produzir código que funcione mesmo nesses casos. No entanto, não consegui encontrar rapidamente este tópico no padrão mais recente (500 páginas!).

Então "use 3" talvez não exista ?

Daí a minha pergunta:

"Volátil" garante alguma coisa no código C portátil para sistemas com vários núcleos?


EDIT - atualização

Depois de procurar o padrão mais recente , parece que a resposta é pelo menos um sim muito limitado:
1. O padrão especifica repetidamente o tratamento especial para o tipo específico "sig_atomic_t volátil". No entanto, o padrão também diz que o uso da função de sinal em um programa multithread resulta em comportamento indefinido. Portanto, esse caso de uso parece limitado à comunicação entre um programa de thread único e seu manipulador de sinais.
2. O padrão também especifica um significado claro para "volátil" em relação a setjmp / longjmp. (O exemplo de código onde é importante é fornecido em outras perguntas e respostas .)

Portanto, a pergunta mais precisa se torna:
"volátil" garante alguma coisa no código C portátil para sistemas com vários núcleos, além de (1) permitir que um programa de thread único receba informações de seu manipulador de sinal ou (2) permitir setjmp código para ver variáveis ​​modificadas entre setjmp e longjmp?

Ainda é uma pergunta de sim / não.

Se "yes", seria ótimo se você pudesse mostrar um exemplo de código portátil sem erros que se tornaria buggy se "volatile" for omitido. Se "não", suponho que um compilador esteja livre para ignorar "volátil" fora desses dois casos muito específicos, para destinos com vários núcleos.

Matt
fonte
3
Os sinais existem no C portátil; e uma variável global que é atualizada por um manipulador de sinal? Seria necessário volatileinformar o programa que ele pode ser alterado de forma assíncrona.
Nate Eldredge #
2
A @NateEldredge Global, embora seja apenas volátil, não é boa o suficiente. Ele precisa ser atômico também.
Eugene Sh.
@EugeneSh .: Sim, é claro. Mas a questão em questão é sobre volatileespecificamente, o que acredito ser necessário.
Node Eldredge #
" enquanto coordenar com o cache L1 não garante mais nada em relação à coordenação com outros threads " Onde "coordenar com o cache L1" não é suficiente para se comunicar com outros threads?
curiousguy
11
Talvez relevante, proposta C ++ para depreciar volátil , a proposta trata muitas das preocupações que você levanta aqui, e talvez o seu resultado será influente ao comitê C
MM

Respostas:

1

Para resumir o problema, parece (depois de ler muito) que "volátil" garante algo como: O valor será lido / gravado não apenas de / para um registro, mas pelo menos no cache L1 do núcleo, na mesma ordem em que as leituras / gravações aparecem no código .

Não, absolutamente não . E isso torna volátil quase inútil para o propósito do código seguro MT.

Se isso acontecesse, então volátil seria muito bom para variáveis ​​compartilhadas por vários threads, pois a ordem dos eventos no cache L1 é tudo o que você precisa fazer na CPU típica (que é multi-core ou multi-CPU na placa-mãe) capaz de cooperar de uma maneira que possibilite uma implementação normal de multithreading C / C ++ ou Java com custos típicos esperados (isto é, não um custo enorme na maioria das operações mutex atômicas ou sem conteúdo).

Porém, o volátil não fornece nenhuma ordem garantida (ou "visibilidade da memória") no cache, na teoria ou na prática.

(Nota: o seguinte é baseado na interpretação sólida dos documentos padrão, na intenção do padrão, na prática histórica e em um profundo entendimento das expectativas dos escritores do compilador. Essa abordagem baseia-se na história, nas práticas reais, nas expectativas e no entendimento de pessoas reais em o mundo real, que é muito mais forte e mais confiável do que analisar as palavras de um documento que não é conhecido por escrever especificações estelares e que foi revisado várias vezes.)

Na prática, o volátil garante a capacidade de rastrear, que é a capacidade de usar informações de depuração para o programa em execução, em qualquer nível de otimização , e o fato de que as informações de depuração fazem sentido para esses objetos voláteis:

  • você pode usar ptrace(um mecanismo semelhante ao ptrace) para definir pontos de interrupção significativos nos pontos de sequência após operações envolvendo objetos voláteis: você pode realmente quebrar exatamente nesses pontos (observe que isso funciona apenas se você estiver disposto a definir muitos pontos de interrupção como qualquer outro) A instrução C / C ++ pode ser compilada em muitos pontos diferentes de início e de final da montagem, como em um loop massivamente desenrolado);
  • enquanto um encadeamento de execução de parado, você pode ler o valor de todos os objetos voláteis, pois eles têm sua representação canônica (seguindo a ABI para seu respectivo tipo); uma variável local não volátil pode ter uma representação atípica, por exemplo uma representação deslocada: uma variável usada para indexar uma matriz pode ser multiplicada pelo tamanho de objetos individuais, para facilitar a indexação; ou pode ser substituído por um ponteiro para um elemento da matriz (desde que todos os usos da variável sejam convertidos da mesma forma) (pense em mudar dx para du em uma integral);
  • você também pode modificar esses objetos (contanto que os mapeamentos de memória permitam que, como objeto volátil com vida estática que seja const qualificado possa estar em um intervalo de memória mapeado apenas para leitura).

A garantia volátil na prática é um pouco mais do que a estrita interpretação do ptrace: também garante que variáveis ​​automáticas voláteis tenham um endereço na pilha, pois não são alocadas a um registro, uma alocação de registro que tornaria as manipulações do ptrace mais delicadas (o compilador pode informações de depuração de saída para explicar como as variáveis ​​são alocadas aos registradores, mas ler e alterar o estado do registrador é um pouco mais envolvido do que acessar endereços de memória).

Observe que a capacidade total de depuração do programa, que considera todas as variáveis ​​voláteis, pelo menos nos pontos de sequência, é fornecida pelo modo "otimização zero" do compilador, um modo que ainda realiza otimizações triviais como simplificações aritméticas (geralmente não há garantia de otimização em todos os modos). Mas volátil é mais forte que a não otimização: x-xpode ser simplificado para um número inteiro não volátil, xmas não para um objeto volátil.

Portanto, meios voláteis garantidos para serem compilados como estão , como a tradução da origem para o binário / assembly pelo compilador de uma chamada do sistema não são uma reinterpretação, alterada ou otimizada de qualquer forma por um compilador. Observe que as chamadas da biblioteca podem ou não ser chamadas do sistema. Muitas funções oficiais do sistema são, na verdade, funções de biblioteca que oferecem uma fina camada de interposição e geralmente adiam para o kernel no final. (Em particular, getpidnão é necessário acessar o kernel e pode ler uma localização de memória fornecida pelo sistema operacional que contém as informações.)

Interações voláteis são interações com o mundo exterior da máquina real , que devem seguir a "máquina abstrata". Eles não são interações internas das partes do programa com outras partes do programa. O compilador só pode raciocinar sobre o que sabe, ou seja, as partes internas do programa.

A geração de código para um acesso volátil deve seguir a interação mais natural com esse local de memória: não deve surpreender. Isso significa que é esperado que alguns acessos voláteis sejam atômicos : se a maneira natural de ler ou gravar a representação de um longna arquitetura é atômica, é esperado que uma leitura ou gravação de um volatile longseja atômica, pois o compilador não deve gerar código tolo e ineficiente para acessar objetos voláteis byte a byte, por exemplo .

Você deve poder determinar isso conhecendo a arquitetura. Você não precisa saber nada sobre o compilador, pois volátil significa que o compilador deve ser transparente .

Porém, o volátil não força mais do que forçar a emissão do assembly esperado para os menos otimizados para casos específicos para executar uma operação de memória: semântica volátil significa semântica geral de caso.

O caso geral é o que o compilador faz quando não possui nenhuma informação sobre uma construção: f.ex. chamar uma função virtual em um lvalue por meio de despacho dinâmico é um caso geral, fazer uma chamada direta ao overrider após determinar em tempo de compilação o tipo de objeto designado pela expressão é um caso específico. O compilador sempre tem um tratamento geral de caso de todas as construções e segue a ABI.

O volátil não faz nada de especial para sincronizar threads ou fornecer "visibilidade da memória": o volátil fornece apenas garantias no nível abstrato visto de dentro de um thread em execução ou parado, que é o interior de um núcleo de CPU :

  • volátil não diz nada sobre quais operações de memória atingem a RAM principal (você pode definir tipos específicos de cache de memória com instruções de montagem ou chamadas do sistema para obter essas garantias);
  • volátil não fornece nenhuma garantia sobre quando as operações de memória serão confirmadas em qualquer nível de cache (nem mesmo L1) .

Somente o segundo ponto significa que o volátil não é útil na maioria dos problemas de comunicação entre threads; o primeiro ponto é essencialmente irrelevante em qualquer problema de programação que não envolva comunicação com componentes de hardware fora da (s) CPU (s), mas ainda no barramento de memória.

A propriedade volátil de fornecer comportamento garantido do ponto de vista do núcleo que está executando o encadeamento significa que os sinais assíncronos entregues a esse encadeamento, que são executados do ponto de vista da ordem de execução desse encadeamento, ver operações na ordem do código-fonte .

A menos que você planeje enviar sinais para seus encadeamentos (uma abordagem extremamente útil para a consolidação de informações sobre encadeamentos atualmente em execução, sem um ponto de parada previamente acordado), o volátil não é para você.

curiousguy
fonte
6

Não sou especialista, mas o cppreference.com tem o que parece ser uma informaçãovolatile muito boa sobre . Aqui está a essência:

Todo acesso (leitura e gravação) feito por meio de uma expressão lvalue do tipo volátil é considerado um efeito colateral observável para fins de otimização e é avaliado estritamente de acordo com as regras da máquina abstrata (ou seja, todas as gravações são concluídas em algum tempo antes do próximo ponto de sequência). Isso significa que, em um único encadeamento de execução, um acesso volátil não pode ser otimizado ou reordenado em relação a outro efeito colateral visível que é separado por um ponto de sequência do acesso volátil.

Também fornece alguns usos:

Usos voláteis

1) objetos voláteis estáticos modelam portas de E / S mapeadas na memória e objetos voláteis estáticos const modelam portas de entrada mapeadas na memória, como um relógio em tempo real

2) objetos voláteis estáticos do tipo sig_atomic_t são usados ​​para comunicação com manipuladores de sinais.

3) variáveis ​​voláteis que são locais para uma função que contém uma chamada da macro setjmp são as únicas variáveis ​​locais garantidas para manter seus valores após o retorno de longjmp.

4) Além disso, variáveis ​​voláteis podem ser usadas para desabilitar certas formas de otimização, por exemplo, desabilitar a eliminação de lojas mortas ou dobrar constantemente as marcas de microempresas.

E, claro, menciona que volatilenão é útil para sincronização de threads:

Observe que variáveis ​​voláteis não são adequadas para comunicação entre threads; eles não oferecem atomicidade, sincronização ou pedido de memória. Uma leitura de uma variável volátil modificada por outro encadeamento sem sincronização ou modificação simultânea de dois encadeamentos não sincronizados é um comportamento indefinido devido a uma corrida de dados.

Fred Larson
fonte
2
Em particular, (2) e (3) são relevantes para o código portátil.
Nate Eldredge #
2
@TED Apesar do nome de domínio, o link é para informações sobre C, não C ++
David Brown
@NateEldredge Você raramente pode usar longjmpno código C ++.
2392525252Código
@DavidBrown C e C ++ têm a mesma definição de um SE observável e essencialmente as mesmas primitivas de encadeamento.
curiousguy
4

Antes de tudo, historicamente houve vários problemas com relação a diferentes interpretações do significado de volatileacesso e similares. Veja este estudo: Voláteis são mal compilados e o que fazer sobre isso .

Além dos vários problemas mencionados nesse estudo, o comportamento de volatileé portátil, exceto por um aspecto deles: quando eles agem como barreiras de memória . Uma barreira de memória é um mecanismo que existe para impedir a execução simultânea e não consecutiva do seu código. Usar volatilecomo barreira de memória certamente não é portátil.

Se a linguagem C garante ou não o comportamento da memória volatileé aparentemente discutível, embora pessoalmente eu ache que a linguagem é clara. Primeiro, temos a definição formal de efeitos colaterais, C17 5.1.2.3:

Acessar um volatileobjeto, modificar um objeto, modificar um arquivo ou chamar uma função que execute qualquer uma dessas operações são todos efeitos colaterais , que são alterações no estado do ambiente de execução.

A norma define o termo sequenciamento, como uma maneira de determinar a ordem da avaliação (execução). A definição é formal e complicada:

Seqüenciada anteriormente, há uma relação assimétrica, transitiva e em pares entre avaliações executadas por um único encadeamento, que induz uma ordem parcial entre essas avaliações. Dadas duas avaliações A e B, se A for sequenciado antes de B, a execução de A precederá a execução de B. (Inversamente, se A for sequenciado antes de B, B será sequenciado após A.) Se A não for sequenciado antes ou depois de B, A e B não são sequenciados . As avaliações A e B são sequenciadas indeterminadamente quando A é sequenciada antes ou depois de B, mas não é especificado qual.13) A presença de um ponto de sequência entre a avaliação das expressões A e B implica que todos os cálculos de valor e efeitos colaterais associados a A sejam sequenciados antes de cada cálculo de valor e efeito colateral associado a B. (Um resumo dos pontos de sequência é apresentado no anexo C.)

O TL; DR acima é basicamente que, no caso de termos uma expressão Aque contém efeitos colaterais, ela deve ser executada antes de outra expressão B, caso Bseja sequenciada posteriormente A.

Otimizações do código C são possíveis através desta parte:

Na máquina abstrata, todas as expressões são avaliadas conforme especificado pela semântica. Uma implementação real não precisa avaliar parte de uma expressão se puder deduzir que seu valor não é usado e que nenhum efeito colateral necessário é produzido (incluindo os causados ​​pela chamada de uma função ou pelo acesso a um objeto volátil).

Isso significa que o programa pode avaliar (executar) expressões na ordem que o padrão exige em outros lugares (ordem de avaliação, etc.). Mas ele não precisa avaliar (executar) um valor se puder deduzir que não é usado. Por exemplo, a operação 0 * xnão precisa avaliar xe simplesmente substituir a expressão por 0.

A menos que acessar uma variável seja um efeito colateral. Isso significa que, caso xseja volatile, ele deve avaliar (executar) 0 * xmesmo que o resultado sempre seja 0. A otimização não é permitida.

Além disso, o padrão fala de comportamento observável:

Os requisitos mínimos em uma implementação em conformidade são:

  • O acesso a objetos voláteis é avaliado estritamente de acordo com as regras da máquina abstrata.
    / - / Este é o comportamento observável do programa.

Dado todo o exposto, uma implementação em conformidade (compilador + sistema subjacente) pode não executar o acesso a volatileobjetos em uma ordem não sequencial, caso a semântica da fonte C escrita diga o contrário.

Isso significa que neste exemplo

volatile int x;
volatile int y;
z = x;
z = y;

Ambas as expressões de atribuição deve ser avaliado e z = x; deve ser avaliada antes z = y;. Uma implementação de multiprocessador que terceiriza essas duas operações para dois núcleos de sequências diferentes não está em conformidade!

O dilema é que os compiladores não podem fazer muito sobre coisas como cache de pré-busca e pipelining de instruções, etc., principalmente quando executados em cima de um sistema operacional. E assim os compiladores entregam esse problema aos programadores, dizendo que as barreiras de memória agora são de responsabilidade do programador. Enquanto o padrão C afirma claramente que o problema precisa ser resolvido pelo compilador.

O compilador não necessariamente se preocupa em resolver o problema e, volatilepor isso, agir como uma barreira de memória não é portátil. Tornou-se um problema de qualidade de implementação.

Lundin
fonte
@curiousguy Não importa.
Lundin
@curiousguy Não importa, desde que seja algum tipo de número inteiro com ou sem qualificadores.
Lundin
Se é um número inteiro simples e não volátil, por que gravações redundantes zseriam realmente executadas? (como z = x; z = y;) O valor será apagado na próxima instrução.
curiousguy
@curiousguy Como as leituras das variáveis ​​voláteis precisam ser executadas, não importa, na sequência especificada.
Lundin
Então é zrealmente atribuído duas vezes? Como você sabe que "as leituras são executadas"?
curiousguy