MÓDULO 01 · CONCEITO 01 DE 8

Processos, threads & modelo de execução

A unidade fundamental que tudo o que vem depois pressupõe.

Tempo de leitura ~22 min Pré-requisito Módulo 00 Próximo Memória — stack, heap e GC

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.

C# — async/await + Task
// 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.

Python — asyncio + GIL
# 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.

Go — goroutines + channels
// 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:

  1. Identifica processos elegíveis (não bloqueados em I/O).
  2. Escolhe o que mais merece tempo (menor "vruntime" no CFS).
  3. Coloca-o numa CPU.
  4. Programa um timer para o fim da janela.
  5. 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:

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.

armadilha em produção

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:

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.

heurística do sênior

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:

  1. Inspecione um processo real. Pegue um app rodando (pode ser o navegador, o VS Code, qualquer coisa). Use ps aux | grep nome para achar o PID. Depois cat /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.
  2. 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> e kill -KILL <pid>; observe a diferença.
  3. 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

  1. livro Operating Systems: Three Easy Pieces — Remzi & Andrea Arpaci-Dusseau. pages.cs.wisc.edu/~remzi/OSTEP — gratuito online. Os capítulos 4-9 (processos, threads, escalonamento) são os melhores que existem para programadores.
  2. livro The Linux Programming Interface — Michael Kerrisk (2010). Bíblia de Linux para programadores. Capítulos 24-27 sobre processos, 29-33 sobre threads. Use como referência, não para ler do início ao fim.
  3. livro Computer Systems: A Programmer's Perspective (3rd ed.) — Bryant & O'Hallaron (2015). Capítulo 8 (Exceptional Control Flow) é onde fork, exec, sinais, processos vivem. Material de graduação top-tier.
  4. artigo Concurrency Is Not Parallelism — Rob Pike (2012). YouTube + slides. A palestra que cunhou a distinção formal. 30 minutos, formativa para sempre.
  5. artigo What every programmer should know about memory — Ulrich Drepper (2007). akkadia.org/drepper/cpumemory.pdf — envelheceu mas ainda fundamental para entender custo de threads e cache.
  6. artigo The C10K problem — Dan Kegel. kegel.com/c10k.html — arqueologia útil. Explica por que async/event loops surgiram para resolver o problema das 10 mil conexões.
  7. docs Linux man pages — proc(5). man7.org/linux/man-pages/man5/proc.5.html — referência oficial do filesystem virtual /proc. Onde tudo sobre processos vive.
  8. docs Go Scheduler. go.dev/src/runtime/proc.go — o código real do scheduler M:N. Comentários explicam decisões de design.
  9. docs Python asyncio docs. docs.python.org/3/library/asyncio.html — fonte primária. Veja "Concurrency and multithreading" para limites do GIL.
  10. docs .NET — async in depth. learn.microsoft.com/en-us/dotnet/csharp/asynchronous-programming — modelo async/await em C# 13 + .NET 10, com state machines explicadas.
  11. vídeo Containers Unplugged: Linux Namespaces — Michael Kerrisk. YouTube. Como containers são processos. Conexão direta com o conceito 06.
  12. paper The Design and Implementation of the FreeBSD Operating System — McKusick et al. Capítulos sobre processos e escalonamento têm conexão direta com Linux. Para quem quer profundidade conceitual.