Fevereiro de 2019

Volume 34 - Número 2

[C#]

Minimizar a complexidade no código C# com vários threads

Thomas Hansen | Fevereiro de 2019

Bifurcações, ou programação com vários threads, estão entre as coisas mais difíceis de acertar ao programar. Isso é devido à sua natureza paralela, que exige uma mentalidade completamente diferente da programação linear com um único thread. Uma boa analogia para o problema é um malabarista, que deve manter várias bolas no ar sem que elas interfiram umas com as outras. É um grande desafio. No entanto, com as ferramentas certas e a mentalidade correta, é possível.

Neste artigo, detalho algumas ferramentas que criei para simplificar a programação com vários threads e evitar problemas, como condições de corrida, deadlocks e outros. A cadeia de ferramentas se baseia, possivelmente, em uma sintática açucarada e representantes mágicos. No entanto, para citar o excelente músico de jazz Miles Davis: “Na música, o silêncio é mais importante do que o som”. A mágica acontece entre os ruídos.

Em outras palavras, não é necessariamente o que você pode codificar, mas o que opta por não fazer, porque prefere criar um pouco de mágica nas entrelinhas. Uma citação de Bill Gates vem à mente: “Medir a qualidade do trabalho de acordo com o número de linhas de código, é como medir a qualidade do avião por seu peso”. Portanto, ao invés de ensinar como codificar mais, espero ajudá-lo a codificar menos.

O desafio da sincronização

O primeiro problema que você encontrará na programação com vários threads é sincronizar o acesso a um recurso compartilhado. Os problemas ocorrem quando dois ou mais threads compartilham o acesso a um objeto, e ambos tentam modificá-lo ao mesmo tempo. Quando o C# foi lançado pela primeira vez, a instrução lock implementou uma forma básica de garantir que apenas um thread pudesse acessar um recurso específico, como um arquivo de dados, o que funcionou bem. A palavra-chave lock no C# é tão fácil de entender que, sozinha, revolucionou como pensamos sobre esse problema.

No entanto, um bloqueio (lock) simples tem uma falha: Não discrimina o acesso a somente leitura do acesso a gravação. Por exemplo, você pode ter 10 threads diferentes que desejam ler um objeto compartilhado e esses threads podem receber acesso simultâneo à sua instância sem causar problemas por meio da classe ReaderWriterLockSlim no namespace System.Threading. Ao contrário da instrução lock, essa classe permite especificar se o seu código está gravando no objeto ou simplesmente lendo o objeto. Isso permite a entrada de vários leitores ao mesmo tempo, mas nega qualquer acesso ao código de gravação até que todos os outros threads de leitura e gravação tenham terminado de fazer suas coisas.

Agora, o problema: Quando consome a classe ReaderWriterLock, a sintaxe se torna chata, com muito código repetido que reduz a legibilidade e complica a manutenção ao longo do tempo; seu código geralmente fica espalhado com vários blocos try e finally. Um erro de digitação simples também pode ter efeitos desastrosos que, às vezes, são extremamente difíceis de identificar mais tarde. 

Encapsular o ReaderWriterLockSlim em uma classe simples pode resolver o problema imediatamente sem usar um código repetitivo, o que reduz o risco de um pequeno erro de digitação estragar seu dia. A classe, como mostrado na Figura 1, se baseia inteiramente em um truque com lambda. Sem dúvida, é apenas uma sintática mais agradável em torno de certos representantes, supondo a existência de algumas interfaces. O mais importante é que ele pode ajudar a tornar seu código muito mais DRY (ou “Don’t Repeat Yourself”).

Figura 1 Encapsulando ReaderWriterLockSlim

public class Synchronizer<TImpl, TIRead, TIWrite> where TImpl : TIWrite, TIRead
{
  ReaderWriterLockSlim _lock = new ReaderWriterLockSlim();
  TImpl _shared;

  public Synchronizer(TImpl shared)
  {
    _shared = shared;
  }

  public void Read(Action<TIRead> functor)
  {
    _lock.EnterReadLock();
    try {
      functor(_shared);
    } finally {
      _lock.ExitReadLock();
    }
  }

  public void Write(Action<TIWrite> functor)
  {
    _lock.EnterWriteLock();
    try {
      functor(_shared);
    } finally {
      _lock.ExitWriteLock();
    }
  }
}

Há apenas 27 linhas de código na Figura 1, fornecendo uma maneira elegante e concisa de garantir que os objetos sejam sincronizados entre vários threads. A classe pressupõe que você tem uma interface de leitura e outra de gravação no seu tipo. Você também pode usá-la repetindo a própria classe modelo três vezes se, por alguma razão, não conseguir alterar a implementação da classe subjacente para a qual é necessário sincronizar o acesso. O uso básico pode ser algo como o mostrado na Figura 2.

Figura 2 Usando a classe Synchronizer

interface IReadFromShared
{
  string GetValue();
}

interface IWriteToShared
{
  void SetValue(string value);
}

class MySharedClass : IReadFromShared, IWriteToShared
{
  string _foo;

  public string GetValue()
  {
    return _foo;
  }

  public void SetValue(string value)
  {
    _foo = value;
  }
}

void Foo(Synchronizer<MySharedClass, IReadFromShared, IWriteToShared> sync)
{
  sync.Write(x => {
    x.SetValue("new value");
  });
  sync.Read(x => {
    Console.WriteLine(x.GetValue());
  })
}

No código da Figura 2, independentemente de quantos threads estão em execução no método Foo, nenhum método de Gravação será chamado, contanto que outro método de Leitura ou Gravação esteja sendo executado. No entanto, vários métodos de Leitura podem ser chamados simultaneamente, sem precisar espalhar seu código com várias instruções try/catch/finally nem repetir o mesmo código sempre. Aliás, consumi-lo com uma cadeia de caracteres simples não faz sentido, porque o System.String é imutável. Aqui, eu uso um objeto de cadeia de caracteres simples para simplificar o exemplo.

Basicamente, a ideia é que todos os métodos que podem modificar o estado de sua instância devem ser adicionados à interface IWriteToShared. Ao mesmo tempo, todos os métodos lidos somente a partir de sua instância devem ser adicionados à interface IReadFromShared. Separando seus interesses em duas interfaces distintas e implementando ambas as interfaces em seu tipo subjacente, você poderá usar a classe Synchronizer para sincronizar o acesso à sua instância. Desse modo, a arte de sincronizar o acesso ao seu código fica muito mais simples e você pode fazer isso em grande parte de uma maneira muito mais declarativa.

Quando se trata de uma programação com vários threads, a sintática açucarada poderá ser a diferença entre o sucesso e falha. Depurar um código com vários threads normalmente é muito difícil e criar testes de unidade para objetos de sincronização pode ser inútil.

Se quiser, você poderá criar um tipo sobrecarregado com apenas um argumento genérico, herdando da classe Synchronizer original e passando três vezes seu único argumento genérico como o argumento de tipo para sua classe base. Dessa forma, você não precisará das interfaces de leitura ou gravação, uma vez que basta usar a implementação concreta do seu tipo. No entanto, essa abordagem requer que você cuide manualmente das partes que precisam usar o método de Gravação ou Leitura. Também é um pouco menos seguro, mas permite encapsular classes que você não pode alterar para uma instância Synchronizer.

Coleções lambda para suas bifurcações

Depois de ter executado as primeiras etapas para a mágica com lambdas (ou representantes, como são chamados no C#), não é difícil imaginar o que se pode fazer mais com elas. Por exemplo, um tema comum e recorrente no multithreading é fazer com que vários threads acessem outros servidores para buscar dados e retorná-los ao chamador.

O exemplo mais básico seria um aplicativo que lê dados de 20 páginas da Web e quando termina, retorna o HTML para um único thread que cria um tipo de resultado agregado com base no conteúdo de todas as páginas. A menos que você crie um thread para cada um dos métodos de recuperação, esse código será muito mais lento do que o desejado; 99% de todo o tempo de execução provavelmente seria gasto aguardando a solicitação HTTP para retornar.

Executar esse código em um único thread é ineficiente e a sintaxe para criar um thread é difícil de acertar. O desafio aumenta quando você dá suporte a vários threads e seus objetos assistentes, forçando os desenvolvedores a se repetirem conforme escrevem o código. Depois de perceber que você pode criar uma coleção de representantes e uma classe para encapsulá-los, é possível criar todos os seus threads com uma única chamada do método. Desse modo, criar threads fica muito menos complicado.

Na Figura 3, você encontrará um trecho de código que cria duas lambdas executadas em paralelo. Observe que esse código é, na verdade, dos testes de unidade de minha primeira versão da linguagem de script Lizzie, que pode ser encontrada em bit.ly/2FfH5y8.

Figura 3 Criando Lambdas

public void ExecuteParallel_1()
{
  var sync = new Synchronizer<string, string, string>("initial_");

  var actions = new Actions();
  actions.Add(() => sync.Assign((res) => res + "foo"));
  actions.Add(() => sync.Assign((res) => res + "bar"));

  actions.ExecuteParallel();

  string result = null;
  sync.Read(delegate (string val) { result = val; });
  Assert.AreEqual(true, "initial_foobar" == result || result == "initial_barfoo");
}

Se você olhar com cuidado o código, notará que o resultado da avaliação não pressupõe que uma das lambdas esteja sendo executada antes da outra. A ordem de execução não é especificada explicitamente e essas lambdas estão sendo executadas em threads separados. Isso ocorre porque a classe Actions na Figura 3 permite adicionar representantes, para que você possa decidir depois se deseja executá-los em paralelo ou em sequência.

Para isso, você deve criar muitas lambdas e executá-las usando seu mecanismo preferido. Você pode ver a classe Synchronizer mencionada anteriormente na Figura 3, sincronizando o acesso para o recurso da cadeia de caracteres compartilhado. No entanto, ela usa um novo método em Synchronizer, chamado Assign, não incluído na lista da Figura 1 para minha classe Synchronizer. O método Assign usa o mesmo “truque com lambda” descrito anteriormente nos métodos de Gravação e Leitura.

Se você quiser estudar a implementação da classe Actions, observe que é importante baixar a versão 0.1 do Lizzie, pois eu reescrevi completamente o código para que ele se torne uma linguagem de programação autônoma nas versões posteriores.

Programação Funcional no C#

A maioria dos desenvolvedores tende a pensar no C# como sendo quase um sinônimo, ou menos estreitamente relacionado, para OOP (programação orientada a objetos); é óbvio que é. No entanto, ao reconsiderar como você usa o C# e se aprofundar em seus aspectos funcionais, fica muito mais fácil resolver alguns problemas. A OOP, em sua forma atual, não é muito fácil de reutilizar e um bom motivo para isso é que ela é fortemente tipada.

Por exemplo, a reutilização de uma única classe força a reutilização de toda classe que a classe inicial referencia, ou seja, as usadas pela composição e pela herança. Além disso, a reutilização da classe força a reutilização de todas as classes que fazem referência a classes de terceiros, e assim por diante. E se essas classes forem implementadas em assemblies diferentes, você terá que incluir muitos assemblies só para ter acesso a um único método em um único tipo.

Certa vez, li uma analogia que ilustra esse problema: “Você deseja uma banana, mas acaba com um gorila segurando uma banana e a floresta tropical onde ele vive”. Compare essa situação com a reutilização em uma linguagem mais dinâmica, como o JavaScript, que não se preocupa com o tipo, desde que implemente as funções que suas próprias funções estão consumindo. Uma abordagem um pouco menos tipada produz um código mais flexível e reutilizado com maior facilidade. Os representantes permitem que você faça isso.

Você pode trabalhar com o C# de forma que melhora a reutilização do código em vários projetos. Só é preciso perceber que uma função ou um representante também pode ser um objeto e que você pode manipular as coleções desses objetos de um modo menos tipado.

As ideias a respeito dos representantes neste artigo se baseiam naquelas articuladas em um artigo anterior que escrevi, “Criar sua própria linguagem de script com representantes simbólicos”, na edição de novembro de 2018 da MSDN Magazine (msdn.com/magazine/mt830373). O artigo também introduziu Lizzie, minha linguagem de script caseira que deve sua existência a essa mentalidade centrada em representantes. Se eu tivesse criado a Lizzie usando as regras da OOP, minha opinião é que provavelmente ela teria, pelo menos, um tamanho maior.

Claro, a OOP e a forte tipagem têm uma posição dominante hoje, sendo praticamente impossível encontrar uma descrição de trabalho que não a mencione como sua principal habilidade requerida. Aliás, criei código OOP por mais de 25 anos, portanto, fui tão culpado como qualquer outra pessoa por ter uma tendência fortemente tipada. Hoje, no entanto, sou mais pragmático em minha abordagem de codificação e estou menos interessado em como acaba ficando minha hierarquia de classes.

Não quer dizer que não gosto de uma bela hierarquia de classes, mas os benefícios estão diminuindo. Quanto mais classes você adiciona a uma hierarquia, menos elegante ela fica, até sucumbir ao seu próprio peso. Às vezes, o design superior tem poucos métodos, menos classes e principalmente funções menos acopladas, permitindo que o código seja estendido facilmente, sem precisar “trazer o gorila e a floresta”.

Volto ao tema recorrente deste artigo, inspirado pela abordagem de música feita por Miles Davis, em que menos é mais e “o silêncio é mais importante do que o som”. Com os códigos também é assim. A mágica geralmente reside nas entrelinhas e as melhores soluções podem ser medidas mais pelo que você não codifica, não o contrário. Qualquer idiota pode soprar um trompete e fazer barulho, mas poucos conseguem tocar música com ele. E muito menos conseguem fazer mágica, como Miles fazia.


Thomas Hansen trabalha nos setores de Tecnologia Financeira e Câmbio como desenvolvedor de software e mora em Chipre.


Discuta esse artigo no fórum do MSDN Magazine