Clean Code (Código limpo) – Concorrência

Escrever programas com concorrência e que seja limpos é difícil – muito difícil. É muito mais fácil escrever código que seja executado em uma única thread. Também é fácil escrever código multithread que parece bom na superfície, mas está quebrado em um nível mais profundo. Esse código vai funcionar bem até que o sistema seja colocado sob pressão.

Por que utilizar simultaneidade?

A simultaneidade é uma estratégia de desacoplamento. Isso nos ajuda a separar o ‘que’ é feito de ‘quando’ é feito. Em aplicativos de thread única, o ‘que’ e ‘quando’ estão tão fortemente acoplados que o estado de todo o aplicativo pode ser determinado observando-se o backtrace da pilha de execução.

Separar o ‘que’ do ‘quando’ pode melhorar drasticamente o rendimento e as estruturas de uma aplicação. De um ponto de vista estrutural, a aplicação usando simultaneidade se parece com muitos pequenos computadores colaborativos, em vez de um grande loop principal. Isso pode tornar o sistema mais fácil de entender e oferece algumas maneiras poderosas de separar interesses.

Considere um agregador de informações que usa somente uma thread e que adquire informações de muitos sites diferentes mesclando-as em um resumo diário. Como esse sistema é de thread única, ele acessa cada site por vez, sempre terminando uma requisição antes de iniciar o próximo. A execução diária precisa ser executada em menos de 24 horas. No entanto, à medida que mais e mais sites são adicionados, o tempo aumenta até levar mais de 24 horas para reunir todos os dados. Esta abordagem consiste em muita espera dos soquetes da Web para que a E/S seja concluída. Poderíamos melhorar o desempenho usando um algoritmo multithread que acessa mais de um site por vez.

Ou considere um sistema que interpreta grandes conjuntos de dados, mas só pode fornecer uma solução completa depois de processar todos eles. Talvez cada conjunto de dados possa ser processado em um computador diferente, de forma que muitos conjuntos de dados sejam processados em paralelo.

Mitos e Equívocos

Considere estes mitos e equívocos comuns:
• A simultaneidade sempre melhora o desempenho.
A simultaneidade às vezes pode melhorar o desempenho, mas apenas quando há muito tempo de espera que pode ser compartilhado entre várias threads ou vários processadores. Nenhuma das situações é trivial.

• O design não muda ao escrever programas com simultaneidade.
Na verdade, o projeto de um algoritmo concorrente pode ser muito diferente do projeto de um sistema de thread único. A separação entre o ‘que’ e ‘quando’ geralmente tem um grande efeito na estrutura do sistema.

• Entender os problemas de simultaneidade não é importante ao trabalhar com um contêiner, como um contêiner Web por exemplo.
Na verdade, é melhor você saber exatamente o que seu contêiner está fazendo e como se proteger contra os problemas de atualização simultânea e deadlock.

Aqui estão algumas frases de efeito mais equilibradas em relação à escrita de software simultâneo:
Multithread adiciona em alguma sobrecarga, tanto no desempenho quanto na escrita de código.
Multithread correta é complexa, mesmo para problemas simples.
• Os bugs de simultaneidade não costumam ser repetidos, por isso são frequentemente ignorados como únicos, em vez dos verdadeiros defeitos que são.
MultithreadPrincípios para defender o uso de Concorrência

O que se segue é uma série de princípios e técnicas para defender seus sistemas dos problemas de código simultâneo.

– Princípio de Responsabilidade Única

O SRP afirma que um determinado método/classe/componente deve ter um único motivo para mudar. O design usando simultaneidade é complexo o suficiente para ser uma razão para mudar por si só e, portanto, merece ser separado do resto do código
• O código relacionado à multithread tem seu próprio ciclo de vida de desenvolvimento, mudança e ajuste.
• O código relacionado à multithread tem seus próprios desafios, que são diferentes e geralmente mais difíceis do que o código não relacionado.
• O número de maneiras em que o código com multithread, escrito incorretamente pode falhar torna-o desafiador o suficiente sem a carga adicional do código ao seu redor.

Recomendação: mantenha todo o código relacionado à concorrência separado do restante.

– Limite o escopo dos dados

É importante restringir o número de seções críticas. Deve-se proteger estas seções críticas no código que usa um objeto compartilhado. Quanto mais lugares os dados compartilhados puderem ser atualizados, maior será a probabilidade de:
• Você se esquecerá de proteger um ou mais desses lugares, quebrando efetivamente todo o código que modifica os dados compartilhados.
• Haverá duplicação de esforços necessários para garantir que tudo seja protegido de forma eficaz (violação de DRY).
• Será difícil determinar a origem das falhas, que já são difíceis de localizar.

Recomendação: Leve a sério o encapsulamento de dados; limitar severamente o acesso de quaisquer dados que possam ser compartilhados.

– Use cópias de dados

Uma boa maneira de evitar o compartilhamento de dados é evitar o compartilhamento de dados em primeiro lugar. Em algumas situações, é possível copiar objetos e tratá-los como somente leitura. Em outros casos, pode ser possível copiar objetos, coletar resultados de várias threads nessas cópias e, em seguida, mesclar os resultados em um único local.

Se houver uma maneira fácil de evitar o compartilhamento de objetos, o código resultante terá muito menos probabilidade de causar problemas. E se você está preocupado com o custo de toda a criação de objetos extras, vale a pena experimentar para descobrir se isso é de fato um problema. No entanto, se o uso de cópias de objetos permite que o código evite a sincronização, a economia em evitar o bloqueio intrínseco será provavelmente compensado pela sobrecarga adicional de criação e coleta de lixo.

– As threads devem ser tão independentes quanto possível

Considere escrever seu código multithread de forma que cada thread exista em seu próprio mundo, sem compartilhar dados com qualquer outro recurso. Cada uma processa uma solicitação do cliente, com todos os dados necessários vindo de uma fonte não compartilhada e armazenados como variáveis locais. Isso faz com que cada um desses threads se comporte como se fosse o único thread no mundo e não haja requisitos de sincronização.

Recomendação: tente particionar dados em subconjuntos independentes que podem ser operados independentemente, possivelmente em processadores diferentes.

Conheça sua biblioteca e o que ela é capaz de fazer
No caso do C#, podemos encontrar a documentação no site da Microsoft. https://docs.microsoft.com/en-us/dotnet/api/system.threading.thread?view=net-5.0

Conheça seus modelos de execução

Existem várias maneiras diferentes de particionar o comportamento em uma aplicação multithread. Para discuti-las, precisamos entender algumas definições básicas.

Recursos vinculados: recursos de tamanho ou número fixo usados em um ambiente multithread. Os exemplos incluem conexões de banco de dados e buffers de leitura/gravação desde que sejam de tamanho fixo.

Exclusão mútua: apenas um thread pode acessar dados ou recursos compartilhados por vez.

Starvation: Uma thread ou grupo de threads é proibido de prosseguir por um tempo excessivamente longo ou para sempre. Por exemplo, sempre permitir que as threads de execução rápida passem primeiro pode prejudicar as threads de execução mais longa se sempre chegarem novas threads de execução rápida.

Deadlock: duas ou mais threads aguardando a conclusão um do outro. Cada thread possui um recurso que o a outra thread requer e nenhuma pode terminar até obter o outro recurso.

Livelock: threads em sincronia, cada um tentando fazer o trabalho, mas encontrando outro bloqueio no caminho. As threads continuam tentando progredir, mas não conseguem por um tempo excessivamente longo (ou para sempre).

Modelos de execução (links do artigo completo na wikipedia)

Producer-Consumer – https://en.wikipedia.org/wiki/Producer%E2%80%93consumer_problem
Readers-Writers – https://en.wikipedia.org/wiki/Readers%E2%80%93writers_problem
Dining Philosophers – https://en.wikipedia.org/wiki/Dining_philosophers_problem

Cuidado com as dependências entre métodos sincronizados

Dependências entre métodos sincronizados causam erros sutis no código multithread.
Recomendação: Evite usar mais de um método em um objeto compartilhado.
Haverá momentos em que você deverá usar mais de um método em um objeto compartilhado. Quando for esse o caso, existem três maneiras de tornar o código correto:

• Bloqueio baseado em cliente: faça com que o cliente bloqueie o servidor antes de chamar o primeiro método e certifique-se de que a extensão do bloqueio inclui o código que chama o último método.
• Bloqueio com base no servidor: dentro do servidor, crie um método que bloqueie o servidor, chame todos os métodos e, em seguida, desbloqueie. Faça com que o cliente chame o novo método.
• Servidor Adaptado: crie um intermediário que executa o bloqueio. Este é um exemplo de bloqueio baseado em servidor, em que o servidor original não pode ser alterado.

Mantenha as seções sincronizadas pequenas

Stream.Synchronized introduz um bloqueio. Todas as seções do código protegidas pelo mesmo bloqueio têm a garantia de ter apenas um thread em execução por meio delas a qualquer momento.

Os bloqueios são caros porque criam atrasos e aumentam a sobrecarga. Portanto, não queremos bagunçar nosso código com instruções sincronizadas. Por outro lado, seções críticas devem ser protegidas, então devemos projetar nosso código com o mínimo de seções críticas possíveis.

Escrever código de desligamento correto é difícil

O desligamento normal pode ser difícil de corrigir. Problemas comuns envolvem deadlock, com threads esperando por um sinal que nunca chega.

Por exemplo, imagine um sistema com uma thread pai que gera várias threads filhas e espera que todos terminem antes de liberar seus recursos e encerrar. E se uma das threads geradas entrar em conflito? O pai vai esperar para sempre e o sistema nunca será desligado.

Situações como essa não são incomuns. Portanto, se você deve escrever código multithread que envolva desligamento normal, e espere gastar muito tempo fazendo com que o desligamento ocorra corretamente.

Testando código multithread

Escreva testes que tenham o potencial de expor problemas e, em seguida, execute-os com frequência, com diferentes configurações de programação e configurações de sistema e carga. Se os testes falharem, rastreie a falha.

Lembrete: Não ignore uma falha apenas porque os testes passaram em uma execução subsequente.

Sempre tratar falhas hipotéticas como possíveis problemas de multithread.
Bugs no código multithread podem exibir seus sintomas uma vez a cada mil ou um milhão de execuções. As tentativas de repetir os sistemas podem ser frustrantes. Isso geralmente leva os desenvolvedores a descrever a falha como um raio cósmico, uma falha de hardware ou algum outro tipo de “único”.

Recomendação: Não ignore as falhas do sistema como ocorrências pontuais.

– Torne seu código multithread plugável

Escreva o código de suporte à simultaneidade de forma que possa ser executado em várias configurações:
• Uma thread, várias threads, variados à medida que executa
• O código multithread interage com algo que pode ser real ou um Mock de teste.
• Execute com Mock de teste que executam rápido, lento e variável.
• Configure os testes para que possam ser executados em várias iterações.

Recomendação: Torne seu código baseado em thread especialmente plugável para que você possa executá-lo em várias configurações.

– Torne seu código multithread ajustável

Obter o equilíbrio certo de threads normalmente requer uma tentativa de erro. No início, encontre maneiras de cronometrar o desempenho do seu sistema em diferentes configurações. Permita também que o número de threads seja facilmente ajustado e considere permitir que ele mude enquanto o sistema está funcionando.

– Executar com mais threads do que processadores

Coisas acontecem quando o sistema alterna entre tarefas. Para encorajar a troca de tarefas, execute com mais threads do que processadores ou núcleos. Quanto mais frequentemente suas tarefas trocam, maior a probabilidade de você encontrar um código que está faltando uma seção crítica ou que causa um impasse.

– Executar em diferentes plataformas

Recomendação: execute seu código multithread em todas as plataformas de destino com antecedência e com frequência. É comum software ser desenvolvido usando Windows mas rodar em servidores Linux por exemplo.

– Instrua seu código para tentar e forçar falhas

A razão pela qual threading bugs podem ser raros, esporádicos e difíceis de repetir, é que apenas alguns poucos caminhos dos muitos milhares de caminhos possíveis através de uma seção vulnerável realmente falham. Portanto, a probabilidade de que um caminho de falha seja seguido pode ser surpreendentemente baixa. Isso torna a detecção e a depuração muito difíceis.

Conclusão

O código multithread é difícil de acertar. Um código que era simples de seguir pode se tornar um pesadelo quando várias threads e dados compartilhados entram na mistura. Se você se depara com a escrita de multithread, vai precisar escrever um código limpo com rigor ou então enfrentará falhas sutis e raras.

Em primeiro lugar, siga o Princípio da Responsabilidade Única. Divida seu sistema em partes que separam o código multithread do código que não sabe o que é thread. Certifique-se de que, ao testar seu código com threads, esteja apenas testando esta parte e nada mais. Isso sugere que seu código com reconhecimento de thread deve ser pequeno e focado.

Conheça as possíveis fontes de problemas de simultaneidade: várias threads operando em dados compartilhados ou usando um pool de recursos comum. Casos de limite, como desligamento limpo ou finalização da iteração de um loop, podem ser especialmente espinhosos.

– Aprenda sua biblioteca e conheça os algoritmos fundamentais.
– Aprenda como encontrar regiões de código que devem ser bloqueadas e bloqueá-las.

A testabilidade, que vem naturalmente de seguir as Três Leis do TDD, implica algum nível de capacidade de plug-in, que oferece o suporte necessário para executar o código em uma gama mais ampla de configurações.

Se você adotar uma abordagem limpa, suas chances de acertar aumentam drasticamente.

Qualquer dúvida ou dicas, entre em contato: leandrolt@gmail.com

Referência
– Clean Code: A Handbook of Agile Software Craftsmanship (English Edition) – Robert C. Martin – Capítulo 13

Leave a Reply

Your email address will not be published. Required fields are marked *