Publico o artigo original sobre Habr, cuja tradução está publicada no blog da Codingsight .Continuo criando uma versão em texto da minha palestra na reunião multithreading. A primeira parte pode ser encontrada
aqui ou
aqui ; havia mais sobre o conjunto básico de ferramentas para iniciar um thread ou Tarefa, maneiras de visualizar seu status e algumas pequenas coisas doces como o PLinq. Neste artigo, quero me concentrar mais nos problemas que podem surgir em um ambiente multithread e em algumas maneiras de resolvê-los.
Conteúdo
Sobre recursos compartilhados
É impossível escrever um programa que funcione em vários threads, mas ao mesmo tempo não teria um único recurso compartilhado: mesmo que funcione no seu nível de abstração, ao descer um ou mais níveis abaixo, ainda há um recurso comum. Vou dar alguns exemplos:
Exemplo 1:Com medo de possíveis problemas, você fez os threads funcionarem com arquivos diferentes. Por arquivo para transmitir. Parece que o programa não possui um único recurso comum.
Depois de descer vários níveis abaixo, entendemos que existe apenas um disco rígido e seu driver ou sistema operacional terá que resolver os problemas de garantir o acesso a ele.
Exemplo 2:Depois de ler o
exemplo 1, você decidiu colocar os arquivos em duas máquinas remotas diferentes, com duas peças fisicamente diferentes de ferro e sistemas operacionais. Mantemos 2 conexões diferentes via FTP ou NFS.
Ao descer vários níveis abaixo, entendemos que nada mudou e o driver da placa de rede ou do sistema operacional da máquina em que o programa está sendo executado terá que resolver o problema do acesso competitivo.
Exemplo 3:Tendo perdido uma parte considerável do seu cabelo na tentativa de provar a possibilidade de escrever um programa multiencadeado, você recusa completamente os arquivos e decompõe os cálculos em dois objetos diferentes, links para cada um dos quais estão disponíveis apenas para um fluxo.
Martelo a última dúzia de pregos no caixão dessa idéia: um tempo de execução e coletor de lixo, agendador de threads, fisicamente uma RAM e memória, um processador ainda são recursos compartilhados.
Portanto, descobrimos que é impossível escrever um programa multithread sem um único recurso compartilhado em todos os níveis de abstração em toda a largura de toda a pilha de tecnologia. Felizmente, como regra geral, cada um dos níveis de abstração resolve parcialmente ou completamente os problemas do acesso competitivo ou simplesmente o proíbe (exemplo: qualquer estrutura de interface do usuário proíbe trabalhar com elementos de diferentes threads); portanto, os problemas geralmente surgem com recursos compartilhados em seu nível de abstração. Para resolvê-los, introduza o conceito de sincronização.
Possíveis problemas ao trabalhar em um ambiente multithread
Os erros no software podem ser divididos em vários grupos:
- O programa não produz um resultado. Falha ou congela.
- O programa retorna um resultado incorreto.
- O programa produz o resultado correto, mas não atende a um ou outro requisito não funcional. Executa muito tempo ou consome muitos recursos.
Em um ambiente multithread, os dois principais problemas que causam os erros 1 e 2 são
deadlock e
condição de corrida .
Impasse
Impasse - impasse. Existem muitas variações diferentes. Os mais comuns são os seguintes:

Enquanto o
Thread # 1 estava fazendo alguma coisa, o
Thread # 2 bloqueou o recurso
B , um pouco mais tarde o
Thread # 1 bloqueou o recurso
A e tentou bloquear o recurso
B , infelizmente isso nunca acontecerá, porque
O segmento # 2 liberará o recurso
B somente após o bloqueio do recurso
A.Condição de corrida
Condição de corrida - condição de corrida. A situação em que o comportamento e o resultado dos cálculos executados pelo programa dependem do trabalho do planejador de encadeamentos em tempo de execução.
O desagrado desta situação reside precisamente no fato de que seu programa pode não funcionar apenas uma vez em cem ou mesmo em um milhão.
A situação é agravada pelo fato de que os problemas podem andar juntos, por exemplo: com um determinado comportamento do planejador de encadeamentos, ocorre um conflito.
Além desses dois problemas que levam a erros óbvios no programa, também existem aqueles que podem não levar a um resultado de cálculo incorreto, mas será gasto mais tempo ou poder de processamento para obtê-lo. Dois desses problemas são:
espera ocupada e
falta de thread .
Espera ocupada
Aguardar ocupado é um problema no qual o programa consome recursos do processador não para cálculos, mas para aguardar.
Muitas vezes, esse problema no código se parece com isso:
while(!hasSomethingHappened) ;
Este é um exemplo de código extremamente ruim, pois Esse código ocupa completamente um núcleo do seu processador, sem fazer absolutamente nada útil. Pode ser justificado se e somente se for criticamente importante processar uma alteração em algum valor em outro encadeamento. E falando rápido, estou falando do caso em que você não pode esperar nem alguns nanossegundos. Em outros casos, ou seja, em tudo o que pode produzir um cérebro saudável, é mais razoável usar as variedades ResetEvent e suas versões Slim. Sobre eles abaixo.
Talvez um dos leitores proponha resolver o problema de carregar completamente um núcleo com uma espera inútil, adicionando construções como Thread.Sleep (1) ao loop. Isso realmente resolverá o problema, mas criará outro: o tempo de resposta à mudança será em média de meio milissegundo, o que pode não ser muito, mas catastroficamente mais do que você poderia usar as primitivas de sincronização da família ResetEvent.
Inanição de thread
Ausência de thread é um problema em que o programa tem muitos threads trabalhando simultaneamente. O que significa exatamente aqueles fluxos que estão ocupados com os cálculos, e não apenas aguardando uma resposta de qualquer IO. Com esse problema, todo o ganho de desempenho possível com o uso de threads é perdido, porque O processador gasta muito tempo alternando contextos.
É conveniente procurar esses problemas usando vários
criadores de perfil. Abaixo está um exemplo de uma captura de tela do
criador de perfil
dotTrace iniciado no modo Linha de tempo.
(A imagem é clicável)No programa que não sofre de fome de fluxo contínuo, não haverá cor rosa nos gráficos que refletem fluxos. Além disso, na categoria Subsistemas, fica claro que 30,6% do programa estava aguardando a CPU.
Quando esse problema é diagnosticado, ele é resolvido com muita simplicidade: você iniciou muitos threads ao mesmo tempo, inicia menos ou não todos de uma vez.
Ferramentas de sincronização
Intertravado
Esta é talvez a maneira mais leve de sincronizar. Interlocked é uma coleção de operações atômicas simples. Uma operação atômica é chamada de operação no momento em que nada pode acontecer. No .NET, Interlocked é representado pela classe estática de mesmo nome com vários métodos, cada um dos quais implementa uma operação atômica.
Para perceber o horror das operações não atômicas, tente escrever um programa que inicie 10 threads, cada um dos quais faz um milhão de incrementos da mesma variável e, no final do trabalho, imprima o valor dessa variável - infelizmente, será muito diferente de 10 milhões. Cada vez que o programa inicia, será diferente. Isso acontece porque mesmo uma operação tão simples como um incremento não é atômica, mas envolve extrair um valor da memória, calcular um novo e escrever de volta. Portanto, dois encadeamentos podem executar simultaneamente cada uma dessas operações; nesse caso, o incremento será perdido.
A classe Interlocked fornece métodos de Incremento / Decremento; é fácil adivinhar o que eles fazem. Eles são convenientes de usar se você estiver processando dados em vários threads e considerar algo. Esse código funcionará muito mais rápido que o bloqueio clássico. Se Intertravado for usado para a situação descrita no último parágrafo, o programa distribuirá 10 milhões de maneira estável em qualquer situação.
O método CompareExchange executa, à primeira vista, uma função bastante óbvia, mas toda a sua presença permite implementar muitos algoritmos interessantes, especialmente a família sem bloqueio.
public static int CompareExchange (ref int location1, int value, int comparand);
O método usa três valores: o primeiro é passado por referência e este é o valor que será alterado para o segundo, se no momento da comparação o local1 corresponder ao comparando, o valor original do local1 será retornado. Parece um pouco confuso, porque é mais fácil escrever código que executa as mesmas operações que o CompareExchange:
var original = location1; if (location1 == comparand) location1 = value; return original;
Somente uma implementação na classe Interlocked será atômica. Ou seja, se nós escrevêssemos esse código, uma situação poderia ter ocorrido quando a condição location1 == comparand já tivesse sido atendida, mas quando a expressão location1 = value foi executada, outro encadeamento alterou o valor do location1 e ele seria perdido.
Podemos encontrar um bom exemplo de uso desse método no código que o compilador gera para qualquer evento C #.
Vamos escrever uma classe simples com um evento MyEvent:
class MyClass { public event EventHandler MyEvent; }
Vamos criar o projeto na configuração Release e abrir o assembly usando
dotPeek com a opção Show
Generator Generated Code 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 você pode ver que nos bastidores, o compilador gerou um algoritmo bastante sofisticado. Esse algoritmo protege contra a situação de perda de uma inscrição de evento quando vários threads se inscrevem nesse evento simultaneamente. Vamos escrever o método add com mais detalhes, lembrando o que o método CompareExchange faz nos bastidores
EventHandler eventHandler = this.MyEvent; EventHandler comparand; do { comparand = eventHandler;
Isso já é um pouco mais claro, embora provavelmente ainda precise de uma explicação. Em palavras, eu descreveria esse algoritmo da seguinte maneira:
Se o MyEvent ainda é o mesmo que era no momento em que começamos a executar o Delegate.Combine, escreva nele o que o Delegate.Combine retorna e, se não, não importa, tente novamente e repita até que saia.
Portanto, nenhuma assinatura de evento será perdida. Você terá que resolver um problema semelhante se desejar repentinamente implementar uma matriz dinâmica livre de bloqueios, segura para threads. Se vários fluxos correm para adicionar elementos a ele, é importante que todos sejam adicionados no final.
Monitor.Enter, Monitor.Exit, bloquear
Essas são as construções mais usadas para sincronização de encadeamentos. Eles implementam a ideia de uma seção crítica: ou seja, o código gravado entre as chamadas para Monitor.Enter, Monitor.Exit em um recurso pode ser executado ao mesmo tempo em apenas um thread. A instrução de bloqueio é um açúcar sintático em torno das chamadas de Enter / Exit envolvidas no try-finalmente. Um bom recurso da implementação de uma seção crítica no .NET é a capacidade de inseri-la novamente no mesmo fluxo. Isso significa que esse código será executado sem problemas:
lock(a) { lock (a) { ... } }
É improvável, é claro, que alguém escreva dessa maneira, mas se você espalhar esse código em vários métodos de pilha de chamadas em profundidade, esse recurso poderá economizar alguns ifs. Para tornar possível esse truque, os desenvolvedores do .NET precisaram adicionar uma restrição - apenas uma instância de um tipo de referência pode ser usada como um objeto de sincronização e vários bytes são adicionados implicitamente a cada objeto em que o identificador do fluxo será gravado.
Esse recurso da seção crítica em c # impõe uma limitação interessante na operação da instrução de bloqueio: você não pode usar a instrução de espera dentro da instrução de bloqueio. No começo, ele me surpreendeu, porque uma compilação Monitor.Enter / Exit de tentativa e finalmente compilada. Qual é o problema? Aqui, você deve reler cuidadosamente o último parágrafo mais uma vez e, em seguida, adicionar algum conhecimento sobre o princípio de async / waitit: o código após aguardar não será necessariamente executado no mesmo encadeamento que o código antes de aguardar, depende do contexto de sincronização e da presença ou nenhuma chamada para ConfigureAwait. Daqui resulta que Monitor.Exit pode ser executado em um thread que não seja Monitor.Enter, que lançará um
SynchronizationLockException . Se você não acredita, pode executar o seguinte código em um aplicativo de console: ele lançará 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 ressaltar que no WinForms ou em um aplicativo WPF, esse código funcionará corretamente se for chamado do thread principal. haverá um contexto de sincronização que implementa um retorno ao thread da interface do usuário após aguardar. De qualquer forma, você não deve brincar com a seção crítica no contexto do código que contém o operador aguardar. Nesses casos, é melhor usar primitivas de sincronização, que serão discutidas mais adiante.
Falando sobre o trabalho da seção crítica no .NET, vale ressaltar outro recurso de sua implementação. A seção crítica no .NET opera em dois modos: modo de espera de rotação e modo de kernel. O algoritmo spin-wait é convenientemente representado como o seguinte pseudo-código:
while(!TryEnter(syncObject)) ;
Essa otimização visa a captura mais rápida da seção crítica em um curto espaço de tempo, com base no pressuposto de que, se o recurso estiver ocupado agora, ele está prestes a se libertar. Se isso não ocorrer em um curto período de tempo, o thread aguardará no modo kernel, o que, como retornar dele, leva tempo. Os desenvolvedores do .NET otimizaram o cenário de bloqueio curto o máximo possível, infelizmente, se muitos threads começarem a rasgar a seção crítica, isso poderá levar a uma carga de CPU alta e repentina.
SpinLock, SpinWait
Como mencionei o algoritmo spin-wait, vale a pena mencionar as estruturas BCL SpinLock e SpinWait. Eles devem ser usados se houver motivos para acreditar que sempre haverá uma oportunidade de trancar rapidamente. Por outro lado, não vale a pena lembrar deles antes que os resultados da criação de perfil mostrem que o uso de outras primitivas de sincronização é o gargalo do seu programa.
Monitor.Wait, Monitor.Pulse [Tudo]
Este par de métodos deve ser considerado em conjunto. Com a ajuda deles, vários cenários Produtor-Consumidor podem ser implementados.
Produtor-Consumidor - um padrão de design com vários processos / multithread assumindo a presença de um ou mais threads / processos que produzem dados e um ou mais processos / threads que processam esses dados. Geralmente usa uma coleção compartilhada.Ambos os métodos podem ser chamados apenas se o encadeamento que os está causando tiver um bloqueio no momento. O método Wait libera o bloqueio e trava até que outro thread chame Pulse.
Para demonstrar o trabalho, 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 a imagem, não o texto, para mostrar visualmente a ordem de execução das instruções)Analisar: defina um atraso de 100ms no início do segundo fluxo, especificamente para garantir que sua execução seja iniciada mais tarde.
- T1: o fluxo da linha 2 inicia
- T1: o fluxo da linha 3 entra na seção crítica
- T1: Linha 6, o fluxo adormece
- T2: início da linha # 3
- T2: a linha 4 congela enquanto aguarda uma seção crítica
- T1: a linha 7 libera a seção crítica e congela enquanto aguarda o pulso sair
- T2: a linha 8 entra na seção crítica
- T2: a linha 11 notifica T1 usando o método Pulse
- T2: a linha 14 sai da seção crítica. Até então, T1 não pode continuar a execução.
- T1: a linha 15 acorda
- T1: a linha 16 sai da seção crítica
O MSDN possui uma observação importante sobre o uso dos métodos Pulse / Wait, a saber: O Monitor não armazena informações de status, o que significa que, se o método Pulse for chamado antes da chamada do método Wait, poderá levar a um impasse. Se essa situação for possível, é melhor usar uma das classes da família ResetEvent.O exemplo anterior demonstra claramente como os métodos Wait / Pulse da classe Monitor funcionam, mas ainda deixa dúvidas sobre quando deve ser usado. Um bom exemplo seria 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
Esta é minha primitiva de sincronização amada, representada pela classe de espaço para nome System.Threading com o mesmo nome. Parece-me que muitos programas funcionariam melhor se seus desenvolvedores usassem essa classe em vez do bloqueio usual.
Idéia: muitos threads podem ler, apenas uma gravação. Assim que o fluxo declara seu desejo de gravar, novas leituras não podem ser iniciadas, mas aguardam a conclusão da gravação. Existe também o conceito de bloqueio de leitura atualizável, que pode ser usado se você entender durante o processo de leitura que precisa escrever algo; esse bloqueio será convertido em bloqueio de gravação em uma operação atômica.Há também uma classe ReadWriteLock no espaço para nome System.Threading, mas é altamente recomendável para novo desenvolvimento. A versão Slim permitirá evitar vários casos que levam a conflitos, além de permitir a captura rápida do bloqueio, porque suporta sincronização no modo espera por rotação antes de sair para o modo kernel.Se, no momento da leitura deste artigo, você ainda não conhecia essa classe, acho que agora você se lembrou de alguns exemplos do código escrito recentemente, em que essa abordagem de bloqueios permitiria que o programa funcionasse com eficiência.
A interface da classe ReaderWriterLockSlim é simples e direta, mas seu uso dificilmente pode ser chamado de conveniente:
var @lock = new ReaderWriterLockSlim(); @lock.EnterReadLock(); try {
Eu gosto de agrupar seu uso em uma classe, o que o torna muito mais conveniente.
Idéia: criar métodos Read / WriteLock que retornam um objeto com o método Dispose, isso permitirá que eles sejam usados no uso e pelo número de linhas dificilmente será diferente do bloqueio usual.
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(); }
Esse truque permite que você simplesmente escreva mais:
var rwLock = new RWLock();
Família ResetEvent
Incluo as classes ManualResetEvent, ManualResetEventSlim, AutoResetEvent a esta família.
As classes ManualResetEvent, sua versão Slim e a classe AutoResetEvent podem estar em dois estados:
- Um engatilhado (não sinalizado), nesse estado, todos os threads que chamaram WaitOne congelam até que o evento seja transferido para um estado sinalizado.
- O estado abaixado (sinalizado), nesse estado, todos os fluxos pendurados na chamada WaitOne são liberados. Todas as novas chamadas do WaitOne em um evento de degradação passam instantaneamente condicionalmente.
A classe AutoResetEvent difere da classe ManualResetEvent, pois entra automaticamente em um estado armado depois que libera exatamente um thread. Se vários threads ficarem esperando enquanto o AutoResetEvent, a chamada Set liberará apenas um arbitrário, diferente do ManualResetEvent. ManualResetEvent lançará 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();

O exemplo mostra que o evento entra em estado de armação (sem sinalização) automaticamente apenas deixando o encadeamento suspenso na chamada WaitOne.
A classe ManualResetEvent, diferentemente do ReaderWriterLock, não é marcada como obsoleta e não é recomendada para uso após o aparecimento de sua versão Slim. A versão slim desta classe é usada eficientemente para pequenas expectativas, como Isso acontece no modo Spin-Wait, a versão regular é adequada para as longas.
Além das classes ManualResetEvent e AutoResetEvent, a classe CountdownEvent também existe. Essa classe é conveniente para a implementação de algoritmos, onde a parte que conseguiu ser paralelizada é seguida pela parte de reunir os resultados. Essa abordagem é conhecida como
junção de garfo . Um excelente
artigo é dedicado ao trabalho desta classe, portanto, não o analisarei em detalhes aqui.
Conclusões
- Ao trabalhar com encadeamentos, dois problemas que resultam em resultados incorretos ou ausentes são condição de corrida e impasse
- Os problemas que fazem com que o programa gaste mais tempo ou recursos - falta de thread e espera ocupada
- .NET é rico em sincronização de threads
- Existem 2 modos de espera de bloqueio - Spin Wait, Core Wait. Algumas primitivas de sincronização de encadeamento .NET usam ambos
- Interlocked é um conjunto de operações atômicas, usado em algoritmos sem bloqueio, é a primitiva de sincronização mais rápida
- O operador de bloqueio e o Monitor.Enter / Exit implementam a ideia de uma seção crítica - um pedaço de código que só pode ser executado por um encadeamento de cada vez
- Os métodos Monitor.Pulse / Wait são convenientes para implementar scripts Producer-Consumer
- O ReaderWriterLockSlim pode ser mais eficiente do que o bloqueio regular em scripts em que a leitura paralela é aceitável
- A família de classes ResetEvent pode ser útil para sincronização de threads.