Originalmente, eu publiquei este artigo no blog CodingSight .
Também está disponível em russo aqui .Este artigo contém a segunda parte do meu discurso no meetup multithreading. Você pode dar uma olhada na primeira parte
aqui e
aqui . Na primeira parte, concentrei-me no conjunto básico de ferramentas usadas para iniciar um encadeamento ou uma tarefa, as maneiras de rastrear seu estado e algumas outras coisas interessantes, como o PLinq. Nesta parte, vou corrigir os problemas que você pode encontrar em um ambiente com vários threads e algumas maneiras de resolvê-los.
Conteúdo
Sobre recursos compartilhados
Você não pode escrever um programa cujo trabalho se baseie em vários threads sem ter recursos compartilhados. Mesmo que funcione no seu nível de abstração atual, você descobrirá que ele realmente compartilhou recursos assim que descer um ou mais níveis de abstração. Aqui estão alguns exemplos:
Exemplo 1:Para evitar possíveis problemas, faça com que os threads funcionem com arquivos diferentes, um arquivo para cada thread. Parece que o programa não possui recursos compartilhados.
Ao descer alguns níveis, você fica sabendo que existe apenas um disco rígido, e cabe ao driver ou ao sistema operacional encontrar uma solução para problemas com o acesso ao disco rígido.
Exemplo 2:Depois de ler o
exemplo 1 , você decidiu colocar os arquivos em duas máquinas remotas diferentes com hardware e sistemas operacionais fisicamente diferentes. Você também mantém duas conexões FTP ou NFS diferentes.
Ao descer alguns níveis novamente, você entende que nada realmente mudou e o problema do acesso competitivo agora está delegado no driver da placa de rede ou no sistema operacional da máquina em que o programa está sendo executado.
Exemplo 3:Depois de puxar a maior parte do seu cabelo durante as tentativas de provar que você pode escrever um programa multiencadeado, você decide abandonar os arquivos completamente e mover os cálculos para dois objetos diferentes, com os links para cada um dos objetos disponíveis apenas para suas especificidades. tópicos.
Para martelar a última dúzia de pregos no caixão dessa idéia: um tempo de execução e o Garbage Collector, um planejador de encadeamentos, uma fisicamente uma RAM unificada e um processador ainda são considerados recursos compartilhados.
Portanto, aprendemos que é impossível escrever um programa multiencadeado sem recursos compartilhados em todos os níveis de abstração e em todo o escopo da pilha de tecnologia. Felizmente, cada nível de abstração (como regra geral) cuida parcial ou totalmente dos problemas de acesso competitivo ou apenas o nega imediatamente (exemplo: qualquer estrutura de interface do usuário não permite trabalhar com elementos de diferentes threads). Normalmente, os problemas com recursos compartilhados aparecem no seu nível de abstração atual. Para cuidar deles, é introduzido o conceito de sincronização.
Possíveis problemas em ambientes multithread
Podemos classificar erros de software nas seguintes categorias:
- O programa não produz resultado - trava ou congela.
- O programa apresenta um resultado incorreto.
- O programa produz um resultado correto, mas não atende a alguns requisitos não relacionados à função - gasta muito tempo ou recursos.
Em ambientes multithread, os principais problemas que resultam nos erros 1 e 2 são o
impasse e a
condição de corrida .
Impasse
O impasse é um bloqueio mútuo. Existem muitas variações de um impasse. O seguinte pode ser considerado como o mais comum:

Enquanto o
Thread # 1 estava fazendo algo, o
Thread # 2 bloqueou o recurso
B. Algum tempo depois, o
thread nº 1 bloqueou o recurso
A e estava tentando bloquear o recurso B. infelizmente, isso nunca acontecerá porque o
thread nº 2 liberará o recurso
B apenas após o bloqueio do recurso
A.Condição de corrida
Condição de corrida é uma situação em que ambos, o comportamento e os resultados dos cálculos dependem do planejador de encadeamentos do ambiente de execução
O problema é que seu programa pode funcionar incorretamente uma vez em cem, ou mesmo em um milhão.
As coisas podem piorar quando os problemas ocorrem em três. Por exemplo, o comportamento específico do planejador de encadeamentos pode levar a um conflito mútuo.
Além desses dois problemas que levam a erros explícitos, também existem problemas que, se não levarem a resultados de cálculos incorretos, ainda podem levar o programa a levar muito mais tempo ou recursos para produzir o resultado desejado. Dois desses problemas são
Busy Wait e
Thread Starvation .
Espera ocupada
A espera ocupada é um problema que ocorre quando o programa gasta recursos do processador em espera e não em cálculo.
Normalmente, esse problema é semelhante ao seguinte:
while(!hasSomethingHappened) ;
Este é um exemplo de código extremamente ruim, pois ocupa totalmente um núcleo do seu processador sem realmente fazer nada produtivo. Esse código só pode ser justificado quando é extremamente importante processar rapidamente uma alteração de um valor em um thread diferente. E com 'rapidamente' quero dizer que você não pode esperar nem por alguns nanossegundos. Em todos os outros casos, ou seja, em todos os casos em que uma mente razoável possa surgir, é muito mais conveniente usar as variações do ResetEvent e suas versões Slim. Falaremos sobre eles um pouco mais tarde.
Provavelmente, alguns leitores sugeririam resolver o problema de um núcleo estar totalmente ocupado com a espera adicionando Thread.Sleep (1) (ou algo semelhante) ao ciclo. Embora ele resolva esse problema, um novo será criado - o tempo necessário para reagir às alterações será de 0,5 ms, em média. Por um lado, não é muito, mas por outro lado, esse valor é catastroficamente mais alto do que o que podemos alcançar usando primitivas de sincronização da família ResetEvent.
Inanição de thread
Ausência de Thread é um problema com o programa com muitos threads em operação simultânea. Aqui, estamos falando especificamente sobre os segmentos ocupados com o cálculo, e não com a espera de uma resposta de algum pedido de veiculação. Com esse problema, perdemos os possíveis benefícios de desempenho que acompanham os threads porque o processador gasta muito tempo na alternância de contextos.
Você pode encontrar esses problemas usando vários criadores de perfil. A seguir, é apresentada uma captura de tela do profiler
dotTrace que funciona no modo Linha de tempo
(clique para ampliar).Normalmente, os programas que não sofrem com a inanição do encadeamento não possuem nenhuma seção rosa nos gráficos que representam os encadeamentos. Além disso, na categoria Subsistemas, podemos ver que o programa estava aguardando CPU por 30,6% do tempo.
Quando esse problema é diagnosticado, você pode resolvê-lo de maneira simples: você iniciou muitos threads de uma só vez, portanto, inicie menos threads.
Métodos de sincronização
Intertravado
Este é provavelmente o método de sincronização mais leve. Intertravado é um conjunto de operações atômicas simples. Quando uma operação atômica está sendo executada, nada pode acontecer. No .NET, Interlocked é representado pela classe estática de mesmo nome com uma seleção de métodos, cada um deles implementando uma operação atômica.
Para perceber o horror final das operações não atômicas, tente escrever um programa que inicie 10 threads, cada um deles incrementando a mesma variável um milhão de vezes. Quando eles terminarem seu trabalho, imprima o valor dessa variável. Infelizmente, será muito diferente de 10 milhões. Além disso, será diferente sempre que você executar o programa. Isso acontece porque mesmo operações simples como o incremento não são atômicas e incluem a extração de valor da memória, o cálculo do novo valor e a gravação na memória novamente. Portanto, dois encadeamentos podem fazer qualquer uma dessas operações e um incremento será perdido nesse caso.
A classe Interlocked fornece os métodos de Incremento / Decremento, e não é difícil adivinhar o que eles devem fazer. Eles são realmente úteis se você processar dados em vários threads e calcular alguma coisa. Esse código funcionará muito mais rápido que o bloqueio clássico. Se usássemos o Interlocked na situação descrita no parágrafo anterior, o programa produziria com segurança um valor de 10 milhões em qualquer cenário.
A função do método CompareExchange não é tão óbvia. No entanto, sua existência permite a implementação de muitos algoritmos interessantes. Mais importante ainda, os da família sem bloqueio.
public static int CompareExchange (ref int location1, int value, int comparand);
Este método usa três valores. O primeiro é passado por uma referência e é o valor que será alterado para o segundo se o local1 for igual a comparar e quando a comparação for realizada. O valor original de location1 será retornado. Isso parece complicado, por isso é mais fácil escrever um pedaço de código que executa as mesmas operações que o CompareExchange:
var original = location1; if (location1 == comparand) location1 = value; return original;
A única diferença é que a classe Interlocked implementa isso de maneira atômica. Portanto, se escrevêssemos esse código, poderíamos enfrentar um cenário no qual a condição location1 == comparand já foi atendida. Mas quando a instrução location1 = value está sendo executada, um encadeamento diferente já alterou o valor location1, portanto será perdido.
Podemos encontrar um bom exemplo de como esse método pode ser usado no código que o compilador gera para qualquer evento C #.
Vamos escrever uma classe simples com um evento chamado MyEvent:
class MyClass { public event EventHandler MyEvent; }
Agora, vamos criar o projeto na configuração da versão e abrir a construção através do
dotPeek com a opção "Mostrar código gerado pelo compilador" ativada:
[CompilerGenerated] private EventHandler MyEvent; public event EventHandler MyEvent { [CompilerGenerated] add { EventHandler eventHandler = this.MyEvent; EventHandler comparand; do { comparand = eventHandler; eventHandler = Interlocked.CompareExchange<EventHandler>(ref this.MyEvent, (EventHandler) Delegate.Combine((Delegate) comparand, (Delegate) value), comparand); } while (eventHandler != comparand); } [CompilerGenerated] remove {
Aqui, podemos ver que o compilador gerou um algoritmo bastante complexo nos bastidores. Esse algoritmo nos impede de perder uma assinatura para o evento no qual alguns threads são simultaneamente inscritos nesse evento. Vamos elaborar o método add, mantendo em mente o que o método CompareExchange faz nos bastidores:
EventHandler eventHandler = this.MyEvent; EventHandler comparand; do { comparand = eventHandler;
Isso é muito mais gerenciável, mas provavelmente ainda requer uma explicação. É assim que eu descreveria o algoritmo:
Se MyEvent ainda for o mesmo que era no momento em que começamos a executar o Delegate.Combine, defina-o como o que Delegate.Combine retorna. Se não for o caso, tente novamente até que funcione.Dessa forma, as assinaturas nunca serão perdidas. Você precisará resolver um problema semelhante se desejar implementar uma matriz dinâmica, segura para threads e sem bloqueios. Se vários encadeamentos começarem a adicionar elementos a essa matriz de repente, é importante que todos esses elementos sejam adicionados com êxito.
Monitor.Enter, Monitor.Exit, bloquear
Essas construções são usadas para sincronização de threads com mais freqüência. Eles implementam o conceito de uma seção crítica: ou seja, o código gravado entre as chamadas Monitor.Enter e Monitor.Exit só pode ser executado em um recurso em um ponto do tempo por apenas um encadeamento. O operador de bloqueio serve como açúcar de sintaxe nas chamadas Enter / Exit encerradas no try-finalmente. Uma qualidade agradável da seção crítica no .NET é que ele suporta a reentrada. Isso significa que o seguinte código pode ser executado sem problemas reais:
lock(a) { lock (a) { ... } }
É improvável que alguém escreva dessa maneira exata, mas se você espalhar esse código entre alguns métodos pela profundidade da pilha de chamadas, esse recurso poderá economizar alguns IFs. Para que esse truque funcione, os desenvolvedores do .NET precisaram adicionar uma limitação - você só pode usar instâncias de tipos de referência como um objeto de sincronização e alguns bytes são adicionados a cada objeto em que o identificador de encadeamento será gravado.
Essa peculiaridade do processo de trabalho da seção crítica em C # impõe uma limitação interessante ao operador de bloqueio: você não pode usar o operador de espera dentro do operador de bloqueio. No início, isso me surpreendeu, pois uma construção semelhante de Monitor-Entrada / Saída de tentativa e finalmente pode ser compilada. Qual é o problema? É importante reler o parágrafo anterior e aplicar algum conhecimento de como funciona o assíncrono / espera: o código após a espera não será executado no mesmo encadeamento que o código antes da espera. Isso depende do contexto de sincronização e se o método ConfigureAwait é chamado ou não. A partir disso, segue-se que Monitor.Exit pode ser executado em um thread diferente de Monitor.Enter, o que levará ao lançamento de SynchronizationLockException. Se você não acredita em mim, tente executar o seguinte código em um aplicativo de console - ele irá gerar um
SynchronizationLockException :
var syncObject = new Object(); Monitor.Enter(syncObject); Console.WriteLine(Thread.CurrentThread.ManagedThreadId); await Task.Delay(1000); Monitor.Exit(syncObject); Console.WriteLine(Thread.CurrentThread.ManagedThreadId);
Vale a pena notar que, em um aplicativo WinForms ou WPF, esse código funcionará corretamente se você o chamar do thread principal, pois haverá um contexto de sincronização que implementa o retorno ao UI-Thread após a chamada em espera. De qualquer forma, é melhor não brincar com seções críticas no contexto de um código que contém o operador aguardar. Nesses exemplos, é melhor usar as primitivas de sincronização que veremos um pouco mais adiante.
Enquanto estamos no tópico de seções críticas do .NET, é importante mencionar mais uma peculiaridade de como elas são implementadas. Uma seção crítica no .NET funciona de dois modos: espera de rotação e espera de núcleo. Podemos representar o algoritmo spin-wait como o seguinte pseudocódigo:
while(!TryEnter(syncObject)) ;
Essa otimização é direcionada para capturar uma seção crítica o mais rápido possível em um curto período de tempo, com base em que, mesmo que o recurso esteja ocupado atualmente, ele será liberado muito em breve. Se isso não acontecer em um curto período de tempo, o thread passará para espera no modo principal, o que leva tempo - assim como voltar da espera. Os desenvolvedores do .NET otimizaram o cenário de blocos curtos, tanto quanto possível. Infelizmente, se muitos threads começarem a puxar a seção crítica entre si, isso poderá levar a uma carga repentina na CPU.
SpinLock, SpinWait
Tendo mencionado o algoritmo de espera cíclica (spin-wait), vale a pena falar sobre as estruturas SpinLock e SpinWait da BCL. Você deve usá-los se houver motivos para supor que sempre será possível obter um bloco muito rapidamente. Por outro lado, você realmente não deve pensar neles até que os resultados da criação de perfil mostrem que o gargalo do seu programa é causado pelo uso de outras primitivas de sincronização.
Monitor.Wait, Monitor.Pulse [Tudo]
Devemos olhar para esses dois métodos lado a lado. Com a ajuda deles, você pode implementar vários cenários Produtor-Consumidor.
Producer-Consumer é um padrão de design multiprocesso / multithread que implica um ou mais threads / processos que produzem dados e um ou mais processos / threads que processam esses dados. Geralmente, uma coleção compartilhada é usada.
Ambos os métodos podem ser chamados apenas por um thread que atualmente possui um bloco. O método Wait liberará o bloco e congelará até que outro thread chame Pulse.
Como demonstração disso, escrevi um pequeno exemplo:
object syncObject = new object(); Thread t1 = new Thread(T1); t1.Start(); Thread.Sleep(100); Thread t2 = new Thread(T2); t2.Start();
(Usei uma imagem em vez de texto aqui para mostrar com precisão a ordem de execução das instruções)Explicação: Defino uma latência de 100 ms ao iniciar o segundo encadeamento para garantir especificamente que ele será executado posteriormente.
- T1: Linha 2, o encadeamento é iniciado
- T1: Linha 3, o thread entra em uma seção crítica
- T1: Linha 6, o encadeamento entra em suspensão
- T2: Linha 3, o encadeamento é iniciado
- T2: Linha 4, congela e aguarda a seção crítica
- T1: Linha 7, permite que a seção crítica congele e congele enquanto espera o pulso sair
- T2: Linha 8, entra na seção crítica
- T2: Linha 11 sinaliza T1 com a ajuda do Pulse
- T2: Linha 14, sai da seção crítica. T1 não pode continuar sua execução antes que isso aconteça.
- T1: Linha 15 sai da espera
- T1: Linha 16, que sai da seção crítica
Há uma observação importante no MSDN sobre o uso dos métodos Pulse / Wait: O Monitor não armazena as informações de estado, o que significa que chamar o método Pulse antes do método Wait pode levar a um impasse. Se esse caso for possível, é melhor usar uma das classes da família ResetEvent.O exemplo anterior mostra claramente como os métodos Wait / Pulse da classe Monitor funcionam, mas ainda deixa algumas perguntas sobre os casos em que devemos usá-los. Um bom exemplo é
essa implementação do BlockingQueue <T>. Por outro lado, a implementação do BlockingCollection <T> do System.Collections.Concurrent usa o SemaphoreSlim para sincronização.
ReaderWriterLockSlim
Eu amo muito essa primitiva de sincronização e é representada pela classe com o mesmo nome no espaço para nome System.Threading. Eu acho que muitos programas funcionariam muito melhor se seus desenvolvedores usassem essa classe em vez do bloqueio padrão.
Idéia: muitos tópicos podem ler, e o único pode escrever. Quando um encadeamento deseja gravar, novas leituras não podem ser iniciadas - elas aguardam a gravação até o fim. Há também o conceito de upgrade-leitura-bloqueio. Você pode usá-lo quando, durante o processo de leitura, entender que é necessário escrever algo - esse bloqueio será transformado em bloqueio de gravação em uma operação atômica.
No espaço para nome System.Threading, também há a classe ReadWriteLock, mas é altamente recomendável não usá-la para novos desenvolvimentos. A versão Slim ajudará a evitar casos que levam a conflitos e permite capturar rapidamente um bloco, pois suporta a sincronização no modo espera por rotação antes de passar para o modo principal.
Se você não conhecia essa classe antes de ler este artigo, acho que agora já se lembrou de muitos exemplos do código recentemente escrito, em que essa abordagem de blocos permitia que o programa funcionasse efetivamente.
A interface da classe ReaderWriterLockSlim é simples e fácil de entender, mas não é tão confortável de usar:
var @lock = new ReaderWriterLockSlim(); @lock.EnterReadLock(); try {
Eu geralmente gosto de envolvê-lo em uma classe - isso torna muito mais prático.
Idéia: crie métodos Read / WriteLock que retornem um objeto junto com o método Dispose. Você pode acessá-los em Usando, e provavelmente não será muito diferente do bloqueio padrão quando se trata do número de linhas. class RWLock : IDisposable { public struct WriteLockToken : IDisposable { private readonly ReaderWriterLockSlim @lock; public WriteLockToken(ReaderWriterLockSlim @lock) { this.@lock = @lock; @lock.EnterWriteLock(); } public void Dispose() => @lock.ExitWriteLock(); } public struct ReadLockToken : IDisposable { private readonly ReaderWriterLockSlim @lock; public ReadLockToken(ReaderWriterLockSlim @lock) { this.@lock = @lock; @lock.EnterReadLock(); } public void Dispose() => @lock.ExitReadLock(); } private readonly ReaderWriterLockSlim @lock = new ReaderWriterLockSlim(); public ReadLockToken ReadLock() => new ReadLockToken(@lock); public WriteLockToken WriteLock() => new WriteLockToken(@lock); public void Dispose() => @lock.Dispose(); }
Isso nos permite escrever o seguinte posteriormente no código:
var rwLock = new RWLock();
A família ResetEvent
Incluo as seguintes classes nessa família: ManualResetEvent, ManualResetEventSlim e AutoResetEvent.
A classe ManualResetEvent, sua versão Slim e a classe AutoResetEvent podem existir em dois estados:
- Não sinalizado - nesse estado, todos os encadeamentos que chamaram WaitOne congelam até que o evento mude para um estado sinalizado.
- Sinalizado - nesse estado, todos os threads congelados anteriormente em uma chamada WaitOne são liberados. Todas as novas chamadas do WaitOne em um evento sinalizado são realizadas de forma relativamente instantânea.
O AutoResetEvent difere de ManualResetEvent, pois alterna automaticamente para o estado não sinalizado após liberar
exatamente um segmento . Se alguns threads estiverem congelados enquanto aguarda o AutoResetEvent, a chamada de Set liberará apenas um thread aleatório, em oposição ao ManualResetEvent que libera todos os threads.
Vejamos um exemplo de como o AutoResetEvent funciona:
AutoResetEvent evt = new AutoResetEvent(false); Thread t1 = new Thread(T1); t1.Start(); Thread.Sleep(100); Thread t2 = new Thread(T2); t2.Start();

Nesses exemplos, podemos ver que o evento muda para o estado não sinalizado automaticamente somente depois de liberar o encadeamento que foi congelado em uma chamada WaitOne.
Ao contrário do ReaderWriterLock, o ManualResetEvent não é considerado obsoleto mesmo depois que a versão Slim apareceu. Esta versão Slim da classe pode ser eficaz para esperas curtas, como acontece no modo de espera por rotação; a versão padrão é boa para longas esperas.
Além das classes ManualResetEvent e AutoResetEvent, também há a classe CountdownEvent. Essa classe é muito útil para implementar algoritmos que mesclam resultados juntos após uma seção paralela. Essa abordagem é conhecida como
junção de garfo . Há um ótimo
artigo dedicado a esta classe, então não vou descrevê-lo em detalhes aqui.
Conclusões
- Ao trabalhar com encadeamentos, há dois problemas que podem levar a resultados incorretos ou até a ausência de resultados - condição de corrida e conflito.
- Os problemas que podem fazer com que o programa gaste mais tempo ou recursos são falta de thread e espera ocupada.
- O .NET fornece várias maneiras de sincronizar threads.
- Existem dois modos de espera de bloco - Spin Wait e Core Wait. Algumas primitivas de sincronização de threads no .NET usam os dois.
- Interlocked é um conjunto de operações atômicas que podem ser usadas para implementar algoritmos sem bloqueio. É a primitiva de sincronização mais rápida.
- Os operadores lock e Monitor.Enter / Exit implementam o conceito de uma seção crítica - um fragmento de código que só pode ser executado por um encadeamento em um ponto do tempo.
- Os métodos Monitor.Pulse / Wait são úteis para implementar cenários Produtor-Consumidor.
- O ReaderWriterLockSlim pode ser mais útil do que os casos de bloqueio padrão quando se espera uma leitura paralela.
- A família de classes ResetEvent pode ser útil para sincronização de threads.