1. Introdução
Vamos ver uma situação conhecida ao trabalhar com threads. Suponha que temos um contador compartilhado de sucesso em um aplicativo bem simples.
int counter = 0;
void IncrementCounter()
{
for (int i = 0; i < 100_000; i++)
{
counter++; // Não é atômico!
}
}
// Iniciamos duas threads:
Thread t1 = new Thread(IncrementCounter);
Thread t2 = new Thread(IncrementCounter);
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Console.WriteLine($"Counter: {counter}");
Execute esse código algumas vezes. Quase nunca você verá 200_000! Por quê? Duas threads ficam constantemente atrapalhando uma à outra, às vezes ambas leem a variável ao mesmo tempo, incrementam — e escrevem o mesmo resultado. No final, alguns incrementos "se perdem".
Isso é uma condição de corrida, ou Race Condition. Sem regras de "fila", as threads literalmente brigam pelos dados.
Seção crítica: o que é?
Seção crítica — é um trecho de código que deve ser executado apenas por uma thread de cada vez. Voltando à analogia da cozinha: é como uma pia — se duas pessoas tentam lavar o rosto na mesma pia ao mesmo tempo, sujeira e pasta de dente acabarão por toda parte. Vamos combinar que cada um entra no banheiro de cada vez!
No nosso exemplo a seção crítica é a linha counter++.
2. A palavra-chave lock
Em C# existe uma forma concisa e segura de criar uma seção crítica — a palavra-chave lock. Ela esconde de nós o trabalho complexo com o primitivo de sincronização e garante que apenas uma thread entre no bloco protegido por vez.
Como usar lock
Sintaxe:
lock (lockerObject)
{
// Código que só pode ser executado por uma thread por vez
}
lockerObject — é qualquer objeto que exista durante toda a vida do programa. Normalmente faz-se assim:
private static object locker = new object();
Atenção: nunca use strings, números ou objetos que outra pessoa possa acessar acidentalmente! Use apenas objetos privados que você tem certeza que não são usados em mais nenhum lugar.
Corrigindo nosso exemplo
private static object locker = new object();
int counter = 0;
void IncrementCounter()
{
for (int i = 0; i < 100_000; i++)
{
lock (locker)
{
counter++; // Agora é atômico!
}
}
}
Agora duas ou dez threads vão entrar nesse pedaço de código em ordem. O resultado final será o perfeito 200_000. Gatinhos felizes!
3. Como lock funciona por baixo? A classe Monitor
Por baixo, a palavra-chave lock usa a classe System.Threading.Monitor. É como um secretário que só deixa entrar com permissão.
Sintaxe equivalente ao lock (mas mais "descoberta"):
Monitor.Enter(locker);
try
{
// Seção crítica
}
finally
{
Monitor.Exit(locker);
}
A diferença chave — você precisa garantir que Monitor.Exit seja chamado. Normalmente usamos try...finally para isso. Se esquecer de chamar Exit(), a thread fica "dentro" pra sempre, e as demais threads vão esperar eternamente — o programa trava como um Windows antigo instalando atualizações.
Tabela: lock vs. manual Monitor
| Método | Segurança contra erros | Mais fácil de escrever | Flexibilidade |
|---|---|---|---|
|
Sim | Sim | Não |
|
Somente com try/finally | Não | Sim |
Em 99% dos casos use lock. O Monitor manual é necessário só quando você precisa de máxima flexibilidade: por exemplo, se quiser implementar lock com timeout.
4. Argumentos para lock: o que pode e o que não pode?
Erro muito comum de iniciantes: usar uma string ou outro objeto "visível" para bloquear. Por exemplo:
lock ("mylock") { /*...*/ } // Muito ruim!
O problema é que strings são internadas (únicas para todo o aplicativo), é fácil ter conflito com bibliotecas de terceiros e acabar com uma aplicação "morta". Use sempre objetos privados:
private readonly object myLock = new object();
lock (myLock)
{
// só seu código conhece myLock
}
5. lock: exemplo com saída no console
Vamos treinar bastante! Criamos um mini-app onde duas threads imprimem linhas, mas o acesso ao console também é sincronizado — para que o texto não fique embaralhado.
private static object consoleLock = new object();
void PrintMessages(string name)
{
for (int i = 0; i < 5; i++)
{
lock (consoleLock)
{
Console.WriteLine($"{name}: Mensagem {i + 1}");
Thread.Sleep(50); // Modelando processamento
}
}
}
Thread t1 = new Thread(() => PrintMessages("Thread 1"));
Thread t2 = new Thread(() => PrintMessages("Thread 2"));
t1.Start();
t2.Start();
t1.Join();
t2.Join();
Resultado: as linhas aparecem sequencialmente, nada bagunçado. Essa abordagem é frequentemente usada para logging, para não ler "caracteres estranhos" nos logs.
6. Nuances úteis
Gerenciamento manual de bloqueio: Monitor avançado
Quando o lock padrão não é suficiente (por exemplo, se você quiser tentar entrar na seção sem esperar pra sempre), use Monitor.TryEnter.
if (Monitor.TryEnter(locker, 100)) // 100 ms de espera
{
try
{
// Seção crítica
}
finally
{
Monitor.Exit(locker);
}
}
else
{
Console.WriteLine("Não foi possível obter o bloqueio em 100 milissegundos");
}
É útil quando o programa não quer "travar" — por exemplo, podemos mostrar uma mensagem ao usuário ou fazer algo útil enquanto o recurso compartilhado está ocupado.
Visualização: como o bloqueio funciona (diagrama)
flowchart LR
A[Thread 1: quer entrar na seção crítica]
B[Thread 2: quer entrar na seção crítica]
C[locker livre]
D[Thread 1 executa código dentro do lock]
E[Thread 2 espera]
F[Thread 1 saiu do lock]
G[Thread 2 obtém acesso]
A -- Verifica locker --> C
C -- locker livre --> D
B -- Verifica locker --> D
D -- lock ocupado --> E
D -- Terminou trabalho --> F
F -- Liberou locker --> G
E -- locker agora livre --> G
Bloqueios e performance
Bloqueios funcionam simples: apenas uma thread por vez pode executar o código entre as chaves. Isso é ótimo para integridade dos dados, mas... quanto mais threads "na fila", mais lento tudo fica. Então sincronização não é panaceia: tente manter seções críticas o menor possível.
Dica de vida: se a execução da seção crítica leva frações de milissegundo — ótimo. Se contém cálculo longo, I/O, rede ou operações de arquivo — melhor separar isso fora do bloco lock. Primeiro leia/calculue, e só depois atualize rapidamente o estado compartilhado dentro da proteção.
Na entrevista e na prática
Em qualquer programa sério que use threads, empregadores vão perguntar: "O que fazer se duas threads acessam a mesma variável?" Mostre código com bloqueio — e seu currículo dificilmente sumirá num caixa-preta de triagem.
Na prática, especialmente em sistemas de alta carga, usam mecanismos de sincronização mais avançados — mas lock e Monitor continuam sendo padrão de ouro para casos simples.
7. Peculiaridades no uso de bloqueios e erros típicos
O erro mais comum — "esquecer" de usar o mesmo objeto como lock. Por exemplo:
void Foo() { lock (a) { ... } }
void Bar() { lock (b) { ... } }
Se ambos os métodos manipulam a mesma variável, mas os objetos a e b são diferentes, você criou proteção fictícia — as threads vão operar simultaneamente sobre a variável!
Conclusão: sempre use o mesmo objeto para proteger os mesmos dados.
Outro caso — usar um bloqueio demasiado "amplo". Por exemplo, fazer lock (this) dentro de uma classe normal, sem ter certeza de que ninguém de fora não vai usar esse objeto para lock. Isso pode causar deadlocks e outros bugs divertidos, mas indesejáveis.
E por fim: NÃO bloqueie operações longas ou externas (I/O, rede) dentro de lock. Você corre o risco de "travar" o acesso para outras threads por muito tempo, reduzindo a performance. Seção crítica = apenas o que realmente não pode ser feito em paralelo!
GO TO FULL VERSION