Introdução

Quando alguém diz que “esse serviço é thread-safe”, a frase está dizendo algo simples e sério: ele pode ser acessado por múltiplas threads ao mesmo tempo sem criar corrupção de estado, resultados intermitentes ou condições de corrida. Em produção, isso é matéria de arquitetura, não apenas de curiosidade acadêmica. Um Singleton com Dictionary interno pode funcionar em testes e falhar em produção sob carga. Um cache compartilhado sem controle de concorrência pode perder itens sem qualquer erro aparente. E o problema não é novo: ele acompanha aplicações web, workers, filas, serviços de background e processamento em lote.

O objetivo deste artigo é mostrar exatamente como isso acontece em duas plataformas muito usadas no ecossistema moderno: .NET 10 e Python. Em .NET, a resposta costuma passar por System.Threading.Lock, Interlocked, ConcurrentDictionary e escolhas de lifetime em DI. Em Python, a conversa rapidamente entra no GIL (Global Interpreter Lock), no modelo de threading, em queue.Queue e, hoje, em um cenário mais relevante: o Python free-threaded, que remove parte dessa garantia e devolve ao desenvolvedor a responsabilidade direta pela sincronização.

A boa notícia é que o raciocínio é o mesmo nos dois lados: estado mutável compartilhado exige proteção ou eliminação do compartilhamento. Se o estado é imutável, a coisa fica mais simples. Se o estado é compartilhado e mutável, você precisa pensar em invariantes, acesso atômico e testabilidade. Isso é o que vou mostrar aqui com exemplos práticos e com um padrão de teste que prova se o serviço aguenta concorrência de verdade.

⚠️ Atenção: O problema não é “muitas threads ao mesmo tempo” em si. O problema é o acesso simultâneo a estado compartilhado sem uma regra de sincronização. Sem esse detalhe, qualquer serviço pode parecer estável em ambiente de laboratório e quebrar sob carga real.

Comparação entre .NET 10 e Python em concorrência, GIL e sincronização de estado compartilhado

O que significa thread-safe na prática

Thread-safe é uma propriedade do comportamento do componente, não apenas da linguagem. Uma implementação é thread-safe quando, sob qualquer interleaving válido de threads, a estrutura de dados e o comportamento observável continuam consistentes. Em outras palavras: o objeto continua correto mesmo quando duas ou mais threads tentam usar o mesmo recurso ao mesmo tempo.

Para ajudar a ser mais concreto, pense em um contador simples:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public class CounterService
{
    private int _count;

    public void Increment()
    {
        _count++;
    }

    public int GetValue()
    {
        return _count;
    }
}

Esse código parece trivial. Na prática, ++ em um inteiro não é uma operação atômica. A sequência real é algo como: ler o valor, somar 1, escrever de volta. Se duas threads fizerem isso ao mesmo tempo, ambas podem ler o mesmo valor antigo e escrever o mesmo resultado final. O contador “perde” um incremento, e a falha é chamada de race condition.

Isso é importante porque muitos serviços de produção têm estado compartilhado em um Singleton, em um cache em memória, em um acumulador de estatísticas, em um processamento em lote e em filas. Quando isso acontece sem sincronização, o problema é não apenas teórico — ele aparece como contadores inconsistentes, listas parcialmente preenchidas, dicionários quebrados ou erros intermitentes que parecem aleatórios.

Quatro propriedades que importam

Quando você trabalha com concorrência, normalmente quer responder quatro perguntas:

  • O estado pode ser acessado por múltiplas threads ao mesmo tempo?
  • Essa operação tem semântica atômica?
  • A leitura e a escrita têm visibilidade consistente entre threads?
  • A mutação preserva invariantes do objeto?

Se a resposta for “sim” para a primeira, você precisa considerar sincronização ou imutabilidade. A operação x++ falha na primeira e na segunda. O ConcurrentDictionary tenta resolver isso sem exigir lock manual em cada operação. O Interlocked resolve o caso mais simples de contador ou valor atômico.

.NET 10: o que mudou e quando usar System.Threading.Lock

No .NET, a forma clássica de proteger um bloco era usar lock em um objeto: lock (_lock) { ... }. Isso funciona, mas o padrão antigo deixa o desenvolvedor escrever suporte de sincronização de forma pouco explícita. A partir do .NET 9, a biblioteca padrão passou a oferecer o tipo System.Threading.Lock, que deixa a intenção mais clara.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public sealed class CounterWithLock
{
    private readonly Lock _lock = new();
    private int _count;

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

    public int GetValue()
    {
        lock (_lock)
        {
            return _count;
        }
    }
}

A ideia do Lock é muito parecida com o lock tradicional, mas a API tem uma intenção mais explícita e ajuda a evitar armadilhas de sincronização com objetos escolhidos de forma ingênua. Isso é importante porque lock (this) ou lock (new object()) costumam causar bugs reais em arquitetura. O Lock também deixa o código mais legível e mais específico ao que está protegendo.

Quando usar lock e quando usar Interlocked

A regra prática é simples:

  • use lock quando você precisa proteger um bloco de código com múltiplas operações e invariantes mais complexos;
  • use Interlocked quando você precisa apenas proteger uma variável primitiva com uma operação simples.

Exemplo com Interlocked:

1
2
3
4
5
6
7
8
public sealed class CounterWithInterlocked
{
    private int _count;

    public void Increment() => Interlocked.Increment(ref _count);

    public int GetValue() => Interlocked.CompareExchange(ref _count, 0, 0);
}

O Interlocked opera em nível mais baixo e evita contensão desnecessária em operações simples. Ele é especialmente útil para contadores, flags de estado e regras de sincronização rápidas. Já o lock continua sendo a ferramenta certa quando a correção exige isolar uma sequência de passos e garantir que a operação inteira execute de forma indivisível.

ConcurrentDictionary e coleções concorrentes

Uma parte importante de thread-safety em .NET está nas coleções do namespace System.Collections.Concurrent.

1
2
3
4
5
6
private readonly ConcurrentDictionary<string, int> _acessosPorChave = new();

_acessosPorChave.AddOrUpdate(
    key: "requests",
    addValue: _ => 1,
    updateValueFactory: (_, current) => current + 1);

Esse padrão é usado em serviços de cache e agregações concorrentes. A ideia é que a estrutura já sabe como sincronizar a escrita e a leitura internamente. Isso é muito melhor que um Dictionary compartilhado e acessado por várias threads, especialmente em cenários de alta carga. Em um singleton, um Dictionary comum é uma armadilha clássica. Em um Scoped ou Transient, geralmente não exigirá sincronização porque o objeto não será compartilhado entre requisições.

DI em ASP.NET Core: Singleton é o ponto crítico

O container de DI do ASP.NET Core muda a regra do jogo. O lifetime do serviço define se o mesmo objeto será compartilhado por múltiplas requisições ou não.

Regra prática

  • AddSingleton — o mesmo objeto vive para toda a aplicação. Se ele tiver estado mutável, ele precisa ser thread-safe.
  • AddScoped — normalmente cada requisição recebe sua própria instância. Em geral, não exige thread-safety.
  • AddTransient — cria uma instância por injeção. Em geral, também não exige thread-safety.

Isso é direto, mas a parte perigosa é a seguinte: muita gente registra um “cache em memória” como singleton porque parece simples, mas depois o mesmo objeto é consultado e atualizado por múltiplas requisições ao mesmo tempo. O desenho leva a race condition, KeyNotFoundException inesperado, lista mutável exposta, ou simplesmente dados inconsistentes.

Um exemplo clássico:

1
2
3
4
5
6
7
8
9
builder.Services.AddSingleton<UserCache>();

public sealed class UserCache
{
    private readonly Dictionary<int, User> _users = new();

    public void Add(User user) => _users[user.Id] = user;
    public User? Get(int id) => _users.TryGetValue(id, out var user) ? user : null;
}

Esse serviço parece inofensivo, mas ele não é thread-safe. Se a mesma instância for usada por múltiplas requisições simultaneamente, a mutação de Dictionary causa problemas reais. A correção é usar ConcurrentDictionary, ImmutableDictionary ou encapsular a mutação em lock quando a operação exige uma sequência de passos.

ℹ️ Informação: DbContext nunca deve ser singleton por projeto. Ele não é thread-safe e também não foi desenhado para abertura compartilhada entre requisições.

Python: o GIL e o mundo em que ele não resolve tudo

Em Python, o cenário é diferente. O interpretador CPython usa o GIL (Global Interpreter Lock), que serializa a execução de bytecode em cada processo. Em outras palavras, em uma mesma máquina com o interprete padrão, a linguagem evita que duas threads Python interpretem bytecode ao mesmo tempo.

Isso cria um mito recorrente: “se o Python tem GIL, então a aplicação é thread-safe por padrão”. Isso é falso. O GIL não resolve invariantes compostos e não torna o código thread-safe em sentido de arquitetura. Ele apenas impede a execução simultânea de bytecode Python dentro do mesmo processo.

O GIL ajuda em cenários I/O-bound, como chamadas de rede, arquivos e sockets, porque uma thread pode ficar esperando e outra pode executar. Mas ele não protege um objeto mutável compartilhado quando a operação real exige várias etapas, e não dá nenhuma garantia de consistência para estruturas mutáveis complexas.

Exemplo clássico em Python:

1
2
3
4
5
6
7
counter = 0

# thread A
counter += 1

# thread B
counter += 1

Esse código parece simples, mas não é atomicamente seguro em termos de semântica do programa. O GIL não garante que a sequência inteira de leitura + cálculo + escrita ocorra sem interferência. Os detalhes implementacionais podem variar e a correção exige sincronização explícita com threading.Lock ou uma estrutura projetada para concorrência.

Python 3.13 e o free-threaded mode

A comunidade Python tem discutido muito a ideia de remover ou tornar opcional o GIL. Em 2023/2024, o projeto passou para um estágio mais realista com o free-threaded Python (ou Python sem GIL em execução), uma mudança importante para o ecossistema. Isso significa que, em algumas builds, múltiplas threads podem executar bytecode Python em paralelo, o que faz o problema de sincronização voltar ao centro da discussão.

Em outras palavras:

  • com GIL, a concorrência em Python ainda exige cuidado;
  • sem GIL, a necessidade de sincronização explícita fica mais óbvia;
  • o dia a dia real da engenharia continua sendo: proteger estado compartilhado e testar invariantes.

Primitivas de concorrência em Python

Python oferece opções bem conhecidas:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import threading

lock = threading.Lock()
value = 0


def increment():
    global value
    with lock:
        value += 1

Também existem:

  • threading.RLock — lock reentrante;
  • threading.Semaphore — limite de acesso concorrente;
  • threading.Event — sinalização entre threads;
  • queue.Queue — fila thread-safe para comunicação entre producer/consumer;
  • concurrent.futures — executa tarefas em pool de threads ou processos.

Essas ferramentas têm equivalência conceitual com o que o .NET oferece, mas a semântica do runtime muda. Em Python, especialmente em CPython, a arquitetura do GIL e a presença de free-threaded mode podem mudar o nível de paralelismo e as dicas de design.

Comparativo lado a lado: .NET 10 vs Python

A tabela abaixo ajuda na análise prática:

Necessidade.NET 10Python
Bloqueio simplesSystem.Threading.Lockthreading.Lock
Contador atômicoInterlocked.Incrementthreading.Lock ou itertools.count
Dicionário concorrenteConcurrentDictionarydict com lock ou queue
Limite de concorrênciaSemaphoreSlimthreading.Semaphore
Fila produtor/consumidorChannel<T>queue.Queue / asyncio.Queue
Estado imutávelImmutableDictionaryfrozenset, tuple, MappingProxyType
Parallelismo assíncronoTask, awaitasyncio, ThreadPoolExecutor
Processamento CPU-boundParallel.ForEach, TaskProcessPoolExecutor

A principal diferença conceitual é esta: no .NET, o modelo de threads e o memory model são mais explícitos; no Python, a existência do GIL e do modelo cooperativo de asyncio cria mais camadas de abstração. Mas, em ambos os casos, o problema é o mesmo: a sincronização precisa existir quando você tem estado compartilhado mutável.

Como testar se um serviço é thread-safe

O melhor jeito de testar não é “olhar o código” e achar que parece seguro. O melhor jeito é construir um teste de estresse com múltiplas threads, executar várias operações e validar uma invariante. O padrão é simples: N threads, K iterações, um total esperado e uma assertiva para garantir que o valor final seja sempre o mesmo.

Em .NET, isso fica assim:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static void ExecutarConcorrencia(IContadorService contador)
{
    const int threads = 32;
    const int iteracoes = 50_000;
    var barreira = new Barrier(threads);

    var tarefas = Enumerable.Range(0, threads)
        .Select(_ => Task.Run(() =>
        {
            barreira.SignalAndWait();
            for (var i = 0; i < iteracoes; i++)
            {
                contador.Incrementar();
            }
        }))
        .ToArray();

    Task.WaitAll(tarefas);

    var totalEsperado = (long)threads * iteracoes;
    Console.WriteLine($"Esperado: {totalEsperado} | Atual: {contador.Valor}");
}

Se a implementação for insegura, o total final será menor do que o esperado em algumas execuções. Isso é típico de race condition: o problema aparece de forma probabilística e intermitente. Não basta rodar uma vez. Vale repetir várias rodadas para validar.

Isso é exatamente o que o repositório de exemplos do blog demonstra: um serviço sem proteção, um serviço com Lock e um serviço com Interlocked. O mesmo padrão é fácil de repetir em Python com threading.Thread e threading.Lock.

📂 Código Fonte: O exemplo completo está disponível no repositório de exemplos do blog: BlogSamples/AsyncParallel/ThreadSafety/

Exemplo prático em C#

Abaixo está um exemplo mínimo com duas versões do mesmo serviço: uma insegura e outra segura. O objetivo aqui não é mostrar um framework completo; é demonstrar o conceito com precisão.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public interface IContadorService
{
    void Incrementar();
    long Valor { get; }
}

public sealed class ContadorNaoSeguro : IContadorService
{
    private long _valor;

    public long Valor => _valor;

    public void Incrementar() => _valor++;
}

public sealed class ContadorComLock : IContadorService
{
    private readonly Lock _sincronizacao = new();
    private long _valor;

    public long Valor
    {
        get
        {
            lock (_sincronizacao)
            {
                return _valor;
            }
        }
    }

    public void Incrementar()
    {
        lock (_sincronizacao)
        {
            _valor++;
        }
    }
}

A estrutura acima já explica muita coisa. A versão insegura perde incrementos sob concorrência; a versão protegida mantém o valor consistente. O próximo passo, em um serviço real, é decidir se a solução correta é lock, Interlocked, ConcurrentDictionary ou imutabilidade completa. Isso depende do tipo de operação e do grau de mutação que o objeto precisa suportar.

Exemplo realista: ConcurrentDictionary em singleton

Um singleton com agregação de estatísticas é um caso clássico de uso correto de ConcurrentDictionary:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public sealed class EstatisticasService
{
    private readonly ConcurrentDictionary<string, long> _contagemPorChave = new();

    public void Registrar(string chave, long incremento = 1)
    {
        _contagemPorChave.AddOrUpdate(
            chave,
            _ => incremento,
            (_, atual) => atual + incremento);
    }

    public long Obter(string chave) =>
        _contagemPorChave.TryGetValue(chave, out var valor) ? valor : 0;
}

Esse padrão é muito útil quando a aplicação registra eventos, métricas, contadores por usuário ou por endpoint. A estrutura já implementa a sincronização interna, então o serviço pode continuar sendo um singleton sem virar uma fonte de race condition.

Dicas e boas práticas

  • Prefira imutabilidade quando possível. Objetos imutáveis são mais fáceis de compartilhar sem sincronização.
  • Em .NET 10, prefira System.Threading.Lock em blocos complexos em vez de usar objetos arbitrários como sincronizadores.
  • Em Singleton, assuma que o estado mutável precisa de proteção; em Scoped e Transient, a maioria das instâncias não é compartilhada.
  • Em Python, GIL não é a mesma coisa que thread-safety; sincronize sempre quando o estado compartilhado é mutável.
  • Teste concorrência com múltiplas threads e assertivas sobre invariantes, não apenas com execução unitária simples.
  • Quando o padrão exige para cada operação, use ConcurrentDictionary, Channel<T> ou filas em vez de Dictionary e List expostos.
  • Preserve snapshots quando for expor estado interno para leitura; evite retornar dicionários ou listas mutáveis do objeto compartilhado.

Resumo Objetivo

  • Thread-safety — é a garantia de que um componente mantém estado consistente mesmo sob acesso simultâneo de múltiplas threads.
  • System.Threading.Lock no .NET 10 — oferece uma primitiva explícita e mais legível para proteger blocos de código e invariantes complexos.
  • Interlocked — é ideal para contadores simples e operações atômicas de variável única, sem bloquear o sistema inteiro.
  • Singleton em ASP.NET Core — exige atenção redobrada porque o mesmo serviço pode atender múltiplas requisições em paralelo.
  • Dictionary e List em singleton — são clássicos exemplos de código que funciona em testes e falha em produção sob carga.
  • GIL em Python — ajuda em algumas situações, mas não substitui sincronização de estado compartilhado nem elimina race conditions.
  • Python free-threaded — reforça ainda mais que sincronização explícita continua sendo responsabilidade do desenvolvedor.
  • Teste de concorrência — N threads × K iterações com invariant check é o jeito mais confiável de determinar se o serviço realmente aguenta carga.

Leia Também

Referências