O que é um lock?
Há situações em threads que precisamos fazer com que uma thread espera a outra, dado que ambas pretendem acessar um recurso que não pode ser acessado simultaneamente. Neste caso, utilizamos locks.
O problema é que sempre que uma thread espera por um lock, ela está consumindo recursos, gerando trabalho para o escalonador do sistema operacional e não está fazendo nada. Esse comportamento pode gerar problemas de desempenho.
Locks
Como explicado no post anterior (Programação paralela – Parte 1), threads compartilham o heap. Dessa maneira, eventualmente queremos evitar que as threads concorram pelos mesmos recursos. Para isso, existem objetos de sincronização, como no exemplo abaixo:
public void DoSomething() { lock(this) { //Trecho que executará somente com uma thread de cada vez. } }
O trecho protegido por lock, fará com que as threads não entrem simultaneamente no mesmo trecho. Existem diversos outros objetos de sincronização, inclusive que impedem que mais de um processo acesse o mesmo recurso simultaneamente. Eles ficam no namespace System.Threading.
Corrida de saco
O grande problema de usarmos uma infinidade de threads com diversos pontos de sincronização (locks) é que nossas threads ficarão realizando uma espécie de corrida de sacos. Uma anda um pouquinho, mas precisa esperar a outra liberar um recurso, fazendo com que na média, tudo fique mais lento.
Para exemplificar, alterei o exemplo anterior, a parte que cada uma das threads executa, para o seguinte código:
private void DequeueAndProcess(Queue<Job> queue) { while (true) { Job job = null; lock (queue) { if (queue.Count > 0) job = queue.Dequeue(); if (job == null) { Thread.Sleep(0); continue; } job.JobDelegate(); job.JobEndedNotifier.Set(); } } }
Neste caso, obtemos os seguintes tempos:
O exemplo é bastante exagerado, pois todo o processamento está sendo serializado dado que somente uma thread consegue processar o trecho protegido pelo lock, mas para fins didáticos, nos ajuda a entender porque muitas vezes o processamento multi-thread concorrendo por vários recursos fica pior do que o processamento single-threaded. A reposta é que além do custo do processamento, temos todo o custo de gerenciar as threads. Como desenvolvedor, não sentimos este custo, mas o sistema operacional sem dúvida sente.
Deadlocks
Se deadlocks em bancos de dados incomodam, deadlocks em threads fazem você sentir saudades dos de banco de dados. O processo é o mesmo: duas threads que dependem de mais de um recurso tentam os obter forma inversa. O sintoma é bem diferente: as threads envolvidas no deadlock simplesmente congelam. Diferentemente do banco de dados, não existe um recurso que automaticamente escolhe uma thread como vítima, e automaticamente a mata. Você precisa desenvolver este mecanismo por sua conta.
Por sorte, deadlocks em threads são tão raros quanto um deadlock na vida real, mas como a foto acima ilustra, eles acontecem.
I/O
Como regra geral, handles também são recursos que não suportam concorrência. O mesmo mecanismo de forçar locks deve ser utilizado para evitar que duas threads tentem escrever no mesmo socket ao mesmo tempo, tentem escrever no mesmo arquivo ao mesmo tempo e assim sucessivamente. Em geral, utiliza-se mecanismos como o “lock” exemplificado acima.
Tipos thread-safe
Outra preocupação que devemos ter ao utilizar concorrência por objetos entre threads está relacionada ao tipo de dados utilizado. No MSDN há para cada tipo de dados observações sobre o tipo de dado ser thread-safe ou não (as vezes notas bastante confusas!).
Em geral, um tipo thread safe é aquele que você não precisa ter medo de acessar ou modificar em diferentes threads (Ex.: ConcurrentQueue). Tipos não thread-safe, você precisa explicitamente utilizar um lock (Ex.: Queue), o que pode gerar gargalos de desempenho, como apresentado anteriormente.
Notas importantes para aplicações Web
Aplicações web são em geral, multi-threaded sem muitas vezes nós percebermos. Geralmente cada novo request é servido numa nova thread, ou seja, se 10 usuários pedem para um servidor servir um request, é possível que tenhamos 10 threads no servidor. Utilizo as palavras geralmente e “é possível” porque o ASP.NET utiliza um mecanismo interno dele para determinar quando ou não utilizar uma nova thread, justamente para evitar contenções e baixo desempenho, conforme apresentado nesses posts. As vezes é melhor fazer um usuário esperar e os 10 que estão sendo atendidos serem bem atendidos, do que deixar 11 com péssima responsividade.
Sendo que aplicações web são essencialmente multi-thread, as mesmas preocupações devemos ter em relação a locks. Se dois requests tentam escrever num arquivo simultaneamente, um dos dois tomará uma exception. Se há alguma estrutura em memória compartilhada entre os requests (exemplos: singletons, instâncias únicas de serviço do Spring.NET, dados estáticos em classes), os mesmos problemas de concorrência estão sujeitos a acontecer.
Conclusão
Como diria um amigo: pensou que ia ser fácil, começou errado!
Processamento paralelo está longe de ser simples. É um conceito um tanto quanto complexo e na prática, debuggar aplicações multi-thread é algo bastante complexo. A própria existência do debugger, de uma linha de log, pode fazer com que as threads concorram em locais diferentes e o bug não se apresente.
Mas o recado mais importante é que não adianta pegar uma aplicação que não foi feita para ter paralelismo, que possui diversos pontos de compartilhamento de informações e sair gerando threads e entupindo de locks onde os problemas são apresentados. Muitas vezes esse processo torna a aplicação ainda menos eficiente do que era quando totalmente single-threaded.
É importante pensar numa arquitetura que suporte concorrência e escala.