Resposta ao Desafio da Semana #13 [Performance/Hang - Sincronização de Threads em C# e VB.NET]

Por: Roberto Alexis Farah

Olá pessoal! Eis a Resposta ao Desafio da Semana #13.

PROBLEMA

Note o fragmento em vermelho do código abaixo:

using System;

using System.Collections.Generic;

using System.Text;

using System.Threading;

namespace ThreadBug

{

          public class Bacteria

          {

                   private static int _bacteriaCount;

                  

                   private static void DoSomething()

                   {

                             Console.WriteLine("DoSomething chamado por {0} no momento {1}",

                                                                   Thread.CurrentThread.ManagedThreadId,

                                                                   DateTime.Now.ToLongTimeString());

                             // Assuma que o método poderia fazer outra operação que não incrementar uma variável!

                             _bacteriaCount++;

                            

                             Thread.Sleep(3000);

                   }

                  

                   public Bacteria()

                   {

                             lock(this)

                             {

                                      DoSomething();

                             }

                   }

          }

         

          public class SpecializedBacteria : Bacteria

          {

          }

         

         

          class Program

          {

                   private static void Worker1()

                   {

                             Console.WriteLine("Worker1 executando thread {0}",

                                                                   Thread.CurrentThread.ManagedThreadId);

                                                         

                             Bacteria bac = new Bacteria();

                   }

                   private static void Worker2()

                   {

                             Console.WriteLine("Worker2 executando thread {0}",

                                                                   Thread.CurrentThread.ManagedThreadId);

                             SpecializedBacteria bac = new SpecializedBacteria();

                   }

                  

                   [STAThread]

                   static void Main(string[] args)

                   {

                             Thread thread1 = new Thread(new ThreadStart(Worker1));

                             Thread thread2 = new Thread(new ThreadStart(Worker2));

                            

                             Console.WriteLine("Iniciando as threads em {0}",

                                                DateTime.Now.ToLongTimeString());

                             thread1.Start();

                             thread2.Start();

                            

                             Thread.Sleep(4000);

                   }

          }

}

Usando o lock(this) do código original pode parecer, a primeira vista, correto. Infelizmente está errado por isso as threads acessam o recurso ao mesmo tempo.

Logo, o problema aqui é a incorreta sincronização das threads ao acessar DoSomething().

Com a construção acima estamos fazendo um lock em diferentes instâncias do objeto Bacteria.

Agora vamos fazer uma pequena alteração no lock:

public Bacteria()

{

          lock(GetType()) ß Continua errado…

          {

                   DoSomething();

          }

}

Ao executar a aplicação notamos o mesmo sintoma. O acesso continua sendo feito ao mesmo tempo.

Isso ocorre porque thread1 chama Worker1() que cria uma instancia de Bacteria. Ao mesmo tempo, thread2 cria uma instancia de SpecializedBacteria. Entretanto, o construtor de Bacteria é chamado ao se criar uma instancia de SpecializedBacteria.

Dentro do construtor GetType() retorna o Type de SpecializedBacteria não o de Bacteria, ou seja, novamente as threads estão fazendo o lock em instâncias diferentes e acessando o método DoSomething() ao mesmo tempo.

Para se comprovar isso não é necessária nenhuma depuração avançada de WinDbg (de fato, raramente isso é necessario, tenho usado o WinDbg apenas para mostrar com mais detalhes a relação de causa e efeito).

Com o Visual Studio 2005 se você colocar um breakpoint na linha do lock e ir para Command Window basta usar o comando ? para ver o resultado:

>? GetType() ß Comando no momento do breakpoint.

{ThreadBug.Bacteria}

    [System.RuntimeType]: {ThreadBug.Bacteria}

    base {System.Reflection.MemberInfo}: {ThreadBug.Bacteria}

    Assembly: {ThreadBug, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null}

    AssemblyQualifiedName: "ThreadBug.Bacteria, ThreadBug, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"

    Attributes: Public | BeforeFieldInit

    (restante removido por causa do tamanho)

    

>? GetType() ß Comando no momento do breakpoint na segunda vez que é executado.

{ThreadBug.SpecializedBacteria}

    [System.RuntimeType]: {ThreadBug.SpecializedBacteria}

    base {System.Reflection.MemberInfo}: {ThreadBug.SpecializedBacteria}

    Assembly: {ThreadBug, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null}

    AssemblyQualifiedName: "ThreadBug.SpecializedBacteria, ThreadBug, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"

    Attributes: Public | BeforeFieldInit

    (restante removido por causa do tamanho)

Ok... vamos tentar outra solução:

public Bacteria()

{

lock (typeof(Bacteria)) ß Resolve o problema mas não do melhor modo…

          {

                   DoSomething();

          }

}

No lock usado acima notamos que a instância é a mesma, vejam:

>? typeof(Bacteria) ß Primeiro acesso do breakpoint.

{ThreadBug.Bacteria}

    [System.RuntimeType]: {ThreadBug.Bacteria}

    base {System.Reflection.MemberInfo}: {ThreadBug.Bacteria}

    Assembly: {ThreadBug, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null}

    AssemblyQualifiedName: "ThreadBug.Bacteria, ThreadBug, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"

    Attributes: Public | BeforeFieldInit

? typeof(Bacteria) ß Segundo acesso do breakpoint.

{ThreadBug.Bacteria}

    [System.RuntimeType]: {ThreadBug.Bacteria}

    base {System.Reflection.MemberInfo}: {ThreadBug.Bacteria}

    Assembly: {ThreadBug, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null}

    AssemblyQualifiedName: "ThreadBug.Bacteria, ThreadBug, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"

    Attributes: Public | BeforeFieldInit

Portanto, há uma thread por vez acessando o recurso e não há mais acesso simultâneo, como queríamos! Mas...

Enquanto a solução funciona para esse exemplo específico, se houvessem mais métodos estáticos chamados por Main() sincronizando no tipo de metadata como acima então o lock externo ía interferir no lock interno, atrasando o tempo de instanciação do objeto.

Como exemplo, mude o código original para o código abaixo e teste:

[STAThread]

static void Main(string[] args)

{

          Thread thread1 = new Thread(new ThreadStart(Worker1));

          Thread thread2 = new Thread(new ThreadStart(Worker2));

         

          lock(typeof(Bacteria)) ß Sincroniza no tipo de metadata…

          {

                   Console.WriteLine("Iniciando as threads em {0}",

                                                          DateTime.Now.ToLongTimeString());

                   thread1.Start();

                   thread2.Start();

                                     

                   Thread.Sleep(4000);

          }

}

Executando a aplicação com essa mudança notamos que há uma demora desnecessária porque o método Main() faz o lock do tipo Bacteria por alguns segundos, então a criação dos objetos é atrasada até Main() liberar o lock.

Note que um cliente da classe Bacteria poderia usar um lock desse tipo usado em Main() para chamar múltiplos métodos estáticos de modo sincronizado, então se Main() ou qualquer outro método da aplicação apropriar o lock (via o tipo do metadata) a execução do construtor é atrasada!

Arrumamos o problema mas não do melhor modo, pois acabamos por criar outro discreto problema que poderia se manifestar no cenário descrito acima. Isso ocorre porque o método estático e a implementação do construtor são privados para a classe mas usamos um objeto com visibilidade pública (o metadata de Bacteria) para garantir o acesso sincronizado!

Essa abordagem sacrifica a concorrência desnecessariamente. Poderíamos usar um lock mais localizado, com escopo menor.

Agora vamos a solução definitiva...

SOLUÇÃO

Na descrição do cenário mencionei que a solução deve funcionar mesmo se não houver um incremento de variável, mas outro tipo de processamento.

Sendo assim, utilizar Interlocked.Increment() funcionaria perfeitamente se o objetivo fosse apenas incrementar a variável.

Entretanto, pensando que outro tipo de processamento poderia ser efetuado ao invés da soma temos:

using System;

using System.Collections.Generic;

using System.Text;

using System.Threading;

namespace ThreadBug

{

          public class Bacteria

          {

                   private static int _bacteriaCount;

                   private static object _dummyObj = new Object(); ß Criamos um objeto estático que nada faz.

                  

                   private static void DoSomething()

                   {

                             Console.WriteLine("DoSomething chamado por {0} no momento {1}",

                                                                   Thread.CurrentThread.ManagedThreadId,

                                                                   DateTime.Now.ToLongTimeString());

                             // Assuma que o método poderia fazer outra operação que não incrementar uma variável!

                             _bacteriaCount++;

                                                         

                             Thread.Sleep(3000);

                   }

                            

                   public Bacteria()

                   {

                             lock(_dummyObj) ) ß Fazemos o lock na instância do objeto estático.

                             {

                                      DoSomething();

                             }

                   }

          }

         

          public class SpecializedBacteria : Bacteria

          {

          }

         

         

          class Program

          {

                   private static void Worker1()

                   {

                             Console.WriteLine("Worker1 executando thread {0}",

                                                                   Thread.CurrentThread.ManagedThreadId);

                                                                            

                             Bacteria bac = new Bacteria();

                   }

                   private static void Worker2()

                   {

                             Console.WriteLine("Worker2 executando thread {0}",

                                                                   Thread.CurrentThread.ManagedThreadId);

                             SpecializedBacteria bac = new SpecializedBacteria();

                   }

                  

                   [STAThread]

                   static void Main(string[] args)

                   {

                             Thread thread1 = new Thread(new ThreadStart(Worker1));

                             Thread thread2 = new Thread(new ThreadStart(Worker2));

                            

                             Console.WriteLine("Iniciando as threads em {0}",

                                                                             DateTime.Now.ToLongTimeString());

                             thread1.Start();

                             thread2.Start();

                                     

                             Thread.Sleep(4000);

                   }

          }

}

Essa solução usa um objeto dummy, ou seja, um objeto que nada faz e que é criado na classe Bacteria.

No construtor fazemos um lock nesse objeto e, como o objeto é private, não pode ser usado de fora da classe para fazer um lock, como fizemos com o lock anterior.

Com essa abordagem temos a seguinte vantagem:

- Código em outras classes não afeta a concorrência no construtor porque o objeto _dummyObj não tem visibilidade pública. Logo, esse tipo de lock demonstrado abaixo não atrasa a instanciação do objeto porque não interfere com o tipo de lock sendo usado internamente pela classe:

[STAThread]

static void Main(string[] args)

{

          Thread thread1 = new Thread(new ThreadStart(Worker1));

          Thread thread2 = new Thread(new ThreadStart(Worker2));

                            

          lock(typeof(Bacteria))

          {

                   Console.WriteLine("Iniciando as threads em {0}",

                                      DateTime.Now.ToLongTimeString());

                   thread1.Start();

                   thread2.Start();

                                               

                   Thread.Sleep(4000);

          }

}

Referências:

https://www.amazon.com/NET-Gotchas-Venkat-Subramaniam/dp/0596009097

Performance-Conscious Thread Synchronization

https://msdn.microsoft.com/msdnmag/issues/05/10/ConcurrentAffairs/