Thread-safety em .NET 10 e Python: GIL e race conditions
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.
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:
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çãox++falha na primeira e na segunda. OConcurrentDictionarytenta resolver isso sem exigirlockmanual em cada operação. OInterlockedresolve 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.
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
lockquando você precisa proteger um bloco de código com múltiplas operações e invariantes mais complexos; - use
Interlockedquando você precisa apenas proteger uma variável primitiva com uma operação simples.
Exemplo comInterlocked:
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.
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,KeyNotFoundExceptioninesperado, lista mutável exposta, ou simplesmente dados inconsistentes.
Um exemplo clássico:
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:
DbContextnunca 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:
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:
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 10 | Python |
|---|---|---|
| Bloqueio simples | System.Threading.Lock | threading.Lock |
| Contador atômico | Interlocked.Increment | threading.Lock ou itertools.count |
| Dicionário concorrente | ConcurrentDictionary | dict com lock ou queue |
| Limite de concorrência | SemaphoreSlim | threading.Semaphore |
| Fila produtor/consumidor | Channel | queue.Queue / asyncio.Queue |
| Estado imutável | ImmutableDictionary | frozenset, tuple, MappingProxyType |
| Parallelismo assíncrono | Task, await | asyncio, ThreadPoolExecutor |
| Processamento CPU-bound | Parallel.ForEach, Task | ProcessPoolExecutor |
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:
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.
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:
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.Lockem blocos complexos em vez de usar objetos arbitrários como sincronizadores. - Em
Singleton, assuma que o estado mutável precisa de proteção; emScopedeTransient, a maioria das instâncias não é compartilhada. - Em Python,
GILnã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 deDictionaryeListexpostos. - Preserve snapshots quando for expor estado interno para leitura; evite retornar dicionários ou listas mutáveis do objeto compartilhado.
📖 Artigo completo com exemplos de código: Thread-safety em .NET 10 e Python: GIL e race conditions