Concorrência - Aula Prática

Programação Aplicada em .NET • Prof. Me. William Tenório

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:

Hoje vamos ver os dois problemas acontecendo na prática e corrigi-los.

Criando o Projeto no VS Code

  1. Abra o Visual Studio Code.
  2. Crie uma pasta com o seu nome em um local de que você vá se lembrar depois.
  3. No VS Code, vá em File > Open Folder e selecione a pasta criada.
  4. 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:

Pense e responda:

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:

Pense e responda:

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:

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:

  1. Tenha uma variável int ingressos = 10; e um contador de ingressos vendidos.
  2. Tenha um método async Task ComprarAsync(int pessoa) que:
    • entra na fila virtual com um SemaphoreSlim de 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.
  3. 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:

Pense e responda:

Para refletir

ProblemaPerguntaMecanismo
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.