Quando várias threads leem e escrevem os mesmos dados ao mesmo tempo, atualizações podem se perder ou ser vistas pela metade. A instrução lock torna um bloco de código mutuamente exclusivo: enquanto uma thread está dentro dele, todas as outras threads que chegam a um lock no mesmo objeto esperam a vez.
O problema: uma condição de corrida
Quatro tasks somam 100.000 cada uma a um contador compartilhado. A resposta deveria ser 400.000:
Exemplo de saída:
Expected 400000, got 245609
O total normalmente sai menor, e com uma diferença diferente a cada execução (em uma máquina de um núcleo ele pode às vezes sair certo, o que torna esses bugs difíceis de pegar). count++ parece uma operação, mas são três: ler count, somar um, escrever de volta. Duas threads podem ler 500, calcular 501 e escrever 501, e um incremento some.
A solução: lock
Envolva a sequência de ler, modificar e escrever em um lock sobre um objeto compartilhado:
Saída:
Expected 400000, got 400000
Agora só uma thread por vez pode estar dentro do bloco, então cada incremento termina antes de o próximo ler o valor. O lock também garante visibilidade: uma thread que entra no lock vê todas as escritas feitas pela anterior antes de ela sair.
A proteção só funciona se todo acesso a count passar pelo mesmo lock. Um único count++ sem lock em outro lugar do programa traz a condição de corrida de volta.
Para o que o lock é compilado
lock é uma abreviação da classe Monitor, com um try/finally para que o lock seja liberado mesmo quando o bloco lança uma exceção:
lock (sync)
{
count++;
}
// is compiled roughly as:
bool taken = false;
try
{
Monitor.Enter(sync, ref taken);
count++;
}
finally
{
if (taken) Monitor.Exit(sync);
}
Monitor também oferece TryEnter(obj, timeout), que desiste depois de um timeout em vez de esperar para sempre, e Wait/Pulse para sinalização entre threads. Uma thread que já segura um lock pode entrar nele de novo (os locks são reentrantes), então um método com lock pode chamar outro método com lock no mesmo objeto.
Escolhendo o objeto do lock
O objeto em lock (...) é só uma ficha que as threads combinam de usar. As regras:
- Use um campo privado e somente leitura do tipo
object.private readonly object sync = new object();. Privado significa que nenhum código de fora pode pegar o mesmo lock;readonlysignifica que a ficha não pode ser trocada enquanto uma thread a segura. - Nunca faça
lock (this). Qualquer um com uma referência ao seu objeto também pode fazer lock nele, e aí o código dele bloqueia o seu ou entra em deadlock com ele. - Nunca faça lock em uma string ou em
typeof(...). Literais de string são internados (todo"orders"no processo é o mesmo objeto), e objetosTypesão compartilhados pela aplicação inteira, então código sem relação pode acabar disputando o mesmo lock. - Nunca faça lock em um tipo de valor.
lock (count)em umintnão compila (erro CS0185), e fazer o boxing à mão cria um objeto novo a cada vez, então não trancaria nada. - Use um lock por conjunto de dados que precisa ficar consistente, um campo de lock static para dados static, um campo de instância para dados por instância.
Protegendo operações de vários passos
O lock é mais necessário onde uma verificação e uma ação precisam acontecer juntas. Uma conta thread safe mostra tanto o padrão verificar e depois agir quanto uma foto consistente de dois campos:
Saída:
10 left after 33 withdrawals
Sem o lock, duas threads poderiam ver um saldo de 40, passar as duas pela verificação e sacar 30 cada uma, deixando a conta com 20 negativos. O método Summary também faz lock, então nunca informa um balance de depois de um saque junto com uma contagem withdrawals de antes dele.
Dictionary<TKey, TValue> e List<T> também não são thread safe: escritas concorrentes podem corromper os arrays internos, não só perder atualizações. Proteja-os com um lock ou use ConcurrentDictionary<TKey, TValue> e os outros tipos de System.Collections.Concurrent.
Interlocked para valores únicos
Quando o estado compartilhado é um inteiro ou uma referência e a atualização é um único passo, a classe Interlocked a faz de forma atômica, sem lock:
Saída:
Visits: 10000
Bytes: 5120000
Peak: 10000
Increment, Decrement, Add e Exchange correspondem a operações atômicas do processador (uma única instrução em x64). CompareExchange(ref location, newValue, expected) só escreve se o local ainda contiver expected, e retorna o que encontrou, o que permite montar qualquer atualização como um laço de nova tentativa. Qualquer coisa que mexa em duas variáveis ao mesmo tempo ainda precisa de um lock.
Deadlocks
Um deadlock acontece quando duas threads seguram, cada uma, um lock de que a outra precisa:
// Thread 1 // Thread 2
lock (accountA) lock (accountB)
{ {
lock (accountB) { /* ... */ } lock (accountA) { /* ... */ }
} }
Se a thread 1 pega accountA enquanto a thread 2 pega accountB, cada uma espera a outra para sempre. Não há exceção nem timeout; o programa trava. Uma transferência entre duas contas que faz lock em "origem" e depois em "destino" produz exatamente isso quando duas transferências em sentidos opostos executam ao mesmo tempo.
As defesas padrão:
- Faça lock em uma ordem global fixa. Em uma transferência, faça lock primeiro na conta com o menor ID, qualquer que seja o sentido do dinheiro.
- Segure os locks por pouco tempo e faça o trabalho lento (I/O, log, chamadas de rede) fora deles.
- Não chame código desconhecido enquanto segura um lock: eventos, callbacks e métodos virtuais podem pegar locks próprios.
- Use
Monitor.TryEntercom timeout onde um travamento seria pior que uma falha.
Sem await dentro do lock: use SemaphoreSlim
await não é permitido dentro de um bloco lock (erro de compilação CS1996). Depois de um await o método pode continuar em outra thread, e um lock de Monitor precisa ser liberado pela thread que o pegou. Para código async, SemaphoreSlim com contagem 1 funciona como um lock compatível com async:
Saída:
5 saved, one at a time
Sempre combine WaitAsync com Release em um finally, senão uma exceção deixa a porta fechada para sempre. Um SemaphoreSlim(3, 3) deixa três chamadores entrarem de uma vez, que é como você limita requisições concorrentes a uma API com limite de taxa.
System.Threading.Lock (.NET 9)
O .NET 9 com C# 13 adiciona um tipo dedicado, System.Threading.Lock. Quando o objeto em uma instrução lock é um Lock, o compilador usa a API EnterScope, mais rápida, em vez de Monitor:
private readonly Lock sync = new Lock();
public void Add(decimal amount)
{
lock (sync) // uses Lock.EnterScope(), not Monitor
{
balance += amount;
}
}
As regras para escolher o objeto continuam as mesmas. Em versões anteriores, private readonly object é a escolha certa.
Erros comuns
- Fazer lock em alguns acessos, mas não em todos. Toda leitura e escrita dos dados compartilhados precisa pegar o mesmo lock.
lock (this),lock (typeof(X)),lock ("name"). Código de fora pode pegar o mesmo lock.- Fazer I/O lento dentro de um lock. Todas as outras threads esperam; mantenha o bloco curto.
- Pegar dois locks em ordens diferentes em lugares diferentes. O deadlock clássico.
- Usar
lockem volta deawait. Não compila; useSemaphoreSlim. - Um lock por chamada.
lock (new object())não protege nada; o objeto precisa ser compartilhado.
Perguntas frequentes
O que o lock faz em C#?
lock (obj) { ... } permite que só uma thread por vez execute o bloco para um dado objeto de lock. Uma segunda thread que chega a um lock no mesmo objeto espera até a primeira sair do bloco. O lock é liberado mesmo se o bloco lançar uma exceção, porque lock é compilado para Monitor.Enter e Monitor.Exit dentro de um try/finally.
Em qual objeto devo fazer lock em C#?
Em um campo privado dedicado: private readonly object _sync = new object();. Ele precisa ser um tipo de referência, compartilhado por todas as threads que mexem nos dados protegidos, e inacessível de fora da sua classe. Nunca faça lock em this, em um Type (typeof(MyClass)) ou em uma string, porque outro código pode fazer lock no mesmo objeto e bloquear você ou causar um deadlock.
Quando usar Interlocked em vez de lock?
Quando o estado compartilhado é um único número ou referência e a operação é um único passo: Interlocked.Increment(ref count), Interlocked.Add(ref total, x), Interlocked.Exchange ou CompareExchange. São operações atômicas de hardware e mais rápidas que um lock. Para qualquer coisa que mexa em vários campos ou em uma coleção, use lock.
Posso usar await dentro de um lock em C#?
Não, o compilador rejeita await dentro de um bloco lock (erro CS1996), porque o código depois do await pode continuar em uma thread diferente da que segura o lock. Use SemaphoreSlim com contagem 1: await sem.WaitAsync(); try { ... } finally { sem.Release(); }.
Como acontecem deadlocks com lock?
A thread 1 segura o lock A e espera o lock B, enquanto a thread 2 segura o lock B e espera o lock A. Nenhuma consegue avançar, e o programa trava sem exceção. Evite isso pegando vários locks sempre na mesma ordem global, segurando locks pelo menor tempo possível e nunca chamando código desconhecido enquanto segura um lock.