A necessidade de modificador volátil no bloqueio de dupla verificação no .NET

85

Vários textos dizem que, ao implementar o bloqueio com verificação dupla no .NET, o campo que você está bloqueando deve ter o modificador volátil aplicado. Mas por que exatamente? Considerando o seguinte exemplo:

public sealed class Singleton
{
   private static volatile Singleton instance;
   private static object syncRoot = new Object();

   private Singleton() {}

   public static Singleton Instance
   {
      get 
      {
         if (instance == null) 
         {
            lock (syncRoot) 
            {
               if (instance == null) 
                  instance = new Singleton();
            }
         }

         return instance;
      }
   }
}

por que o "lock (syncRoot)" não atinge a consistência de memória necessária? Não é verdade que após a instrução de "bloqueio" tanto a leitura quanto a gravação seriam voláteis e, portanto, a consistência necessária seria alcançada?

Konstantin
fonte
2
Isso já foi mastigado muitas vezes. yoda.arachsys.com/csharp/singleton.html
Hans Passant
1
Infelizmente, nesse artigo Jon faz referência a 'volátil' duas vezes, e nenhuma das referências se refere diretamente aos exemplos de código que ele deu.
Dan Esparza
Consulte este artigo para entender a preocupação: igoro.com/archive/volatile-keyword-in-c-memory-model-explained Basicamente, é TEORICAMENTE possível para o JIT usar um registro de CPU para a variável de instância - especialmente se você precisar de um um pequeno código extra lá. Assim, fazer uma instrução if duas vezes potencialmente poderia retornar o mesmo valor, independentemente de ele ser alterado em outro encadeamento. Na realidade, a resposta é um pouco complexa. A instrução de bloqueio pode ou não ser responsável por tornar as coisas melhores aqui (continuação)
usuário2685937
(continuação do comentário anterior) - Aqui está o que eu acho que está realmente acontecendo - Basicamente, qualquer código que faça algo mais complexo do que ler ou definir uma variável pode acionar o JIT para dizer, esqueça de tentar otimizar isso, vamos apenas carregar e salvar na memória porque se uma função for chamada, o JIT potencialmente precisaria salvar e recarregar o registro se ele o armazenasse lá todas as vezes, ao invés de apenas escrever e ler diretamente da memória a cada vez. Como posso saber se o bloqueio não é nada especial? Veja o link que postei no comentário anterior de Igor (continua no próximo comentário)
user2685937
(veja os 2 comentários acima) - Eu testei o código do Igor e quando ele criou um novo thread, adicionei um bloqueio em torno dele e até fiz um loop. Ainda assim, não faria com que o código fosse encerrado porque a variável de instância foi retirada do loop. Adicionar ao loop while um conjunto de variável local simples ainda tirou a variável do loop - agora, qualquer coisa mais complicada como instruções if ou uma chamada de método ou até mesmo uma chamada de bloqueio impediria a otimização e, portanto, faria com que funcionasse. Portanto, qualquer código complexo muitas vezes força o acesso direto às variáveis ​​em vez de permitir que o JIT otimize. (continua no próximo comentário)
user2685937

Respostas:

60

Volátil é desnecessário. Bem, tipo de **

volatileé usado para criar uma barreira de memória * entre leituras e gravações na variável.
lock, quando usado, faz com que sejam criadas barreiras de memória ao redor do bloco dentro do lock, além de limitar o acesso ao bloco a um thread.
As barreiras de memória fazem com que cada thread leia o valor mais atual da variável (não um valor local armazenado em cache em algum registro) e que o compilador não reordene as instruções. Usar volatileé desnecessário ** porque você já tem um cadeado.

Joseph Albahari explica essas coisas muito melhor do que eu jamais poderia.

E certifique-se de verificar o guia de Jon Skeet para implementar o singleton em C #


update :
* volatilefaz com que as leituras da variável sejam se VolatileReadas gravações sejam VolatileWrites, que em x86 e x64 em CLR, são implementadas com a MemoryBarrier. Eles podem ser mais refinados em outros sistemas.

** minha resposta está correta apenas se você estiver usando o CLR em processadores x86 e x64. Ele pode ser verdade em outros modelos de memória, como em Mono (e outras implementações), Itanium64 e hardware futuro. Isso é o que Jon está se referindo em seu artigo nas "pegadinhas" para bloqueio com verificação dupla.

Pode ser necessário fazer um dos {marcar a variável como volatile, lê-la com Thread.VolatileReadou inserir uma chamada para Thread.MemoryBarrier} para que o código funcione corretamente em uma situação de modelo de memória fraca.

Pelo que entendi, no CLR (mesmo no IA64), as gravações nunca são reordenadas (as gravações sempre têm semântica de liberação). No entanto, no IA64, as leituras podem ser reordenadas para vir antes das gravações, a menos que sejam marcadas como voláteis. Infelizmente, não tenho acesso ao hardware IA64 para brincar, então qualquer coisa que eu diga sobre isso seria especulação.

Eu também achei estes artigos úteis:
http://www.codeproject.com/KB/tips/MemoryBarrier.aspx
artigo de vance morrison (tudo está vinculado a isso, ele fala sobre bloqueio com verificação dupla)
artigo de chris brumme (tudo está vinculado a este )
Joe Duffy: Variantes quebradas de bloqueio duplo verificado

A série de luis abreu sobre multithreading também oferece uma boa visão geral dos conceitos
http://msmvps.com/blogs/luisabreu/archive/2009/06/29/multithreading-load-and-store-reordering.aspx
http: // msmvps. com / blogs / luisabreu / archive / 2009/07/03 / multithreading-introducing-memory-fences.aspx

dan
fonte
Jon Skeet na verdade diz que o modificador volátil é necessário para criar uma barreira de memória adequada, enquanto o primeiro autor do link diz que o bloqueio (Monitor.Enter) seria suficiente. Quem está realmente certo ???
Konstantin
@Konstantin parece que Jon estava se referindo ao modelo de memória nos processadores Itanium 64, então, nesse caso, o uso de voláteis pode ser necessário. No entanto, volátil é desnecessário em processadores x86 e x64. Vou atualizar mais em breve.
dan
Se o bloqueio está de fato criando uma barreira de memória e se a barreira de memória é realmente sobre a ordem de instrução E a invalidação do cache, então ele deve funcionar em todos os processadores. De qualquer forma, é tão estranho que uma coisa tão básica cause tanta confusão ...
Konstantin
2
Esta resposta parece errada para mim. Se volatileera desnecessário em qualquer plataforma, em seguida, isso significaria o JIT não poderia otimizar as cargas de memória object s1 = syncRoot; object s2 = syncRoot;para object s1 = syncRoot; object s2 = s1;nessa plataforma. Isso parece muito improvável para mim.
user541686
1
Mesmo se o CLR não reordenasse as gravações (duvido que seja verdade, muitas otimizações muito boas podem ser feitas com isso), ainda haveria bugs, contanto que possamos embutir a chamada do construtor e criar o objeto no local (podemos ver um objeto meio inicializado). Independente de qualquer modelo de memória usado pela CPU subjacente! De acordo com Eric Lippert, o CLR na Intel pelo menos apresenta um membarrier após os construtores que nega essa otimização, mas isso não é exigido pela especificação e eu não contaria com a mesma coisa acontecendo no ARM, por exemplo ..
Voo
34

Existe uma maneira de implementá-lo sem volatilecampo. Eu vou explicar isso ...

Acho que é o reordenamento do acesso à memória dentro do bloqueio que é perigoso, de modo que você pode obter uma instância não totalmente inicializada fora do bloqueio. Para evitar isso, eu faço o seguinte:

public sealed class Singleton
{
   private static Singleton instance;
   private static object syncRoot = new Object();

   private Singleton() {}

   public static Singleton Instance
   {
      get 
      {
         // very fast test, without implicit memory barriers or locks
         if (instance == null)
         {
            lock (syncRoot)
            {
               if (instance == null)
               {
                    var temp = new Singleton();

                    // ensures that the instance is well initialized,
                    // and only then, it assigns the static variable.
                    System.Threading.Thread.MemoryBarrier();
                    instance = temp;
               }
            }
         }

         return instance;
      }
   }
}

Compreender o código

Imagine que haja algum código de inicialização dentro do construtor da classe Singleton. Se essas instruções forem reordenadas depois que o campo for definido com o endereço do novo objeto, você terá uma instância incompleta ... imagine que a classe tem este código:

private int _value;
public int Value { get { return this._value; } }

private Singleton()
{
    this._value = 1;
}

Agora imagine uma chamada para o construtor usando o novo operador:

instance = new Singleton();

Isso pode ser expandido para estas operações:

ptr = allocate memory for Singleton;
set ptr._value to 1;
set Singleton.instance to ptr;

E se eu reordenar essas instruções assim:

ptr = allocate memory for Singleton;
set Singleton.instance to ptr;
set ptr._value to 1;

Isso faz diferença? NÃO se você pensar em um único tópico. SIM, se você pensar em vários tópicos ... e se o tópico for interrompido logo após set instance to ptr:

ptr = allocate memory for Singleton;
set Singleton.instance to ptr;
-- thread interruped here, this can happen inside a lock --
set ptr._value to 1; -- Singleton.instance is not completelly initialized

Isso é o que a barreira da memória evita, ao não permitir o reordenamento do acesso à memória:

ptr = allocate memory for Singleton;
set temp to ptr; // temp is a local variable (that is important)
set ptr._value to 1;
-- memory barrier... cannot reorder writes after this point, or reads before it --
-- Singleton.instance is still null --
set Singleton.instance to temp;

Boa codificação!

Miguel Angelo
fonte
1
Se o CLR permite acesso a um objeto antes de ser inicializado, isso é uma brecha de segurança. Imagine uma classe privilegiada cujo único construtor público define "SecureMode = 1" e cujos métodos de instância verificam isso. Se você pode chamar esses métodos de instância sem a execução do construtor, você pode sair do modelo de segurança e violar o sandbox.
MichaelGG
1
@MichaelGG: no caso que você descreveu, se essa classe vai suportar vários threads para acessá-la, então é um problema. Se a chamada do construtor é alinhada pelo jitter, então é possível para a CPU reordenar as instruções de forma que a referência armazenada aponte para uma instância não completamente inicializada. Este não é um problema de segurança do CLR porque é evitável, é responsabilidade do programador utilizar: intertravados, barreiras de memória, travamentos e / ou campos voláteis, dentro do construtor de tal classe.
Miguel Angelo
2
Uma barreira dentro do ctor não o corrige. Se o CLR atribui a referência ao objeto recém-alocado antes que o ctor seja concluído e não insere um membarrier, outro encadeamento pode executar um método de instância em um objeto semi-inicializado.
MichaelGG
Este é o "padrão alternativo" que o ReSharper 2016/2017 sugere no caso de um DCL em C #. OTOH, Java faz garantia de que o resultado da newestá totalmente inicializado ..
user2864740
Eu sei que a implementação do MS .net coloca uma barreira de memória no final do construtor ... mas é melhor prevenir do que remediar.
Miguel Angelo
7

Acho que ninguém realmente respondeu à pergunta , então vou tentar.

O volátil e o primeiro if (instance == null)não são "necessários". O bloqueio tornará este código seguro para threads.

Portanto, a questão é: por que você adicionaria o primeiro if (instance == null)?

A razão é presumivelmente para evitar a execução desnecessária da seção bloqueada do código. Enquanto você está executando o código dentro do bloqueio, qualquer outro thread que tente também executar esse código é bloqueado, o que tornará seu programa lento se você tentar acessar o singleton com freqüência de muitos threads. Dependendo do idioma / plataforma, também pode haver sobrecargas do próprio bloqueio que você deseja evitar.

Portanto, a primeira verificação de nulo é adicionada como uma maneira realmente rápida de ver se você precisa do bloqueio. Se você não precisa criar o singleton, pode evitar o bloqueio totalmente.

Mas você não pode verificar se a referência é nula sem bloqueá-la de alguma forma, porque devido ao cache do processador, outra thread poderia alterá-la e você leria um valor "obsoleto" que levaria você a inserir o bloqueio desnecessariamente. Mas você está tentando evitar um bloqueio!

Portanto, você torna o singleton volátil para garantir a leitura do valor mais recente, sem a necessidade de usar um bloqueio.

Você ainda precisa do bloqueio interno porque o volátil o protege apenas durante um único acesso à variável - você não pode testá-lo e configurá-lo com segurança sem usar um bloqueio.

Agora, isso é realmente útil?

Bem, eu diria "na maioria dos casos, não".

Se Singleton.Instance pode causar ineficiência devido aos bloqueios, por que você está ligando para ele com tanta frequência que isso seria um problema significativo ? O ponto principal de um singleton é que há apenas um, então seu código pode ler e armazenar em cache a referência do singleton uma vez.

O único caso em que consigo pensar em que esse armazenamento em cache não seria possível seria quando você tivesse um grande número de threads (por exemplo, um servidor usando um novo thread para processar cada solicitação poderia estar criando milhões de threads de execução muito curta, cada um que teria que chamar Singleton.Instance uma vez).

Então, eu suspeito que o bloqueio de dupla verificação é um mecanismo que tem um lugar real em casos muito específicos de desempenho crítico, e então todo mundo subiu no movimento "esta é a maneira correta de fazer isso" sem realmente pensar o que ele faz e se será realmente necessário caso eles o estejam usando.

Jason Williams
fonte
6
Isso está entre errado e perder o ponto. volatilenão tem nada a ver com a semântica de bloqueio no bloqueio de verificação dupla, tem a ver com o modelo de memória e coerência do cache. Seu objetivo é garantir que um thread não receba um valor que ainda está sendo inicializado por outro thread, o que o padrão de bloqueio de verificação dupla não impede inerentemente. Em Java, você definitivamente precisa da volatilepalavra - chave; no .NET é obscuro, porque está errado de acordo com ECMA, mas certo de acordo com o tempo de execução. De qualquer forma, o lockdefinitivamente não cuida disso.
Aaronaught
Hã? Não consigo ver onde sua declaração discorda do que eu disse, nem disse que o volátil está de alguma forma relacionado à semântica de bloqueio.
Jason Williams
6
Sua resposta, como várias outras declarações neste tópico, afirma que locktorna o código seguro para o segmento. Essa parte é verdade, mas o padrão de bloqueio de verificação dupla pode torná-lo inseguro . É isso que você parece estar perdendo. Esta resposta parece vagar sobre o significado e o propósito de um bloqueio de verificação dupla sem nunca abordar os problemas de segurança de thread que são o motivo volatile.
Aaronaught
1
Como pode ser inseguro se instanceestiver marcado com volatile?
UserControl de
5

Você deve usar volatile com o padrão de bloqueio de verificação dupla.

A maioria das pessoas aponta para este artigo como prova de que você não precisa de voláteis: https://msdn.microsoft.com/en-us/magazine/cc163715.aspx#S10

Mas eles falham em ler até o fim: " Uma palavra final de advertência - estou apenas supondo o modelo de memória x86 a partir do comportamento observado nos processadores existentes. Portanto, as técnicas de baixo bloqueio também são frágeis porque o hardware e os compiladores podem ficar mais agressivos com o tempo . Aqui estão algumas estratégias para minimizar o impacto dessa fragilidade em seu código. Primeiro, sempre que possível, evite técnicas de low-lock. (...) Por fim, assuma o modelo de memória mais fraco possível, usando declarações voláteis em vez de confiar em garantias implícitas . "

Se você precisar de mais convencimento, leia este artigo sobre a especificação ECMA que será usada para outras plataformas: msdn.microsoft.com/en-us/magazine/jj863136.aspx

Se você precisar de mais convicção, leia este artigo mais recente de que otimizações podem ser feitas para evitar que ele funcione sem voláteis: msdn.microsoft.com/en-us/magazine/jj883956.aspx

Em resumo, ele "pode" funcionar para você sem voláteis no momento, mas não se arrisque a escrever o código adequado e usar os métodos volátil ou de leitura / gravação volátil. Artigos que sugerem fazer o contrário às vezes estão deixando de fora alguns dos possíveis riscos de otimizações de JIT / compilador que podem afetar seu código, bem como otimizações futuras que podem acontecer e podem quebrar seu código. Além disso, conforme as premissas mencionadas no último artigo, as premissas anteriores de trabalhar sem voláteis podem não se manter no ARM.

user2685937
fonte
1
Boa resposta. A única resposta certa para essa pergunta é simplesmente "Não". De acordo com isso, a resposta aceita está errada.
Dennis Kassel
3

AFAIK (e - tome cuidado, não estou fazendo muitas coisas simultâneas) não. O bloqueio fornece apenas a sincronização entre vários contendores (threads).

volátil, por outro lado, diz à sua máquina para reavaliar o valor todas as vezes, para que você não tropece em um valor armazenado em cache (e errado).

Consulte http://msdn.microsoft.com/en-us/library/ms998558.aspx e observe a seguinte citação:

Além disso, a variável é declarada volátil para garantir que a atribuição à variável de instância seja concluída antes que a variável de instância possa ser acessada.

Uma descrição de volátil: http://msdn.microsoft.com/en-us/library/x13ttww7%28VS.71%29.aspx

Benjamin Podszun
fonte
2
Um 'bloqueio' também fornece uma barreira de memória, o mesmo que (ou melhor do que) volátil.
Henk Holterman
2

Acho que encontrei o que procurava. Os detalhes estão neste artigo - http://msdn.microsoft.com/en-us/magazine/cc163715.aspx#S10 .

Para resumir - em .NET o modificador volátil não é realmente necessário nesta situação. No entanto, em modelos de memória mais fracos, as gravações feitas no construtor do objeto iniciado lentamente podem ser atrasadas após a gravação no campo, de modo que outros threads podem ler uma instância não nula corrompida na primeira instrução if.

Konstantin
fonte
1
No final desse artigo, leia com atenção, especialmente a última frase, o autor afirma: "Uma palavra final de aviso - estou apenas supondo o modelo de memória x86 a partir do comportamento observado em processadores existentes. Assim, as técnicas de baixo bloqueio também são frágeis porque hardware e compiladores podem ficar mais agressivos com o tempo. Aqui estão algumas estratégias para minimizar o impacto dessa fragilidade em seu código. Em primeiro lugar, sempre que possível, evite técnicas de baixo bloqueio. (...) Por fim, assuma o modelo de memória mais fraco possível, usando declarações voláteis em vez de confiar em garantias implícitas. "
user2685937
1
Se precisar de mais convicção, leia este artigo sobre a especificação ECMA que será usada para outras plataformas: msdn.microsoft.com/en-us/magazine/jj863136.aspx Se precisar de mais convicção, leia este artigo mais recente que otimizações podem ser colocadas que o impede de funcionar sem voláteis: msdn.microsoft.com/en-us/magazine/jj883956.aspx Em resumo, "pode" funcionar para você sem voláteis no momento, mas não se arrisque a escrever o código adequado e usar volatile ou volatileread / write.
user2685937
1

O locké suficiente. A própria especificação de linguagem MS (3.0) menciona este cenário exato em §8.12, sem qualquer menção de volatile:

Uma abordagem melhor é sincronizar o acesso a dados estáticos bloqueando um objeto estático privado. Por exemplo:

class Cache
{
    private static object synchronizationObject = new object();
    public static void Add(object x) {
        lock (Cache.synchronizationObject) {
          ...
        }
    }
    public static void Remove(object x) {
        lock (Cache.synchronizationObject) {
          ...
        }
    }
}
Marc Gravell
fonte
Jon Skeet em seu artigo ( yoda.arachsys.com/csharp/singleton.html ) diz que o volátil é necessário para a barreira de memória adequada neste caso. Marc, você pode comentar sobre isso?
Konstantin
Ah, eu não tinha notado a fechadura verificada duas vezes; simplesmente: não faça isso ;-p
Marc Gravell
Na verdade, acho que o bloqueio verificado duas vezes é uma coisa boa em termos de desempenho. Além disso, se for necessário tornar um campo volátil, enquanto ele é acessado dentro da fechadura, a fechadura com duas faces não é muito pior do que qualquer outra fechadura ...
Konstantin
Mas é tão bom quanto a abordagem de classe separada que Jon menciona?
Marc Gravell
-3

Esta é uma postagem muito boa sobre o uso de volatile com bloqueio de verificação dupla:

http://tech.puredanger.com/2007/06/15/double-checked-locking/

Em Java, se o objetivo é proteger uma variável, você não precisa bloquear se ela estiver marcada como volátil

Mark Pope
fonte
3
Interessante, mas não necessariamente muito útil. O modelo de memória da JVM e o modelo de memória do CLR não são a mesma coisa.
bcat