O volátil é caro?

111

Depois de ler The JSR-133 Cookbook for Compiler Writers sobre a implementação de volátil, especialmente a seção "Interações com instruções atômicas", presumo que ler uma variável volátil sem atualizá-la precisa de um LoadLoad ou uma barreira LoadStore. Mais abaixo na página, vejo que LoadLoad e LoadStore são efetivamente autônomos em CPUs X86. Isso significa que as operações de leitura volátil podem ser feitas sem uma invalidação de cache explícita no x86 e são tão rápidas quanto uma leitura de variável normal (desconsiderando as restrições de reordenamento de volátil)?

Acho que não entendi direito. Alguém poderia se importar em me esclarecer?

EDIT: Gostaria de saber se existem diferenças em ambientes multiprocessadores. Em sistemas de CPU única, a CPU pode olhar para seus próprios caches de thread, como afirma John V., mas em sistemas com várias CPUs deve haver alguma opção de configuração para as CPUs de que isso não é suficiente e a memória principal deve ser atingida, tornando o volátil mais lento em sistemas multi cpu, certo?

PS: No meu caminho para aprender mais sobre isso, tropecei nos seguintes ótimos artigos e, como esta questão pode ser interessante para outras pessoas, compartilharei meus links aqui:

Daniel
fonte
1
Você pode ler minha edição sobre a configuração com múltiplas CPUs a que está se referindo. Pode acontecer que em sistemas com várias CPUs para uma referência de curta duração, não ocorra mais do que uma única leitura / gravação na memória principal.
John Vint
2
a leitura volátil em si não é cara. o principal custo é como ele impede otimizações. na prática, esse custo em média também não é muito alto, a menos que o volátil seja usado em um loop fechado.
Irreputável
2
Este artigo sobre infoq ( infoq.com/articles/memory_barriers_jvm_concurrency ) também pode interessar a você, ele mostra os efeitos de volátil e sincronizado no código gerado para diferentes arquiteturas. Este também é um caso em que o jvm pode ter um desempenho melhor do que um compilador antecipado, uma vez que ele sabe se está rodando em um sistema uniprocessado e pode omitir algumas barreiras de memória.
Jörn Horstmann

Respostas:

123

Na Intel, uma leitura volátil não contestada é bastante barata. Se considerarmos o seguinte caso simples:

public static long l;

public static void run() {        
    if (l == -1)
        System.exit(-1);

    if (l == -2)
        System.exit(-1);
}

Usando a capacidade do Java 7 de imprimir código assembly, o método run é semelhante a:

# {method} 'run2' '()V' in 'Test2'
#           [sp+0x10]  (sp of caller)
0xb396ce80: mov    %eax,-0x3000(%esp)
0xb396ce87: push   %ebp
0xb396ce88: sub    $0x8,%esp          ;*synchronization entry
                                    ; - Test2::run2@-1 (line 33)
0xb396ce8e: mov    $0xffffffff,%ecx
0xb396ce93: mov    $0xffffffff,%ebx
0xb396ce98: mov    $0x6fa2b2f0,%esi   ;   {oop('Test2')}
0xb396ce9d: mov    0x150(%esi),%ebp
0xb396cea3: mov    0x154(%esi),%edi   ;*getstatic l
                                    ; - Test2::run@0 (line 33)
0xb396cea9: cmp    %ecx,%ebp
0xb396ceab: jne    0xb396ceaf
0xb396cead: cmp    %ebx,%edi
0xb396ceaf: je     0xb396cece         ;*getstatic l
                                    ; - Test2::run@14 (line 37)
0xb396ceb1: mov    $0xfffffffe,%ecx
0xb396ceb6: mov    $0xffffffff,%ebx
0xb396cebb: cmp    %ecx,%ebp
0xb396cebd: jne    0xb396cec1
0xb396cebf: cmp    %ebx,%edi
0xb396cec1: je     0xb396ceeb         ;*return
                                    ; - Test2::run@28 (line 40)
0xb396cec3: add    $0x8,%esp
0xb396cec6: pop    %ebp
0xb396cec7: test   %eax,0xb7732000    ;   {poll_return}
;... lines removed

Se você olhar as 2 referências para getstatic, a primeira envolve uma carga da memória, a segunda pula a carga, pois o valor é reutilizado do (s) registro (s) em que já está carregado (o comprimento é de 64 bits e no meu laptop de 32 bits usa 2 registros).

Se tornarmos a variável l volátil, a montagem resultante será diferente.

# {method} 'run2' '()V' in 'Test2'
#           [sp+0x10]  (sp of caller)
0xb3ab9340: mov    %eax,-0x3000(%esp)
0xb3ab9347: push   %ebp
0xb3ab9348: sub    $0x8,%esp          ;*synchronization entry
                                    ; - Test2::run2@-1 (line 32)
0xb3ab934e: mov    $0xffffffff,%ecx
0xb3ab9353: mov    $0xffffffff,%ebx
0xb3ab9358: mov    $0x150,%ebp
0xb3ab935d: movsd  0x6fb7b2f0(%ebp),%xmm0  ;   {oop('Test2')}
0xb3ab9365: movd   %xmm0,%eax
0xb3ab9369: psrlq  $0x20,%xmm0
0xb3ab936e: movd   %xmm0,%edx         ;*getstatic l
                                    ; - Test2::run@0 (line 32)
0xb3ab9372: cmp    %ecx,%eax
0xb3ab9374: jne    0xb3ab9378
0xb3ab9376: cmp    %ebx,%edx
0xb3ab9378: je     0xb3ab93ac
0xb3ab937a: mov    $0xfffffffe,%ecx
0xb3ab937f: mov    $0xffffffff,%ebx
0xb3ab9384: movsd  0x6fb7b2f0(%ebp),%xmm0  ;   {oop('Test2')}
0xb3ab938c: movd   %xmm0,%ebp
0xb3ab9390: psrlq  $0x20,%xmm0
0xb3ab9395: movd   %xmm0,%edi         ;*getstatic l
                                    ; - Test2::run@14 (line 36)
0xb3ab9399: cmp    %ecx,%ebp
0xb3ab939b: jne    0xb3ab939f
0xb3ab939d: cmp    %ebx,%edi
0xb3ab939f: je     0xb3ab93ba         ;*return
;... lines removed

Nesse caso, ambas as referências getstatic para a variável l envolvem uma carga da memória, ou seja, o valor não pode ser mantido em um registro em várias leituras voláteis. Para garantir que haja uma leitura atômica, o valor é lido da memória principal em um registro MMX, movsd 0x6fb7b2f0(%ebp),%xmm0tornando a operação de leitura uma única instrução (no exemplo anterior, vimos que o valor de 64 bits normalmente exigiria duas leituras de 32 bits em um sistema de 32 bits).

Portanto, o custo geral de uma leitura volátil será aproximadamente equivalente a uma carga de memória e pode ser tão barato quanto um acesso de cache L1. No entanto, se outro núcleo estiver gravando na variável volátil, o cache-line será invalidado, exigindo uma memória principal ou talvez um acesso ao cache L3. O custo real dependerá muito da arquitetura da CPU. Mesmo entre Intel e AMD, os protocolos de coerência de cache são diferentes.

Michael Barker
fonte
observação lateral, o java 6 tem a mesma capacidade de mostrar assembly (é o ponto de acesso que faz isso)
bestsss
+1 No JDK5, o volátil não pode ser reordenado em relação a qualquer leitura / gravação (o que corrige o bloqueio de verificação dupla, por exemplo). Isso implica que também afetará como os campos não voláteis são manipulados? Seria interessante misturar o acesso a campos voláteis e não voláteis.
ewernli
@evemli, você precisa ter cuidado, eu mesmo fiz essa afirmação uma vez, mas descobri que estava incorreta. Existe um caso extremo. O modelo de memória Java permite a semântica do motel barato, quando as lojas podem ser reordenadas antes das lojas voláteis. Se você aprendeu isso no artigo de Brian Goetz no site da IBM, vale a pena mencionar que este artigo simplifica demais a especificação JMM.
Michael Barker
20

De modo geral, na maioria dos processadores modernos, uma carga volátil é comparável a uma carga normal. Um armazenamento volátil é cerca de 1/3 do tempo de uma entrada / saída do monitor. Isso é visto em sistemas que são coerentes com o cache.

Para responder à pergunta do OP, as gravações voláteis são caras, enquanto as leituras geralmente não são.

Isso significa que as operações de leitura volátil podem ser feitas sem uma invalidação de cache explícita no x86 e são tão rápidas quanto uma leitura de variável normal (desconsiderando as restrições de reordenamento de volátil)?

Sim, às vezes, ao validar um campo, a CPU pode nem mesmo atingir a memória principal, em vez disso, espiar outros caches de thread e obter o valor de lá (explicação muito geral).

No entanto, apoio a sugestão de Neil de que, se você tiver um campo acessado por vários threads, deve envolvê-lo como AtomicReference. Por ser um AtomicReference, ele executa aproximadamente a mesma taxa de transferência para leituras / gravações, mas também é mais óbvio que o campo será acessado e modificado por vários threads.

Edite para responder à edição do OP:

A coerência do cache é um protocolo um pouco complicado, mas em resumo: as CPUs compartilharão uma linha de cache comum que é anexada à memória principal. Se uma CPU carregar memória e nenhuma outra CPU a tiver, a CPU assumirá que é o valor mais atualizado. Se outra CPU tentar carregar o mesmo local de memória, a CPU já carregada estará ciente disso e realmente compartilhará a referência em cache com a CPU solicitante - agora a CPU solicitante tem uma cópia dessa memória em seu cache de CPU. (Nunca foi necessário procurar na memória principal a referência)

Há um pouco mais de protocolo envolvido, mas isso dá uma ideia do que está acontecendo. Também para responder à sua outra pergunta, com a ausência de vários processadores, as leituras / gravações voláteis podem de fato ser mais rápidas do que com vários processadores. Existem alguns aplicativos que, na verdade, seriam executados com mais rapidez simultaneamente com uma única CPU do que com várias.

John Vint
fonte
5
Um AtomicReference é apenas um invólucro para um campo volátil com funções nativas adicionadas que fornecem funcionalidade adicional como getAndSet, compareAndSet etc., portanto, do ponto de vista do desempenho, usá-lo é útil apenas se você precisar da funcionalidade adicionada. Mas eu me pergunto por que você se refere ao sistema operacional aqui? A funcionalidade é implementada diretamente nos opcodes da CPU. E isso implica que em sistemas com vários processadores, onde uma CPU não tem conhecimento sobre o conteúdo do cache de outras CPUs, os voláteis são mais lentos porque as CPUs sempre precisam atingir a memória principal?
Daniel
Você está certo, eu sinto falta de falar sobre o sistema operacional que deveria ter escrito CPU, consertando isso agora. E sim, eu sei que AtomicReference é simplesmente um wrapper para campos voláteis, mas também adiciona como uma espécie de documentação que o próprio campo será acessado por vários threads.
John Vint
@John, por que você adicionaria outra indireção por meio de uma AtomicReference? Se você precisar de CAS - ok, mas AtomicUpdater pode ser uma opção melhor. Tanto quanto me lembro, não há intrínsecos sobre AtomicReference.
bestsss
@bestsss Para todos os fins gerais, você está certo de que não há diferença entre AtomicReference.set / get e volatile load e stores. Dito isso, tive o mesmo sentimento (e até certo ponto) sobre quando usá-lo. Esta resposta pode detalhar um pouco stackoverflow.com/questions/3964317/… . Usar qualquer um dos dois é mais uma preferência, meu único argumento para usar AtomicReference em vez de um simples volátil é para uma documentação clara - isso por si só não constitui o maior argumento que eu entendo
John Vint
Por outro lado, alguns argumentam que o uso de um campo volátil / AtomicReference (sem a necessidade de um CAS) leva a um código com erros old.nabble.com/…
John Vint
12

Nas palavras do modelo de memória Java (conforme definido para Java 5+ em JSR 133), qualquer operação - leitura ou gravação - em uma volatilevariável cria um relacionamento acontece antes em relação a qualquer outra operação na mesma variável. Isso significa que o compilador e o JIT são forçados a evitar certas otimizações, como instruções de reordenação no thread ou execução de operações apenas no cache local.

Como algumas otimizações não estão disponíveis, o código resultante é necessariamente mais lento do que deveria, embora provavelmente não muito.

No entanto, você não deve fazer uma variável volatile menos que saiba que ela será acessada de vários threads fora dos synchronizedblocos. Mesmo assim, você deve considerar se volátil é a melhor escolha contra synchronized, AtomicReferencee seus amigos, as Lockclasses explícitas , etc.

Neil Bartlett
fonte
4

O acesso a uma variável volátil é, em muitos aspectos, semelhante a agrupar o acesso a uma variável comum em um bloco sincronizado. Por exemplo, o acesso a uma variável volátil impede que a CPU reordene as instruções antes e depois do acesso, e isso geralmente retarda a execução (embora eu não possa dizer quanto).

De forma mais geral, em um sistema multiprocessador, não vejo como o acesso a uma variável volátil pode ser feito sem penalidade - deve haver alguma maneira de garantir que uma gravação no processador A seja sincronizada com uma leitura no processador B.

Cracóvia
fonte
4
Ler variáveis ​​voláteis tem a mesma penalidade que fazer uma entrada de monitor, em relação às possibilidades de reordenação de instruções, enquanto escrever uma variável volátil é igual a uma saída de monitor. Uma diferença pode ser quais variáveis ​​(por exemplo, caches do processador) são liberadas ou invalidadas. Embora sincronizado libere ou invalide tudo, o acesso à variável volátil deve sempre ignorar o cache.
Daniel
12
-1, Acessar uma variável volátil é um pouco diferente do que usar um bloco sincronizado. Inserir um bloco sincronizado requer uma gravação baseada em compareAndSet atômica para remover o bloqueio e uma gravação volátil para liberá-lo. Se o bloqueio estiver satisfeito, o controle deve passar do espaço do usuário para o espaço do kernel para arbitrar o bloqueio (essa é a parte cara). O acesso a um volátil sempre permanecerá no espaço do usuário.
Michael Barker
@MichaelBarker: Tem certeza de que todos os monitores devem ser protegidos pelo kernel e não pelo aplicativo?
Daniel
@Daniel: Se você representa um monitor usando um bloco sincronizado ou um bloqueio, então sim, mas apenas se o monitor estiver satisfeito. A única maneira de fazer isso sem a arbitragem do kernel é usar a mesma lógica, mas girar em vez de estacionar o thread.
Michael Barker
@MichaelBarker: Ok, para bloqueios contentes, eu entendo isso.
Daniel