Sobre esta aula
Na aula teórica vimos dois problemas que aparecem quando várias operações acontecem ao mesmo tempo, e uma ferramenta para cada um:
- Consistência — duas operações mexendo no mesmo dado (o
saldo) podem deixá-lo errado. Solução:lock. - Capacidade — operações pesadas demais rodando juntas sobrecarregam o servidor. Solução:
limitar quantas rodam ao mesmo tempo com
SemaphoreSlim.
Hoje vamos ver os dois problemas acontecendo na prática e corrigi-los.
Criando o Projeto no VS Code
- Abra o Visual Studio Code.
- Crie uma pasta com o seu nome em um local de que você vá se lembrar depois.
- No VS Code, vá em File > Open Folder e selecione a pasta criada.
- Abra o terminal em Terminal > New Terminal e execute um comando por vez:
dotnet new console -n Concorrencia
cd Concorrencia
dotnet run
Em cada exemplo abaixo, apague todo o conteúdo do Program.cs,
cole o código novo, salve e rode dotnet run.
Exemplo 1 — O problema do saldo
O mesmo cenário da teoria: saldo de R$ 1000 e dois saques ao mesmo tempo (R$ 600 e R$ 500).
O Thread.Sleep apenas simula um pequeno atraso entre
ler o saldo e gravar o novo valor — é nesse intervalo que o problema acontece.
decimal saldo = 1000;
void Sacar(decimal valor)
{
decimal lido = saldo; // 1. ler saldo
Thread.Sleep(100); // simula um pequeno atraso
if (lido >= valor) // 2. verificar
{
saldo = lido - valor; // 3. calcular e 4. gravar
Console.WriteLine($"Saque de R$ {valor} APROVADO");
}
else
{
Console.WriteLine($"Saque de R$ {valor} NEGADO");
}
}
Task t1 = Task.Run(() => Sacar(600));
Task t2 = Task.Run(() => Sacar(500));
await Task.WhenAll(t1, t2);
Console.WriteLine($"Saldo final: R$ {saldo}");
Task.Run, threads e escalonador
- Thread é uma "linha de execução": um trabalhador que executa código. Até hoje nossos programas tinham só uma (o "cozinheiro único" da teoria).
Task.Run(...)não executa o código na hora: ele coloca o trabalho numa fila do thread pool, um conjunto de threads que o .NET já mantém prontas. Uma dessas threads pega o trabalho e o executa, em paralelo com o resto do programa.- Quem decide qual thread usa a CPU e quando é o escalonador do sistema operacional. Ele pode pausar uma thread a qualquer momento e dar a vez a outra. Por isso, a ordem das linhas no código não garante a ordem de execução entre threads.
await Task.WhenAll(t1, t2)faz o programa esperar as duas tarefas terminarem antes de mostrar o saldo final.
Desafios:
- Rode o código. Os dois saques foram aprovados? Qual o saldo final? Faz sentido?
- Rode mais algumas vezes. O saldo final é sempre o mesmo?
Pense e responda:
- No código, o saque de R$ 600 aparece antes do de R$ 500, e os dois têm o mesmo
Thread.Sleep(100). Mesmo assim, a cada execução um saque diferente pode terminar primeiro. Por quê? (Dica: quem decide qual thread roda primeiro é o sistema operacional, não a ordem das linhas no código.)
Exemplo 2 — Corrigindo com lock
Ler, verificar, calcular e gravar formam uma região crítica: precisam acontecer como uma
coisa só. O lock garante que apenas uma operação por vez
entre nesse bloco — a outra espera.
decimal saldo = 1000;
object bloqueio = new object();
void Sacar(decimal valor)
{
lock (bloqueio)
{
decimal lido = saldo;
Thread.Sleep(100);
if (lido >= valor)
{
saldo = lido - valor;
Console.WriteLine($"Saque de R$ {valor} APROVADO");
}
else
{
Console.WriteLine($"Saque de R$ {valor} NEGADO");
}
}
}
Task t1 = Task.Run(() => Sacar(600));
Task t2 = Task.Run(() => Sacar(500));
await Task.WhenAll(t1, t2);
Console.WriteLine($"Saldo final: R$ {saldo}");
Desafios:
- Rode várias vezes. Agora quantos saques são aprovados? O saldo final está correto?
- Mova o
lockpara proteger apenas a linhasaldo = lido - valor;. O problema volta? Por quê?
Pense e responda:
- Rodando várias vezes, às vezes é aprovado o saque de R$ 600 e às vezes o de R$ 500. Isso é
um erro? O
lockgarante a consistência (nunca aprova os dois) ou também garante a ordem em que os saques acontecem?
Exemplo 3 — Limitando relatórios com SemaphoreSlim
Agora o problema é outro: 10 relatórios pesados chegam juntos, mas o servidor aguenta no máximo
3 ao mesmo tempo. O Task.Delay(1000) simula 1 segundo
de processamento de cada relatório.
using System.Diagnostics;
SemaphoreSlim limite = new SemaphoreSlim(3); // 3 permissões
Stopwatch relogio = Stopwatch.StartNew();
async Task GerarRelatorioAsync(int numero)
{
await limite.WaitAsync(); // pega uma permissão (ou espera)
try
{
Console.WriteLine($"[{relogio.Elapsed.TotalSeconds:F1}s] Relatório {numero} começou");
await Task.Delay(1000); // simula o processamento
Console.WriteLine($"[{relogio.Elapsed.TotalSeconds:F1}s] Relatório {numero} terminou");
}
finally
{
limite.Release(); // devolve a permissão
}
}
List<Task> tarefas = new List<Task>();
for (int i = 1; i <= 10; i++)
{
tarefas.Add(GerarRelatorioAsync(i));
}
await Task.WhenAll(tarefas);
Console.WriteLine($"Tempo total: {relogio.Elapsed.TotalSeconds:F1}s");
Desafios:
- Rode e observe os horários: quantos relatórios começam juntos? Qual o tempo total?
- Troque o limite para
1e depois para10. O que acontece com o tempo total em cada caso? - Comente a linha
limite.Release();e rode. O que acontece? Por que oReleasefica dentro dofinally?
Exercício — Fila virtual do show
Saíram os ingressos do show mais esperado do ano: são só 10 ingressos e 30 fãs tentando comprar no mesmo instante. Para o site não cair, ele usa uma fila virtual: só 3 pessoas por vez podem estar na tela de compra. Quem está na tela leva um tempinho escolhendo o assento e pagando, e então o sistema tenta reservar o ingresso.
Ou seja, aparecem os dois problemas da aula ao mesmo tempo: capacidade (quantos entram na tela de compra) e consistência (quantos ingressos ainda restam).
Use os Exemplos 2 e 3 como base e escreva um programa que:
- Tenha uma variável
int ingressos = 10;e um contador de ingressos vendidos. - Tenha um método
async Task ComprarAsync(int pessoa)que:- entra na fila virtual com um
SemaphoreSlimde 3 permissões; - simula a escolha do assento e o pagamento com
await Task.Delay(500);; - reserva o ingresso: lê quantos restam, espera um pouco (
Thread.Sleep(50);), e, se ainda houver, grava o novo valor e imprime "Pessoa X comprou"; senão, imprime "Pessoa X: esgotado"; - sai da fila virtual, liberando a vaga para o próximo.
- entra na fila virtual com um
- Dispare as 30 compras ao mesmo tempo, espere todas terminarem e mostre quantos ingressos foram vendidos e o tempo total.
Não é permitido usar await dentro de um bloco
lock. Por isso o Task.Delay
fica fora do lock, e só a reserva do ingresso fica dentro.
Desafios:
- Primeiro faça sem
lock. Quantos ingressos foram vendidos? - Agora proteja a reserva com
lock. Foram vendidos exatamente 10? - Antes de rodar, calcule o tempo total com a fila de 3 pessoas. Depois mude a fila para 1 e para 30 e compare.
Pense e responda:
- Se o
SemaphoreSlimjá limita a fila a 3 pessoas, por que ainda foi preciso usarlock? - Com a fila em 1, o problema de vender demais acontece? Mesmo assim, por que não seria uma boa ideia usar fila de 1 em um site de ingressos de verdade?
Para refletir
| Problema | Pergunta | Mecanismo |
|---|---|---|
| Capacidade | Quantas operações podem executar ao mesmo tempo? | SemaphoreSlim |
| Consistência | Como evitar que operações concorrentes corrompam um dado compartilhado? | lock |
Repare que um SemaphoreSlim(1) também deixa passar só uma
operação por vez. Ainda assim, guarde a regra da aula: SemaphoreSlim
controla concorrência; lock protege estado compartilhado.