A abstração de processo nasceu em 1965 no projeto Multics da Bell Labs, MIT e General Electric — o sistema operacional que pretendia fazer pelo computer time-sharing o que a rede telefônica fazia pela comunicação. Multics fracassou comercialmente, mas emprestou ao seu sucessor de Bell Labs, Unix, o conceito que iria definir todo computador moderno: cada programa em execução é um processo isolado, com seu próprio espaço de memória, suas próprias file descriptors, e seu próprio direito de ser interrompido pelo kernel a qualquer instante. Threads vieram depois, nos anos 1980, como refinamento — múltiplos fluxos de execução compartilhando o mesmo espaço de memória, baratos para criar comparados a um processo inteiro.
Sessenta anos depois, qualquer programa não-trivial usa threads ou tem alguém usando por baixo. Servidores web criam threads para atender requisições; bancos de dados disparam threads para queries paralelas; até linguagens "single-threaded" como Python e JavaScript têm threads dentro do interpretador para garbage collection, finalização e I/O assíncrono. Você não escolhe ter threads — escolhe quantas, e como elas se relacionam com o que o SO consegue executar.
Os modelos de programação que vêm nos próximos conceitos —
async/await, structured concurrency, goroutines, actors — existem
em larga medida como respostas a uma única pergunta:
quanto custa uma thread?. A resposta tem várias
dimensões (memória, scheduler overhead, context switch, cache),
e cada modelo otimiza para algumas em troca de outras. Para
entender por que goroutines e async existem, é preciso
primeiro entender o que threads tradicionais entregam — e o que
cobram.
Este conceito recapitula em profundidade o que o módulo 01 cobriu em superfície, agora sob a lente da concorrência. Se aquele módulo explicou que o SO tem um scheduler, este vai mostrar como ele escolhe, com que custo, e como você observa essa escolha acontecendo em produção.
Processo vs thread — em uma figura mental
Um processo, em Linux, é uma task_struct — uma
estrutura no kernel que carrega: o espaço de endereçamento virtual
(page tables), file descriptors, sinais pendentes, identidade
(PID, UID, GID), credenciais de segurança, e ponteiros para suas
threads. Mata-se um processo, libera-se tudo. Cria-se um processo
via fork() ou, modernamente, clone() com
flags específicas; o overhead é alto comparado a outras operações
do kernel.
Uma thread, no Linux, é também uma task_struct — mas
criada com flags que fazem ela compartilhar com outras
threads do mesmo processo o espaço de memória, file descriptors,
sinais e mais. Ela tem identidade própria (TID), seu próprio
stack, e seus próprios registradores. Para o scheduler,
thread e processo são igualmente unidades de execução; a diferença
é o que compartilham. Esse é o modelo "1:1" que dominou Linux
desde NPTL (2003) e é o motivo pelo qual ps -eLf
lista threads como entidades schedulableis individuais.
A consequência prática é que threads dentro de um mesmo processo podem ler e escrever a mesma memória sem syscall — isso é barato. Processos distintos precisam de IPC para se comunicarem (pipes, shared memory mapeada, sockets locais) — isso custa mais. O lado escuro é que threads compartilhando memória abrem espaço para todas as classes de bug que os próximos conceitos vão tratar: races, deadlocks, ordering. Isolamento custa coordenação; compartilhamento custa segurança.
O scheduler — quem decide quem roda
A qualquer instante, há tipicamente muito mais threads runnable (prontas para executar) do que cores físicos disponíveis. O scheduler do kernel é o componente que decide qual thread usa qual core e por quanto tempo. A escolha errada penaliza latência (uma thread crítica espera demais) ou throughput (cores ociosos enquanto há trabalho). Um sistema operacional moderno gasta uma quantidade não-trivial de engenharia em scheduler.
Linux usou de 2007 até 2023 o Completely Fair Scheduler
(CFS), de Ingo Molnar, baseado na ideia de tempo virtual: cada
thread acumula vruntime conforme executa, e o
scheduler sempre escolhe a thread runnable com menor
vruntime. Isso aproxima alocação proporcional sem
precisar de filas de prioridade complicadas. Em outubro de 2023,
kernel 6.6 substituiu CFS por EEVDF (Earliest Eligible
Virtual Deadline First), que melhora latência de tarefas
interativas mantendo a fairness do CFS — mudança grande, ainda
digerida por sistemas em produção em 2026.
Windows usa scheduler baseado em prioridade com 32 níveis e boosting dinâmico (uma thread acordada por I/O ganha prioridade temporária para reduzir latência percebida pelo usuário). macOS evoluiu para um scheduler similar a EEVDF após anos com Mach-derivado. Os detalhes mudam por SO, mas as duas decisões fundamentais são iguais: quem roda agora, e por quanto tempo.
Preempção — interrupção a qualquer instante
Linux, Windows e macOS são sistemas operacionais preemptivos. Isso significa que o scheduler pode interromper qualquer thread em qualquer instrução, salvar seu estado, e dar a CPU para outra. O timer do hardware dispara tipicamente a cada 1–10 ms (HZ de Linux, varia por kernel compilation), e em cada tick o scheduler reavalia. Uma thread em meio a um cálculo aritmético pode ser preempted entre duas instruções; uma thread em meio a executar uma operação não-atômica pode ser preempted no meio.
Essa é a fonte primária dos bugs de concorrência. Se duas threads
executam contador = contador + 1, e o scheduler
interrompe a primeira logo após ler o valor mas antes de escrever,
a segunda lê o mesmo valor antigo, escreve, depois a primeira
termina escrevendo o seu valor. Uma das atualizações some. Esse é
o canônico race condition; o conceito 08 vai tratar das primitivas
para evitá-lo. Por ora, o ponto é: preempção acontece em qualquer
lugar, e a única forma de garantir atomicidade é pedir ao SO que
garanta (lock) ou usar instruções de hardware atômicas (CAS).
Context switch — o custo escondido
Quando o scheduler troca a thread em execução, há um context switch. O processo é mais caro do que parece:
- Salva registradores da thread atual no kernel stack (program counter, stack pointer, registradores de propósito geral, registradores de ponto flutuante). Em x86-64 com AVX-512 isso já é centenas de bytes.
- Atualiza estruturas internas do kernel (run queue, accounting de CPU usado, prioridade, vruntime).
- Troca de page table se a próxima thread for de outro processo (TLB flush — invalida cache de tradução de endereços virtuais, custoso).
- Restaura registradores da próxima thread do kernel stack.
- Penalty de cache: as caches L1/L2/L3 estão povoadas com dados da thread anterior. A nova thread vai sofrer cache misses até suas linhas voltarem.
Em hardware moderno (2026), um context switch puro custa tipicamente 1–5 microssegundos. Com perda de cache, o efeito real pode chegar a dezenas de microssegundos. Em workload com 10.000 threads ativas competindo por 8 cores, context switching pode consumir uma fração significativa do tempo de CPU — não fazendo nenhum trabalho útil, só trocando.
Você observa isso facilmente. Em Linux, vmstat 1
mostra a coluna cs (context switches por segundo).
Servidores saudáveis tipicamente têm dezenas a centenas de
milhares de cs/segundo; valores próximos do milhão são sinal de
problema. perf sched record seguido de
perf sched latency mostra latência por thread —
útil para identificar quem está sendo preempted demais.
# Observa context switches em tempo real
$ vmstat 1
procs -----------memory---------- ---system--
r b swpd free buff cache in cs
4 0 0 12340M 16M 3210M 8421 142387
3 0 0 12340M 16M 3210M 8553 145012
# Latência de scheduling por thread (root)
$ perf sched record -- sleep 10
$ perf sched latency
-----------------------------------------------------
Task | Runtime ms | Switches |
-----------------------------------------------------
app:1234 | 8423.123 ms | 142387 |
java:5678 | 1234.567 ms | 12345 |
O custo real de uma thread
A pergunta "quanto custa criar uma thread?" tem três dimensões: o momento da criação, a memória que ela ocupa enquanto vive, e o overhead que ela impõe ao scheduler. Cada uma escala diferente.
Memória — o stack domina
O stack default de uma thread em Linux é 8 MB (configurável via
ulimit -s ou pthread_attr_setstacksize).
Em Windows é 1 MB. A maior parte desse stack é virtual — não
consome RAM enquanto não é tocado, graças a paginação demand-paged.
Mas endereço virtual é finito (256 TB em x86-64; muito,
mas finito), e o mapeamento exige page tables. Cada thread
contribui para a pressão sobre o sistema de memória.
A conta prática: 10.000 threads × 8 MB = 80 GB de stack virtual reservado. RAM real consumida costuma ser muito menor (uns poucos KB por thread em uso típico), mas a reserva virtual é barreira concreta. Por isso thread per request em servers Java pré-Loom raramente passava de algumas centenas de threads por host.
Goroutines em Go partem de 2 KB de stack e crescem dinamicamente
via stack copying. 10.000 goroutines começam ocupando
cerca de 20 MB. asyncio tasks em Python são objetos
Python comuns, ocupando algumas centenas de bytes de heap cada.
Java virtual threads sob Loom têm overhead similar a goroutines.
A diferença em números absolutos justifica todo o ecossistema de
modelos cooperativos.
Tempo de criação
Criar uma thread em Linux moderno custa cerca de 10–50
microssegundos. Criar um processo via fork() custa
cerca de 1 ms (mais quanto maior o processo pai, por causa da
cópia de page tables). Criar uma goroutine custa centenas de
nanossegundos. Para um servidor que recebe 10.000
requisições por segundo, a diferença é arquitetural — não
cosmética.
Scheduler overhead
O scheduler do kernel é eficiente, mas escala em
O(log n) com o número de threads runnable
(estrutura de dados é red-black tree no CFS). Com poucas dezenas
de threads, isso é imperceptível. Com dezenas de milhares,
começa a aparecer. Com centenas de milhares — escala que
goroutines e virtual threads atingem confortavelmente — kernel
schedulers tradicionais fazem mau uso de CPU.
User-space vs kernel threads — três modelos
A relação entre as threads que o programador cria e as threads que o kernel agenda admite três arranjos clássicos. Linguagens fazem escolhas diferentes, e cada escolha tem implicações.
1:1 — cada thread do programa é uma thread do kernel
Modelo de pthread em Linux (NPTL desde 2003), threads em Windows,
std::thread em C++, threads em Java pré-Loom, threads
em .NET, threading.Thread em Python. Simples: o que
você cria é o que o kernel agenda. Paralelismo trivial em
multi-core. Cada thread tem o custo de uma thread do kernel:
stack grande, criação cara, scheduler do kernel agenda. Modelo
adequado para poucas threads (centenas), inadequado para
muitas (milhares).
M:1 — N threads do programa em 1 thread do kernel
Modelo dos green threads originais do Java (versão 1.1,
1996), Ruby pré-1.9, Python sem threading nativo.
Todas as threads do programa rodam dentro de um único kernel
thread; um scheduler em user-space alterna entre elas. Threads
são baratas (sem syscall para criar, sem stack grande), mas não
há paralelismo de CPU — um único kernel thread roda em um único
core. Modelo abandonado por linguagens mainstream após
multiprocessadores virarem norma; sobrevive em
asyncio, que escolhe explicitamente esse modelo
pelos seus benefícios em I/O.
M:N — N threads do programa em M threads do kernel
Modelo de Go (goroutines), Java Loom (virtual threads desde JDK
21, 2023), Erlang BEAM, Kotlin coroutines, Rust async com tokio.
Threads do programa são gerenciadas por scheduler em user-space,
que multiplexa em um pool de kernel threads. Goroutines começam
a rodar em alguma kernel thread; quando bloqueiam em I/O, o
runtime acorda outra goroutine na mesma kernel thread; quando o
kernel thread fica saturado, o runtime cria mais kernel threads
até GOMAXPROCS. O resultado: threads baratas
e paralelismo. Mas o scheduler em user-space tem que
ser bem feito — implementar M:N correto é difícil, motivo pelo
qual Java só conseguiu chegar lá em 2023, depois de dez anos de
Project Loom.
A escolha do modelo de threading não é apenas estilo. Threads 1:1 são apropriadas para poucas unidades pesadas; M:N é apropriado para muitas unidades leves, especialmente I/O-bound. Asyncio (M:1 cooperativo) é apropriado quando você quer milhares de tarefas concorrentes sem custo de threading, e não precisa de paralelismo de CPU.
Cache effects — concorrência não é só lógica
CPUs modernas têm hierarquia de caches: L1 (64 KB por core, latência ~1 ns), L2 (1 MB por core, ~3 ns), L3 (compartilhada entre cores, dezenas de MB, ~10 ns), RAM (~100 ns). Acesso a memória sem cache é cem vezes mais lento que cache hit. Em programa concorrente, uma fração significativa do desempenho é decidida por como threads compartilham caches.
Cache locality
Quando uma thread acessa um endereço, a CPU traz para o cache uma cache line inteira (64 bytes em x86-64), não só o byte pedido. Acessos subsequentes a endereços próximos vêm do cache sem custo. Por isso percorrer um array é eficiente; percorrer uma linked list espalhada na heap é lento. Em concorrência, threads que tocam dados próximos podem compartilhar cache — vantagem ou desvantagem dependendo de quem escreve.
False sharing
Quando duas threads em cores diferentes escrevem em variáveis
diferentes que caem na mesma cache line, o protocolo de coerência
de cache (MESI em x86) força invalidação cruzada: cada escrita
em um core invalida a linha no cache do outro core, que precisa
buscar de novo. O programa parece concorrente sem
compartilhamento — duas variáveis distintas — mas paga o custo
de compartilhamento de fato. É um dos bugs de performance mais
sutis em código concorrente. Solução: alinhar variáveis
escritas por threads diferentes a 64 bytes (padding, ou
[CacheLine] em estruturas em C# com
StructLayout, ou __attribute__((aligned))
em C/C++).
NUMA
Em servers grandes (acima de uns 16 cores), CPUs são organizadas
em sockets, e cada socket tem RAM próxima
(local node) e RAM mais distante (remote node).
Acessar RAM remota custa o dobro ou mais. Linux scheduler tenta
manter threads no mesmo socket onde sua memória mora, mas se
uma thread é migrada de socket — coisa que acontece em
desbalanceamento — passa a sofrer overhead de NUMA. Comando
numactl --hardware mostra a topologia;
numactl --cpunodebind=0 ancora um processo a um
socket. Em workload sensível, pinning explícito vira
diferenciador de performance.
O mesmo problema, três modelos
Para tornar o custo de threading concreto, considere o problema simples: disparar 10.000 unidades de trabalho que cada uma dorme um segundo. Isso simula 10.000 conexões I/O-bound em servidor. Veja o que cada modelo entrega.
using System.Diagnostics;
var sw = Stopwatch.StartNew();
var tasks = new Task[10_000];
for (int i = 0; i < tasks.Length; i++) {
tasks[i] = Task.Run(async () => {
await Task.Delay(1000);
});
}
await Task.WhenAll(tasks);
Console.WriteLine($"10k Tasks: {sw.ElapsedMilliseconds} ms");
// Tipicamente: ~1050 ms, ~30 MB de RAM
// O ThreadPool reusa um pequeno número de kernel threads.
Task em C# não é uma thread — é unidade de trabalho
agendada no ThreadPool. Task.Delay cede a thread
ao pool durante a espera. 10.000 Tasks rodam confortavelmente
em um pool de algumas dezenas de threads. Se você usar
Thread (1:1) em vez de Task, o
programa consome ~80 GB de stack virtual e demora muito mais.
import asyncio
import time
async def trabalho():
await asyncio.sleep(1)
async def main():
inicio = time.perf_counter()
tarefas = [trabalho() for _ in range(10_000)]
await asyncio.gather(*tarefas)
print(f"10k tarefas: {time.perf_counter() - inicio:.2f}s")
asyncio.run(main())
# Tipicamente: ~1.05s, ~50 MB de RAM
# Tudo em uma única thread; event loop alterna entre as 10k corotinas.
asyncio é M:1 cooperativo — uma thread só. As
10.000 corotinas dormem cooperativamente; o event loop volta
a executar cada uma quando o sleep expira. Compare com
threading.Thread: criar 10.000 threads em
Python consome cerca de 80 GB de stack reservado e demora
dezenas de segundos só para iniciar. Asyncio é ordens de
magnitude mais barato.
package main
import (
"fmt"
"sync"
"time"
)
func main() {
inicio := time.Now()
var wg sync.WaitGroup
for i := 0; i < 10_000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(time.Second)
}()
}
wg.Wait()
fmt.Printf("10k goroutines: %v\n", time.Since(inicio))
}
// Tipicamente: ~1.01s, ~25 MB de RAM
// Runtime do Go multiplexa em GOMAXPROCS kernel threads.
Goroutines são M:N. Cada uma começa com 2 KB de stack; o
runtime cresce sob demanda. time.Sleep não
bloqueia a kernel thread — o scheduler do Go acorda outra
goroutine. Em uma máquina de 8 cores, este programa usa todas
paralelamente, ainda que o trabalho aqui seja só esperar.
Ferramentas para enxergar o que o SO faz
Concorrência é um desses tópicos onde abstração esconde o que importa. Quando algo desanda em produção — latência alta, CPU mascarando trabalho, memória estourando — você precisa abrir o capô. As ferramentas de Linux para isso amadureceram muito; vale conhecer um pouco de cada uma.
-
top,htop— visão geral.htopmostra threads se você ligar comH. -
pidstat -t -p PID 1— uso de CPU por thread, por segundo. Identifica qual thread está consumindo o tempo. -
vmstat 1— context switches (cs), interrupts (in), CPU breakdown (us/sy/id/wa). -
perf top,perf record / perf report— sampling profiler. Mostra onde o programa gasta tempo no nível de função. -
perf sched record / perf sched latency— análise detalhada de scheduling. Quem foi preempted demais, quem esperou demais para rodar. -
strace -f -p PID— todas as syscalls que o processo (e suas threads) faz. Mostra bloqueios reais. -
bpftracee ferramentas BPF (bcc) — instrumentação dinâmica de qualquer evento do kernel sem recompilar nada. Estado da arte em 2026. -
numactl --hardware,numastat— topologia NUMA e estatísticas de acesso local vs remoto.
O ferramental no Windows e macOS é equivalente em capacidade,
diferente em interface: ETW (Event Tracing for Windows) e
Performance Monitor; Instruments e dtruss em macOS.
Brendan Gregg (Netflix) mantém um conjunto de gráficos canônicos
sobre quando usar cada ferramenta — referência obrigatória.
Default thread pool de framework configurado para a máquina
errada. Tomcat com maxThreads=200 em servidor que
recebe 5.000 requisições/s gera filas, latência alta e às
vezes deadlock por exhaustion. .NET ThreadPool com
SetMinThreads não tunado em workload com bursts
cria starvation visível como "todo mundo trava por 30 segundos".
Sempre olhe os defaults do seu runtime contra o perfil de
carga real, e tune com base em medição (CPU usage, queue depth,
latência).
Como praticar
-
Compare o custo de threading entre modelos.
Escreva o programa "10.000 unidades de trabalho que esperam 1
segundo" em quatro versões:
threading.Threadem Python (vai falhar ou ser muito lento),asyncioem Python,Taskem C# e goroutines em Go. Meça memória (RSS) e tempo total. Documente a diferença. -
Observe context switches em tempo real. Rode
vmstat 1em um terminal. Em outro, dispare seu programa concorrente. Veja a colunacsresponder. Tente um programa com 100 threads vs 10.000 threads — observe o salto no número de context switches por segundo, e veja a CPU dividida emsy(system time, gasto no kernel) crescer. - Demonstre false sharing para si mesmo. Em Go ou C#, crie duas variáveis adjacentes (em uma struct), faça cada uma ser incrementada por uma goroutine/Task em loop apertado. Meça o tempo. Depois, pad as variáveis para ficarem em cache lines distintas (64 bytes de separação). Meça de novo. A diferença pode chegar a 5–10x. Esse é um efeito real que aparece em estruturas concorrentes não otimizadas.
Referências para aprofundar
- livro Operating Systems: Three Easy Pieces — Remzi & Andrea Arpaci-Dusseau (gratuito).
- livro Systems Performance — Brendan Gregg (2ª ed., 2020).
- livro Linux Kernel Development — Robert Love (3ª ed., 2010).
- livro Modern Operating Systems — Andrew S. Tanenbaum & Herbert Bos (4ª ed., 2014).
- artigo What Every Programmer Should Know About Memory — Ulrich Drepper (2007).
- artigo The Linux Scheduler: a Decade of Wasted Cores — Lozi et al. (2016).
- artigo Why Project Loom? — Ron Pressler (2020).
- artigo Go's work-stealing scheduler — Dmitry Vyukov (design doc, 2012).
- docs Linux man pages — pthread(7), sched(7), clone(2).
- docs Brendan Gregg's Linux Performance.
- vídeo Concurrency in Go (talk) — Rob Pike, GopherCon.
- vídeo EEVDF in Linux 6.6 — talks de Linux Plumbers Conference 2023.