Tudo o que executa em um computador moderno executa dentro de um processo. Cada programa que você roda — desde o terminal até o servidor de produção que serve milhões de requisições — é, do ponto de vista do sistema operacional, uma instância de processo com estado próprio. Entender o que isso significa concretamente é a base sobre a qual todas as discussões sobre concorrência, isolamento, containers e escalabilidade vão acontecer. Quem trata processos e threads como detalhes de implementação acaba escrevendo código que parece funcionar até o dia em que algo falha de forma misteriosa em produção — e a causa raiz é sempre alguma propriedade básica do modelo de execução que ficou assumida sem ter sido entendida.
A gênese desta seção: você precisa conseguir responder, sem hesitação, três perguntas. Primeira: o que está dentro de um processo, e quem o controla? Segunda: qual é a diferença entre processos e threads, e quando preferir cada um? Terceira: quando o sistema operacional decide tirar o seu programa da CPU, o que exatamente acontece? Essas três respostas formam a estrutura mental que torna possível raciocinar sobre desempenho, paralelismo e isolamento sem chutar.
O que é um processo
Um processo é a unidade de execução isolada do sistema operacional. Quando você
executa ./meu_programa, o kernel cria um processo que contém:
um espaço de endereçamento próprio (memória virtual exclusiva), uma tabela de
file descriptors (números que apontam para arquivos, sockets, pipes
abertos), credenciais (usuário e grupo), variáveis de ambiente, e pelo menos
uma thread de execução. A grande propriedade do processo é o
isolamento: dois processos diferentes não podem, por padrão,
ler nem escrever na memória um do outro. O kernel garante isso via tabelas de
paginação — cada processo enxerga seu próprio espaço de endereçamento, mesmo
que dois processos usem aparentemente o "mesmo endereço" (são endereços
virtuais distintos, mapeados para memória física diferente).
Esse isolamento é o que torna o computador moderno utilizável. Quando o navegador trava, o terminal continua funcionando. Quando uma API derruba, outras APIs no mesmo servidor continuam respondendo. O preço disso é que comunicação entre processos é cara: precisa passar pelo kernel via mecanismos explícitos (pipes, sockets, memória compartilhada explicitamente declarada). Esse trade-off — isolamento alto, comunicação cara — define muitas decisões de design de sistemas distribuídos.
Cada processo tem um identificador único, o PID. Em Linux, o primeiro
processo é o init (PID 1), tradicionalmente systemd
em distros modernas. Todos os outros processos são descendentes dele —
criados via fork() a partir de algum processo pai. Esse modelo
hierárquico tem consequências práticas: quando um processo morre, seus filhos
órfãos são "adotados" por init, que os limpa quando terminam. Em
containers, isso vira problema: se o processo principal do container não é
"init-aware", processos zumbis se acumulam — daí o uso de tini ou
dumb-init como PID 1 em imagens Docker.
O que é uma thread
Uma thread é uma sequência de execução dentro de um processo. Diferentemente de processos, threads do mesmo processo compartilham espaço de endereçamento, file descriptors, e a maioria do estado — só têm pilha (stack) própria, registradores próprios, e um contador de programa próprio. Esse compartilhamento é o que torna threads baratas para criar e rápidas para se comunicar (ler/escrever variáveis compartilhadas é direto), mas também o que torna concorrência via threads propensa a bugs sutis: uma thread pode ver estado parcial sendo modificado por outra, sem nenhuma proteção automática.
Em sistemas Linux modernos, threads são implementadas como "processos leves"
— usam a mesma estrutura interna do kernel (task_struct), mas
compartilham o espaço de endereçamento via flag em clone(). Isso
tem a consequência elegante de que o escalonador trata threads e processos
de forma idêntica do ponto de vista de tempo de CPU. Diferente de sistemas
antigos onde threads eram puramente em user-space ("green threads"),
threads modernas em Linux são kernel threads: o sistema operacional
sabe da existência de cada uma e pode escaloná-las independentemente entre
cores diferentes.
Concorrência ≠ paralelismo
A distinção é frequentemente borrada em conversas casuais, mas precisa ser mantida em código sério. Concorrência é a estrutura do programa: dizer que ele lida com múltiplas tarefas que podem fazer progresso de forma intercalada. Paralelismo é a execução: o programa está rodando múltiplas tarefas ao mesmo tempo, em CPUs diferentes. Você pode ter concorrência sem paralelismo (uma única CPU alternando rapidamente entre múltiplas tarefas), e em sistemas reais a maior parte da escalabilidade vem disso — não de paralelismo bruto.
O exemplo prático: um servidor web que atende mil conexões simultâneas. A maior parte do tempo de cada conexão não é gasto em CPU, é gasto esperando I/O (rede, banco, disco). Se você tem só quatro cores físicos, paralelismo máximo é quatro — mas concorrência pode chegar a dezenas de milhares, porque enquanto uma conexão espera o banco responder, outra pode estar sendo processada. Concorrência bem-feita é o que permite I/O escalonar.
Rob Pike, criador do Go, formulou isso assim: "concorrência é sobre lidar com muitas coisas ao mesmo tempo; paralelismo é sobre fazer muitas coisas ao mesmo tempo." A distinção tem implicações de design: para concorrência, você precisa de mecanismos de coordenação (channels, locks, async/await); para paralelismo, você precisa de cores físicos disponíveis e capacidade de distribuir trabalho entre eles.
Modelos de concorrência nas três linguagens
Cada uma das linguagens da formação implementa concorrência de forma diferente, refletindo trade-offs diferentes. Conhecer os três modelos é o que permite raciocinar sobre qual cabe melhor em qual problema.
// Modelo: tarefas em pool gerenciado pelo runtime
// Threads OS são caras; tasks são leves e multiplexadas.
public async Task<User?> FindUserAsync(int id) {
using var conn = new SqlConnection(connStr);
await conn.OpenAsync(); // libera thread durante I/O
return await conn.QueryFirstOrDefaultAsync<User>(
"SELECT * FROM users WHERE id = @id", new { id });
}
// Mil chamadas simultâneas — não cria mil threads.
// O runtime usa o pool (ex: 8 threads) e multiplexa.
var tasks = ids.Select(FindUserAsync);
var users = await Task.WhenAll(tasks);
async/await usa um state machine gerado pelo
compilador. Quando hits await, o método "pausa" e a thread
volta ao pool. Quando o I/O completa, o método continua, possivelmente
em outra thread.
# Modelo: event loop single-thread (mas multi-conexão).
# GIL impede threads Python de rodarem código Python em paralelo.
import asyncio
import asyncpg
async def find_user(pool, id):
async with pool.acquire() as conn:
return await conn.fetchrow("SELECT * FROM users WHERE id = $1", id)
async def main():
pool = await asyncpg.create_pool(dsn)
# Mil chamadas simultâneas, no mesmo event loop.
users = await asyncio.gather(*(find_user(pool, id) for id in ids))
asyncio.run(main())
asyncio é single-thread mas multi-conexão. Para CPU-bound, use
multiprocessing (processos reais) — não threads (GIL bloqueia).
Python 3.13 introduziu free-threading experimental sem GIL, mas ainda em
maturação.
// Modelo: M:N — N goroutines multiplexadas em M threads OS.
// O runtime do Go gerencia automaticamente.
func findUser(db *sql.DB, id int) (*User, error) {
var u User
err := db.QueryRow("SELECT id, name FROM users WHERE id = $1", id).
Scan(&u.ID, &u.Name)
return &u, err
}
func main() {
db, _ := sql.Open("postgres", dsn)
defer db.Close()
var wg sync.WaitGroup
results := make(chan *User, len(ids))
for _, id := range ids {
wg.Add(1)
go func(id int) { // goroutine — leve, ~2KB inicial
defer wg.Done()
u, _ := findUser(db, id)
results <- u
}(id)
}
wg.Wait()
close(results)
}
Goroutines startam com ~2KB de stack (cresce dinamicamente). O scheduler do runtime Go (M:N) multiplexa milhões de goroutines em poucas threads OS. Ideal para concorrência massiva I/O-bound — e funciona naturalmente em paralelo quando há cores disponíveis.
Note que os três modelos resolvem o mesmo problema (mil chamadas concorrentes ao banco) com filosofias diferentes. C# usa o pool de threads e multiplexação via state machines. Python usa um único event loop e cooperative scheduling explícito. Go usa M:N scheduling no runtime, criando uma abstração "thread leve" que parece thread mas custa muito menos. Cada um tem consequências para CPU-bound vs I/O-bound, e cada um falha de jeito diferente quando você usa errado.
O escalonador do SO — o que ele decide
Quando você tem mais threads/processos prontos do que cores disponíveis, o sistema operacional precisa decidir quem roda quando. Isso é o escalonador (scheduler). Em Linux, o algoritmo padrão atualmente é o Completely Fair Scheduler (CFS), substituído gradualmente por EEVDF a partir de 2023. A propriedade fundamental é "fair": cada processo recebe uma fatia proporcional de tempo de CPU baseada em sua prioridade (nice value).
O escalonador opera em janelas curtas (tipicamente alguns milissegundos). Em uma janela, ele:
- Identifica processos elegíveis (não bloqueados em I/O).
- Escolhe o que mais merece tempo (menor "vruntime" no CFS).
- Coloca-o numa CPU.
- Programa um timer para o fim da janela.
- Quando o timer dispara, salva o estado (context switch) e repete.
O context switch é a operação onde o kernel salva o estado
atual de uma thread (registradores, contador de programa, ponteiros de pilha)
e carrega o estado da próxima. É barato (~1-5μs em hardware moderno), mas
não é grátis: trocas excessivas — sintoma de muitos processos competindo —
consomem CPU em overhead, não em trabalho útil. Em sistemas onde
htop mostra "ksoftirqd" e "context switches/sec" altíssimos, é
esse fenômeno.
Estados de uma thread
Uma thread, do ponto de vista do escalonador, está sempre em um de poucos estados:
- Running: está executando agora numa CPU.
- Runnable: pronta para rodar, esperando uma CPU livre.
- Blocked / Sleeping: esperando algo (I/O, lock, sinal). Não consome CPU. Não pode ser escalonada até o evento esperado acontecer.
-
Zombie: terminou execução mas o pai ainda não leu o status
de saída. Existe só na tabela do kernel até alguém fazer
wait().
Esse modelo simples explica vários fenômenos. Quando seu app está "lento", o
top mostrando 0% de CPU significa que ele está blocked, não
runnable — provavelmente esperando I/O. Soluções de paralelismo agressivo
não vão ajudar; o gargalo está em outro lugar. Quando você vê processos
"defunct" (Z) no ps, é porque algum pai está negligenciando
seu trabalho de limpar zumbis. Em containers com PID 1 mal configurado, é
praga comum.
File descriptors — o tópico subestimado
Cada arquivo aberto, cada socket de rede, cada pipe, cada stream em um
processo Linux é representado por um file descriptor (FD): um
pequeno inteiro (0, 1, 2, 3, ...). Os três primeiros são canônicos:
stdin (0), stdout (1), stderr (2).
Tudo o mais é alocado dinamicamente quando você abre algo.
A propriedade que muita gente desconhece: FDs são recurso finito.
Cada processo Linux tem um limite (tipicamente 1024 ou 4096 por padrão; o
hard limit pode ser dezenas de milhares com configuração). Quando o limite
é atingido, qualquer chamada que precisa abrir algo — open(),
socket(), accept() — falha com
EMFILE ("Too many open files"). O sintoma em produção é o app
parar de aceitar novas conexões enquanto as existentes continuam funcionando
— confuso de diagnosticar se você não conhece o mecanismo.
A causa raiz típica é file descriptor leak: código que abre e
esquece de fechar (esquecer defer file.Close() em Go, esquecer
using em C#, esquecer with em Python). Cada
requisição que vaza um FD vai eventualmente derrubar o processo. A
ferramenta de diagnóstico é lsof -p <pid>, que lista
todos os FDs abertos do processo — vazamento aparece como crescimento
monotônico ao longo do tempo.
O default de 1024 FDs em Linux é absurdamente baixo para servidores. Apps
que mantêm conexões persistentes (WebSocket, gRPC, banco com pool grande)
atingem o limite com facilidade. Configure ulimit -n em
produção (geralmente 65536 ou mais) e monitore open_files
como métrica. Em containers, isso entra na configuração do runtime
(nofile no Docker / Kubernetes).
Sinais — comunicação assíncrona com processos
Sinais são notificações assíncronas que o kernel ou outros processos podem enviar a um processo. Cada sinal tem um número e um nome. Os mais importantes para um engenheiro backend:
- SIGINT (2): enviado quando você aperta Ctrl+C no terminal. Default: termina o processo. Pode ser capturado para fazer limpeza.
- SIGTERM (15): pedido educado de término. Default: termina. Em containers, o orquestrador (Kubernetes, Docker) envia SIGTERM antes de SIGKILL. Capturá-lo para fazer graceful shutdown é fundamental.
- SIGKILL (9): termina imediatamente. Não pode ser capturado nem ignorado. É o "tiro de misericórdia" do kernel.
- SIGCHLD (17): enviado ao pai quando um filho termina. Importante para shells e supervisores.
- SIGSEGV (11): violação de segmentação. Default: termina com core dump. Aparece em código nativo com bugs de ponteiro.
- SIGPIPE (13): tentativa de escrever em pipe/socket fechado. Importante: default é terminar o processo. Em servidores, ignorar SIGPIPE é geralmente correto.
Em Kubernetes, o ciclo de término de um pod é: o orquestrador envia SIGTERM,
espera terminationGracePeriodSeconds (default 30s), e se o
processo ainda estiver vivo, envia SIGKILL. Apps bem-comportados capturam
SIGTERM, param de aceitar novas conexões, esperam as existentes terminarem
até um timeout, e saem ordenadamente. Apps mal-comportados ignoram SIGTERM e
são derrubados a SIGKILL — cortando conexões abertas, deixando estado
inconsistente, gerando reclamações de cliente.
Todo serviço de produção deveria capturar SIGTERM e fazer graceful shutdown. É linha de código simples — três a dez linhas em qualquer linguagem moderna — e a diferença entre o serviço ser "bem-comportado" ou "fonte de bugs" no orquestrador. Se você não sabe se o seu serviço faz isso, descubra agora; o ROI por linha de código é altíssimo.
Como praticar
Três exercícios concretos:
-
Inspecione um processo real. Pegue um app rodando (pode
ser o navegador, o VS Code, qualquer coisa). Use
ps aux | grep nomepara achar o PID. Depoiscat /proc/<pid>/status— você vai ver dezenas de campos: estado da thread, memória, FDs abertos, threads, sinais bloqueados. Cada campo tem uma história. Pesquise os que não conhece. -
Escreva um servidor que captura SIGTERM. Qualquer linguagem
das três. Faça-o aceitar conexões TCP, e quando receber SIGTERM, parar
de aceitar novas mas terminar as em andamento. Teste enviando
kill -TERM <pid>ekill -KILL <pid>; observe a diferença. -
Provoque vazamento de FD. Escreva um loop que abre arquivos
sem fechar. Rode até esgotar e veja o erro. Depois corrija com o idiom
adequado da linguagem (
using,with,defer) e confirme que o problema some. Esse é o tipo de falha que vai aparecer em código real.
Referências para aprofundar
- livro Operating Systems: Three Easy Pieces — Remzi & Andrea Arpaci-Dusseau.
- livro The Linux Programming Interface — Michael Kerrisk (2010).
- livro Computer Systems: A Programmer's Perspective (3rd ed.) — Bryant & O'Hallaron (2015).
- artigo Concurrency Is Not Parallelism — Rob Pike (2012).
- artigo What every programmer should know about memory — Ulrich Drepper (2007).
- artigo The C10K problem — Dan Kegel.
- docs Linux man pages — proc(5).
- docs Go Scheduler.
- docs Python asyncio docs.
- docs .NET — async in depth.
- vídeo Containers Unplugged: Linux Namespaces — Michael Kerrisk.
- paper The Design and Implementation of the FreeBSD Operating System — McKusick et al.