Software · Infraestrutura & Sistemas
Concorrência vs Paralelismo: Escalonamento, Threads e o Custo Real do Context Switch
Entenda como sistemas operacionais e runtimes modernos lidam com multitarefa, o impacto invisível da troca de contexto no pipeline da CPU e as estratégias para alta concorrência.
Em discussões de engenharia e otimização de sistemas de alto desempenho, os termos concorrência e paralelismo são frequentemente tratados como sinônimos intercambiáveis. Essa confusão conceitual é a causa raiz de arquiteturas ineficientes que tentam resolver problemas de concorrência criando centenas de threads de sistema operacional, culminando em exaustão de memória, saturação de CPU e degradação severa do throughput.
Como sintetizou Rob Pike: "Concorrência diz respeito à estrutura do código e à capacidade de lidar com muitas coisas ao mesmo tempo; paralelismo diz respeito à execução física simultânea de muitas coisas." Compreender essa fronteira técnica, associada aos custos do chaveamento de contexto no kernel e às técnicas de I/O assíncrono, é indispensável para construir serviços que operem de maneira previsível sob carga extrema.
Concorrência versus Paralelismo: A Diferença Estrutural
flowchart TB
subgraph Concurrency["CONCORRÊNCIA — 1 núcleo, tarefas intercaladas"]
direction LR
A1["Task A"] --> B1["Task B"] --> A2["Task A"] --> C1["Task C"] --> B2["Task B"]
end
subgraph Parallelism["PARALELISMO — múltiplos núcleos, execução simultânea"]
direction LR
Core0["Core 0"] --> PA["Task A contínua"]
Core1["Core 1"] --> PB["Task B contínua"]
Core2["Core 2"] --> PC["Task C contínua"]
end
classDef task fill:#172033,stroke:#38bdf8,color:#f8fafc;
classDef core fill:#2a1f32,stroke:#a855f7,color:#f8fafc;
class A1,B1,A2,C1,B2,PA,PB,PC task;
class Core0,Core1,Core2 core;
Concorrência: A Arte da Composição
A concorrência é uma propriedade arquitetural. Um programa concorrente decompõe um fluxo de trabalho em unidades discretas de execução que podem progredir de maneira independente e intercalada. Mesmo em um processador antigo com um único núcleo físico (single-core), é perfeitamente viável executar um sistema concorrente: o escalonador (scheduler) do sistema operacional distribui fatias de tempo (time slices, tipicamente entre 1ms e 10ms) entre os processos e threads, criando a percepção de simultaneidade para o usuário e mantendo a máquina responsiva durante esperas de disco ou rede.
Paralelismo: Execução Física Simultânea
O paralelismo exige suporte físico de hardware. Trata-se de pegar duas ou mais instruções computacionais e executá-las exatamente no mesmo ciclo de clock em núcleos de CPU distintos, threads de GPU ou nós distribuídos. Enquanto a concorrência resolve o problema de latência e coordenação (evitando que uma tarefa bloqueie as demais enquanto aguarda uma resposta externa), o paralelismo resolve o problema de throughput de processamento bruto (reduzindo pela metade o tempo de renderização de um vídeo ou de cálculo de um modelo matemático ao dividir o trabalho em dois núcleos).
Threads do Sistema Operacional e o Custo do Context Switch
Uma thread do sistema operacional (Kernel Thread) representa a menor unidade sequencial gerenciável pelo escalonador. Cada thread aloca um bloco de controle próprio (TCB - Thread Control Block) e uma pilha de execução dedicada na memória (Call Stack, variando tipicamente entre 512 KB e 2 MB dependendo do sistema operacional e da linguagem).
Quando o escalonador preemptivo decide retirar o controle da CPU da Thread 1 para concedê-lo à Thread 2, ocorre o Context Switch (Troca de Contexto). Esse procedimento não é gratuito e envolve um custo oculto expressivo:
- Troca de Modo (User Mode para Kernel Mode): A interrupção de relógio (timer interrupt) força a CPU a chavear do anel de execução 3 (espaço de usuário) para o anel 0 (kernel).
- Salvamento de Registradores: O estado volátil da Thread 1 (registradores de propósito geral, ponteiro de instrução
RIP/PC, ponteiro de pilhaRSP/SPe registradores de ponto flutuanteAVX/SSE) deve ser serializado no TCB em memória RAM. - Carga do Estado da Nova Thread: Os registradores correspondentes da Thread 2 são restaurados a partir da memória.
- Invalidade do Cache e TLB: Se a Thread 2 pertencer a um processo diferente, a tabela de páginas virtuais é trocada (
CR3em x86), forçando o descarte das entradas do Translation Lookaside Buffer (TLB). Além disso, as linhas de cache de dados e instruções L1/L2 que armazenavam informações da Thread 1 tornam-se "frias", desencadeando uma tempestade de cache misses enquanto a nova thread popula o cache novamente com suas próprias instruções.
Em servidores com milhares de threads do kernel em disputa (thread contention), a CPU pode passar mais de 40% dos seus ciclos apenas trocando contextos em vez de executar código útil da aplicação.
Comparativo de Abordagens de Concorrência
| Abordagem | Primitiva de Execução | Custo de Memória por Unidade | Mecanismo de Escalonamento | Custo de Chaveamento |
|---|---|---|---|---|
| Threads do Kernel (C, Java tradicional, C# tradicional) | 1:1 com Threads do SO | 1 MB a 2 MB (Stack fixa) | Kernel preemptivo (SO) | Alto (de 1 µs a 5 µs + cache miss) |
| Green Threads / Virtual Threads (Java 21+, Go goroutines) | M:N (M virtuais sobre N do kernel) | Poucos KB (2 KB a 4 KB, elástica) | Runtime cooperativo ou semi-preemptivo | Quase imperceptível (nanossegundos em user space) |
| Event Loop Assíncrono (Node.js, Rust Tokio, .NET Task) | Reatores orientados a eventos (epoll/kqueue) |
Minúsculo (objeto de estado / Promise) | Event loop central em uma única ou poucas threads | Baixo (resolução de callbacks e microtarefas) |
Desafios Críticos da Concorrência: A Física das Colisões
Ao compartilhar memória mutável entre múltiplos fluxos concorrentes sem isolamento adequado, surgem problemas clássicos da computação distribuída e concorrente:
1. Race Conditions (Condições de Corrida)
Ocorre quando o resultado final da operação depende da ordem temporal imprevisível em que as threads acessam ou modificam uma variável compartilhada. O clássico incremento count++ não é atômico no processador: ele é composto por três instruções (READ, ADD, WRITE). Se duas threads executarem READ simultaneamente, um dos incrementos será sumariamente descartado.
2. Deadlocks (Impasses)
Ocorre quando duas ou mais tarefas mantêm travas exclusivas (locks/mutexes) e tentam adquirir as travas mantidas pelas outras, formando uma dependência circular:
flowchart LR
ThreadA["Thread A<br/>mantém Lock 1"] -->|aguarda| Lock2["Lock 2"]
ThreadB["Thread B<br/>mantém Lock 2"] -->|aguarda| Lock1["Lock 1"]
Lock1 -. ocupado por .-> ThreadA
Lock2 -. ocupado por .-> ThreadB
classDef thread fill:#172033,stroke:#38bdf8,color:#f8fafc;
classDef blocked fill:#321c24,stroke:#f87171,color:#f8fafc;
class ThreadA,ThreadB thread;
class Lock1,Lock2 blocked;
Para evitar deadlocks, a engenharia estabelece regras estritas de aquisição de recursos: sempre obter bloqueios na mesma ordem global hierárquica e empregar timeouts em qualquer tentativa de aquisição de trava.
3. Starvation e Priority Inversion
- Starvation: Uma tarefa de menor prioridade é perpetuamente impedida de executar porque o escalonador continua alimentando tarefas de maior prioridade.
- Priority Inversion: Uma thread de alta prioridade fica bloqueada aguardando um recurso retido por uma thread de baixa prioridade, mas a thread de baixa prioridade não consegue concluir seu trabalho porque threads de média prioridade ocupam a CPU.
I/O Bloqueante versus I/O Não-Bloqueante Baseado em Eventos
O grande divisor de águas nos servidores de alta concorrência foi a transição do modelo "uma thread por conexão" para o modelo de I/O assíncrono baseado em multiplexadores de eventos do kernel:
- Modelo Histórico Bloqueante (Apache HTTPD clássico): 10.000 clientes conectados exigiam 10.000 threads do kernel. A máquina gastava 10 GB a 20 GB de RAM apenas para alocar as pilhas das threads, e o escalonador colapsava sob o peso das trocas de contexto.
- Modelo Assíncrono com Reatores (Nginx, Node.js, Kestrel, Envoy): Utiliza syscalls especializadas do Linux (
epoll), FreeBSD/macOS (kqueue) ou Windows (IOCP). Poucas threads de trabalho (tipicamente uma por núcleo físico) monitoram milhares de descritores de arquivo de soquete simultaneamente. Quando dados chegam pela placa de rede, o kernel acorda a thread responsável apenas para processar o pacote de bytes, mantendo o consumo de memória proporcional às conexões ativas e a CPU dedicada ao trabalho útil.
Boas Práticas de Concorrência em Sistemas Críticos
Em sistemas concorrentes de missão crítica, a gestão de threads e tarefas assíncronas costuma seguir quatro diretrizes práticas:
- Imutabilidade por Padrão: Dados compartilhados entre contextos assíncronos devem ser preferencialmente imutáveis. Se não há mutação, não há necessidade de locks e, consequentemente, não há possibilidade de race conditions.
- Uso Rigoroso de I/O Assíncrono (
async/awaitsem bloqueios síncronos): Eliminamos qualquer chamada bloqueante (.Result,.Wait()ou chamadas de arquivo síncronas) em caminhos críticos de API, prevenindo a exaustão do ThreadPool. - Segregação de CPU-Bound e I/O-Bound: Tarefas computacionalmente intensivas (como processamento de imagens, criptografia pesada ou compilações analíticas) são isoladas em workers dedicados ou filas assíncronas, impedindo que tarefas de CPU monopolizem os threads responsáveis por atender requisições web.
- Métricas de Fila e Contenção: Monitoramos sistematicamente o tamanho da fila do pool de threads, tempo de espera para agendamento de tasks e taxas de context switch por segundo, identificando gargalos antes que eles impactem a experiência do usuário final.