← todos os artigos

.NET

Como eu entendo concorrência em .NET

Eu achava que concorrência só significava threads. Ela cobre race conditions, deadlocks, transactions, e o lost update problem, e um lock em C# não protege em nada uma linha de database.

Como eu entendo concorrência em .NET

Quando ouvi a palavra concorrência pela primeira vez, achei que significava simplesmente usar múltiplas threads.

Depois de estudar mais sobre .NET e SQL Server, aprendi que concorrência é um assunto mais amplo. Ela acontece sempre que múltiplas operações estão em andamento e podem acessar ou modificar os mesmos resources. Duas threads mudando a mesma variável. Dois requests de API atualizando o mesmo registro de database. Duas transactions esperando por resources que uma segura da outra. Dois usuários editando a mesma informação quase ao mesmo tempo.

Como desenvolvedor júnior eu não preciso saber todo detalhe interno de threads, locks e do engine do SQL Server. Deveria entender os problemas mais comuns e as ferramentas disponíveis para lidar com eles.

O que concorrência realmente significa

Concorrência significa múltiplas operações progredindo durante o mesmo período.

Imagine uma API ASP.NET Core recebendo dois requests quase ao mesmo tempo:

Request A: Comprar o último produto disponível
Request B: Comprar o último produto disponível

Os dois requests podem ler que o produto tem uma unidade disponível. Se os dois continuarem sem nenhuma proteção, os dois podem completar a compra, e o sistema vende o mesmo item duas vezes.

É por isso que isso importa. Código que funciona corretamente com um usuário pode produzir resultados inesperados quando vários usuários batem nele simultaneamente.

Concorrência aparece em três níveis: dentro da aplicação .NET quando threads ou tasks compartilham dados, dentro do SQL Server quando transactions acessam as mesmas linhas ou tabelas, e entre instâncias da aplicação quando ela roda em múltiplos servers ou containers.

Esses níveis estão conectados, mas não são controlados da mesma forma. Um lock em C# pode proteger memória dentro de uma instância da aplicação. Ele não pode proteger uma linha do SQL Server de outro server da aplicação.

Race conditions

Uma race condition acontece quando o resultado de uma operação depende de qual thread chega primeiro num pedaço de código.

Considere um counter simples:

public class RequestCounter
{
    private int _count;

    public void Increment()
    {
        _count++;
    }
}

Aquele _count++ parece uma operação só, mas internamente são três passos: ler o valor atual, somar um, guardar o novo valor.

Suponha que o valor atual seja 10. Duas threads poderiam ler 10, calcular 11 cada uma, e guardar 11 as duas. O resultado esperado era 12. O resultado real é 11.

Para operações numéricas simples, o .NET fornece a classe Interlocked:

using System.Threading;

public class RequestCounter
{
    private int _count;

    public void Increment()
    {
        Interlocked.Increment(ref _count);
    }
}

Interlocked.Increment executa o incremento como uma operação atômica só, então nenhuma outra thread pode interferir no meio dela.

Usando lock em C#

Para operações mais complexas, o C# oferece a instrução lock:

public class BankAccount
{
    private readonly object _balanceLock = new();
    private decimal _balance;

    public void Deposit(decimal amount)
    {
        lock (_balanceLock)
        {
            _balance += amount;
        }
    }
}

Só uma thread pode executar a seção protegida usando aquele lock object por vez.

Adicionar locks em todo lugar não é uma boa solução, no entanto. Locks reduzem performance, fazem threads esperarem, aumentam a complexidade, introduzem deadlocks, e podem esconder problemas causados por design ruim. Onde possível, é melhor evitar shared mutable state do que proteger tudo com locks.

Um lock em C# também só funciona dentro do processo que o possui. Se uma aplicação roda em três servers, cada server tem seu próprio lock object. Concorrência de database tem que ser tratada pelo próprio database ou através de outro mecanismo distribuído.

Deadlocks

Um deadlock acontece quando duas operações esperam permanentemente por resources que uma segura da outra.

Thread A:
1. Trava o Resource 1
2. Espera pelo Resource 2

Thread B:
1. Trava o Resource 2
2. Espera pelo Resource 1

Nenhuma das duas threads consegue continuar. A mesma coisa acontece no SQL Server:

Transaction A:
1. Atualiza o Customer 1
2. Tenta atualizar o Customer 2

Transaction B:
1. Atualiza o Customer 2
2. Tenta atualizar o Customer 1

O SQL Server detecta esse ciclo e escolhe uma transaction como a vítima do deadlock. Essa transaction sofre rollback para que a outra possa continuar. Aplicações deveriam estar prontas para receber erros de deadlock, e dependendo da operação, tentar de novo pode ser apropriado.

Algumas formas básicas de reduzir deadlocks: manter transactions curtas, acessar resources numa ordem consistente, evitar operações desnecessárias dentro de transactions, criar índices apropriados, nunca esperar por input do usuário enquanto uma transaction está aberta, e implementar retry logic para operações que são seguras de repetir.

Deadlocks nem sempre podem ser eliminados em sistemas com alta concorrência. O objetivo é reduzir a frequência com que acontecem e tratá-los direito quando acontecem.

Blocking não é um deadlock

Eu costumava tratar essas duas coisas como a mesma coisa. Não são.

Blocking acontece quando uma operação espera porque outra segura um resource de que ela precisa:

Transaction A atualiza um produto e mantém a transaction aberta.
Transaction B tenta atualizar o mesmo produto.
Transaction B espera até a Transaction A terminar.

Essa espera pode ser completamente normal. Um deadlock exige uma dependência circular, onde A espera por B e B espera por A.

Blocking geralmente termina quando a primeira transaction faz commit ou rollback. Um deadlock não se resolve naturalmente, e é por isso que o SQL Server tem que interromper uma das transactions. Transactions de longa duração pioram o blocking, porque locks podem ficar retidos até a transaction terminar.

Por que transactions importam

Uma transaction de database agrupa várias operações numa única unidade lógica de trabalho. O exemplo clássico é transferir dinheiro:

1. Remover $100 da Account A.
2. Adicionar $100 na Account B.

As duas deveriam ter sucesso ou as duas deveriam falhar. Sem uma transaction, a primeira poderia ter sucesso e a segunda poderia falhar, e o dinheiro desaparece.

await using var transaction =
    await dbContext.Database.BeginTransactionAsync();

try
{
    sender.Balance -= 100m;
    receiver.Balance += 100m;

    await dbContext.SaveChangesAsync();
    await transaction.CommitAsync();
}
catch
{
    await transaction.RollbackAsync();
    throw;
}

O EF Core já envolve as mudanças de uma única chamada de SaveChanges numa transaction quando o provider suporta isso. Controle manual importa mais quando uma operação de negócio envolve múltiplas chamadas de SaveChanges, SQL raw, ou outras operações que precisam ter sucesso juntas.

ACID

Uma transaction confiável segue quatro propriedades.

Atomicity significa que a transaction é uma unidade só. Ou todas as operações acontecem ou nenhuma acontece. O SQL Server não pode debitar do remetente sem creditar no destinatário.

Consistency significa que a transaction deixa o database num estado válido, com constraints e regras de negócio ainda satisfeitas. Um campo obrigatório não deveria virar null, uma foreign key deveria continuar apontando para um registro existente, um saldo não deveria ficar negativo quando o negócio não permite isso.

Isolation controla como transactions concorrentes observam as mudanças umas das outras. Outro request não deveria ver o dinheiro depois que ele saiu do remetente mas antes de chegar ao destinatário.

Durability significa que, assim que uma transaction faz commit, suas mudanças sobrevivem mesmo que o server trave depois. O SQL Server usa seu transaction log para isso.

O problema do lost update

Esse é o problema de concorrência de database que achei mais fácil de entender.

Dois funcionários abrem o mesmo registro de cliente, onde o telefone é 1111. Funcionário A muda para 2222. Funcionário B, ainda segurando a versão antiga, muda para 3333. A salva primeiro, depois B salva.

Sem verificação de concorrência, a atualização de B sobrescreve a de A, e a mudança de A se perde sem ninguém ser avisado.

Optimistic concurrency

Optimistic concurrency assume que conflitos são possíveis mas incomuns. Em vez de travar um registro pelo tempo todo em que alguém está editando, a aplicação verifica se o registro mudou antes de salvar.

Ler uma linha e sua versão, deixar ela ser modificada, enviar a atualização com a versão original, atualizar só quando a versão ainda corresponder, e reportar um conflito quando não corresponder.

Isso combina com aplicações web, porque usuários não seguram locks de database abertos enquanto veem e editam páginas.

O EF Core suporta isso deixando uma propriedade ser configurada como um concurrency token. Durante uma atualização ele inclui o token original na cláusula WHERE do SQL. Se outro processo modificou a linha, nada corresponde, e o EF Core lança uma DbUpdateConcurrencyException.

rowversion do SQL Server

O SQL Server fornece o tipo rowversion. Apesar do seu sinônimo antigo timestamp, não é uma data nem uma hora. É um valor binário gerado automaticamente que muda sempre que a linha é atualizada, o que o torna útil para detectar se uma linha mudou desde que você a leu.

using System.ComponentModel.DataAnnotations;

public class Product
{
    public int Id { get; set; }

    public string Name { get; set; } = string.Empty;

    public decimal Price { get; set; }

    [Timestamp]
    public byte[] RowVersion { get; set; } = Array.Empty<byte>();
}

O SQL gerado segue essa ideia:

UPDATE Products
SET Price = @newPrice
WHERE Id = @id
  AND RowVersion = @originalRowVersion;

Se outro request já atualizou o produto, seu RowVersion é diferente, a atualização afeta zero linhas, e o EF Core detecta o conflito.

try
{
    await dbContext.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException)
{
    throw new InvalidOperationException(
        "This record was changed by another user. Reload it and try again.");
}

Numa API real isso vira uma resposta HTTP apropriada, geralmente 409 Conflict. A aplicação pode pedir para o usuário recarregar, mostrar os valores atuais, deixar ele escolher qual versão manter, fazer merge de mudanças compatíveis, ou repetir automaticamente onde isso é seguro.

Pessimistic e optimistic

Estratégias de concorrência se dividem em duas abordagens gerais.

Pessimistic concurrency assume que um conflito é provável, então o sistema trava o resource e outras operações esperam. Útil quando conflitos seriam caros ou operações precisam se coordenar estritamente, ao custo de mais blocking e risco de deadlock.

Optimistic concurrency assume que conflitos são incomuns. Operações prosseguem sem um lock longo, e a aplicação verifica que nada mudou antes de salvar. rowversion e concurrency tokens do EF Core são os exemplos comuns.

Nenhuma das duas é sempre melhor. Depende dos dados, do número esperado de conflitos, e das regras de negócio.

Isolation levels

Isolation levels controlam o quanto uma transaction é isolada das outras. Os principais níveis do SQL Server são READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SNAPSHOT, e SERIALIZABLE.

A lição importante no meu nível é que isolamento mais forte dá maior consistência mas aumenta o blocking e reduz a concorrência. Isolamento mais baixo melhora a concorrência mas pode deixar transactions observarem dados de formas que algumas operações de negócio não conseguem aceitar.

SNAPSHOT e opções baseadas em row version deixam leituras usarem versões de linha armazenadas em vez de esperar por writers, o que reduz o blocking entre leitura e escrita. Isso adiciona outras considerações, e não remove a necessidade de entender conflitos de atualização.

Eu não mudaria o isolation level só porque uma aplicação tem problemas de blocking. As queries, os índices, os limites de transaction, e o requisito de negócio real vêm todos primeiro.

O que eu tiro disso

Não assuma que uma linha significa uma operação atômica, porque _count++ são três passos e pode dar race.

Mantenha transactions curtas. Uma transaction deveria segurar as operações de database de uma única ação de negócio lógica, e não deveria ficar aberta enquanto chama APIs externas, roda cálculos longos, ou espera por input do usuário.

Não use um lock em C# como um lock de database. Ele protege memória dentro de um processo e não coordena nada entre servers.

Espere conflitos. Num sistema multiusuário, dois requests atualizando o mesmo registro não é um edge case impossível, e a aplicação deveria decidir o que acontece quando isso ocorre.

Entenda a regra de negócio primeiro, porque a solução certa depende da operação. Atualizar o título de um post do blog não é a mesma coisa que reservar o último quarto de hotel, processar um pagamento, reduzir estoque, transferir dinheiro, ou resgatar um voucher de uso único. Quanto mais importante a operação, mais cuidadosamente seu comportamento de concorrência precisa ser desenhado.

Tem muito mais para aprender aqui, incluindo tipos de lock, execution plans, deadlock graphs, estratégias de retry, e sistemas distribuídos. Essas bases já me ajudam a escrever aplicações mais seguras e identificar problemas que só aparecem quando vários requests rodam ao mesmo tempo.

Referências

  • Microsoft Learn, Managed Threading Best Practices: race conditions, synchronization, e Interlocked
  • Microsoft Learn, Synchronizing Data for Multithreading
  • Microsoft Learn, Deadlocks Guide for SQL Server
  • Microsoft Learn, Transaction Locking and Row Versioning Guide
  • Microsoft Learn, Handling Concurrency Conflicts in EF Core
  • Microsoft Learn, SQL Server Value Generation in EF Core
  • Microsoft Learn, Transactions in EF Core
  • Microsoft Learn, SET TRANSACTION ISOLATION LEVEL