Este artigo é a continuação da Parte II, onde abordamos processos, seu ciclo de vida, syscalls e como os runtimes de Python, Go e .NET os utilizam. Se você ainda não leu, recomendo começar por lá:
Kernel Linux para Desenvolvedores Backend — Processos & Threads Parte II
Sumário
-
Threads: Fundamentos
- Modelo Clássico de Thread
- Motivação para Threads
- 1. Paralelismo real em múltiplos cores
- 2. Economia de recursos comparado a processos
- 3. Responsividade e overlapping de I/O
- Threads em Espaço de Usuário vs Kernel
- User-Level Threads (ULT) ou Green Threads
- Kernel-Level Threads (KLT)
- Modelos Híbridos (M:N)
- Implementação de Pop-up Threads
- Thread Pools: Conceito e Benefícios
- Comparação: Quando Usar Threads vs Processos
- Context Switching: Teoria
Threads: Fundamentos
Processos são unidades monolíticas de execução. Porém, aplicações modernas — especialmente servidores backend — raramente operam com um único fluxo de execução. Threads permitem que múltiplos fluxos de execução coexistam dentro de um mesmo processo, compartilhando o espaço de endereçamento e recursos, mas mantendo contextos de execução independentes.
Gosto de pensar que threads são linhas de isolamento de processos, porém a nível de kernel uma thread e um processo são a mesma coisa. A grande diferença para você, desenvolvedor backend, é entender a diferença de thread no nível de kernel e thread no nível de usuário, mas isso a gente vai falar mais pra frente.
Documentação Oficial da Kernel - Threads Topology
Modelo Clássico de Thread
Uma thread (ou lightweight process) é a menor unidade de execução escalonável pelo sistema operacional. Enquanto um processo define um espaço de endereçamento e um conjunto de recursos, uma thread define um fluxo de controle dentro desse espaço.
Processo (espaço de endereçamento compartilhado)
┌─────────────────────────────────────────────────────────────┐
│ Code (text segment) │ Data (global variables) │
├──────────────────────────┴──────────────────────────────────┤
│ Heap (dynamic allocation) │
├─────────────────────────────────────────────────────────────┤
│ Open files, sockets, signals, credentials, cwd │
├─────────┬─────────┬─────────┬───────────────────────────────┤
│ Thread 1│ Thread 2│ Thread 3│ ← Cada thread possui: │
│┌───────┐│┌───────┐│┌───────┐│ - Stack própria │
││ Stack │││ Stack │││ Stack ││ - Program counter │
││ PC │││ PC │││ PC ││ - Registradores │
││ Regs │││ Regs │││ Regs ││ - Estado (running, etc) │
│└───────┘│└───────┘│└───────┘│ - Thread-local storage │
└─────────┴─────────┴─────────┴───────────────────────────────┘
O que threads compartilham (pertence ao processo):
- Espaço de endereçamento (code, data, heap)
- File descriptors abertos
- Sinais e handlers de sinais
- Working directory e root directory
- User ID e Group ID
- Memory mappings (
mmap)
O que cada thread possui exclusivamente:
- Stack (cada thread tem sua própria pilha de execução)
- Program counter (aponta para a instrução sendo executada)
- Registradores da CPU (salvos/restaurados no context switch)
- Estado de escalonamento (running, blocked, ready)
- Thread-local storage (TLS) — variáveis privadas por thread
- Signal mask (quais sinais estão bloqueados)
- errno (em sistemas POSIX)
Essa separação é fundamental: o compartilhamento do espaço de endereçamento permite comunicação eficiente entre threads (basta ler/escrever em memória compartilhada), mas introduz problemas de sincronização e race conditions.
Motivação para Threads
Por que não usar simplesmente múltiplos processos? Threads oferecem três vantagens fundamentais:
1. Paralelismo real em múltiplos cores
Em um servidor com 8 cores, um processo single-threaded utiliza no máximo 12.5% da capacidade de CPU. Threads permitem distribuir trabalho entre todos os cores disponíveis.
Servidor 8 cores — Processando 8 requests simultâneos:
Processo single-threaded:
Core 0: |████████████████████████████████████████████████| (100% — saturado)
Core 1: | | (idle)
Core 2: | | (idle)
...
Core 7: | | (idle)
Throughput: 1x (serializado)
Processo multi-threaded (8 threads):
Core 0: |██████| req 1
Core 1: |██████| req 2
Core 2: |██████| req 3
...
Core 7: |██████| req 8
Throughput: ~8x (paralelo)
2. Economia de recursos comparado a processos
Criar uma thread é significativamente mais barato que criar um processo:
| Operação | Custo típico | Motivo |
|---|---|---|
fork() (processo) |
~100-500μs | Copia page tables, duplica estruturas do kernel |
clone() (thread) |
~10-50μs | Compartilha espaço de endereçamento, aloca apenas stack |
| Context switch entre processos | ~3-5μs | Flush de TLB, troca de page tables |
| Context switch entre threads (mesmo processo) | ~1-2μs | Sem flush de TLB (mesmo espaço de endereçamento) |
Fonte: Systems Performance, 2nd Edition — Brendan Gregg; valores de referência medidos com lmbench
A economia é especialmente relevante em servidores que precisam tratar milhares de conexões simultâneas — criar um processo por conexão (modelo Apache pre-fork) é ordens de magnitude mais caro que criar uma thread por conexão.
3. Responsividade e overlapping de I/O
Em aplicações que combinam I/O e computação, threads permitem sobrepor atividades:
Sem threads (serializado):
|── read DB ──|── process ──|── read DB ──|── process ──|── respond ──|
0 50 80 130 160 180ms
Com threads (overlapping):
Thread 1: |── read DB ──|── process ──|── respond ──|
Thread 2: |── read DB ──|── process ──|
↑ I/O concurrent
0 50 80 100ms ← 44% mais rápido
Implicação para backend: Um servidor web que faz múltiplas queries ao banco de dados para compor uma resposta pode disparar todas as queries em paralelo usando threads, ao invés de executá-las sequencialmente. Frameworks como ASP.NET Core fazem isso nativamente com
async/awaite o thread pool.
Threads em Espaço de Usuário vs Kernel
A implementação de threads pode ocorrer em diferentes camadas do sistema, cada uma com trade-offs distintos.
User-Level Threads (ULT) ou Green Threads
Threads implementadas inteiramente em espaço de usuário, por uma biblioteca de runtime — sem envolvimento do kernel. O kernel enxerga apenas um único processo.
┌──────────────────────────────────────┐
│ Espaço de Usuário │
│ ┌──────────────────────────────┐ │
│ │ Thread Library (runtime) │ │
│ │ ┌─────┐ ┌─────┐ ┌─────┐ │ │
│ │ │ ULT │ │ ULT │ │ ULT │ │ │ ← 3 threads visíveis ao runtime
│ │ │ 1 │ │ 2 │ │ 3 │ │ │
│ │ └─────┘ └─────┘ └─────┘ │ │
│ │ Thread Scheduler │ │ ← escalonamento em userspace
│ └──────────────────────────────┘ │
├──────────────────────────────────────┤
│ Kernel │
│ ┌──────────────────────────────┐ │
│ │ 1 kernel thread (1 processo) │ │ ← kernel vê apenas 1 fluxo
│ └──────────────────────────────┘ │
└──────────────────────────────────────┘
Vantagens:
- Context switch ultra-rápido: Troca de thread não envolve trap para o kernel (~100ns vs ~1-2μs)
- Portabilidade: Funciona em qualquer OS, independente de suporte a threads no kernel
- Customização: O algoritmo de escalonamento pode ser otimizado para a aplicação específica
- Escalabilidade: Pode criar milhões de threads (são apenas structs em memória)
Limitações críticas:
- Blocking I/O bloqueia todo o processo: Se uma ULT faz uma syscall bloqueante (read, accept), todas as threads do processo param — o kernel não sabe que existem outras threads prontas
- Sem paralelismo real: Como o kernel vê apenas um processo, todas as ULTs executam no mesmo core — impossível utilizar múltiplos cores
- Page faults bloqueiam tudo: Um page fault em qualquer thread suspende todo o processo
Problema de blocking I/O com ULTs:
ULT 1: |████|── read() ──────────────────|████|
ULT 2: |░░░░|░░░░░░░░░░░░░░░░░░░░░░░░░░░|████| ← bloqueada esperando ULT 1!
ULT 3: |░░░░|░░░░░░░░░░░░░░░░░░░░░░░░░░░|████| ← idem
O kernel vê: |████|── BLOCKED ──────────────|████|
(todo o processo está bloqueado)
Exemplos históricos:
- Green threads do Java 1.0, GNU Pth, Solaris LWPs iniciais.
- Goroutines do Go são user-level threads, mas o runtime gerencia a multiplexação em OS threads para contornar as limitações de ULTs tradicionais.
- Python (antes do GIL) tinha uma implementação de green threads chamada
greenlet, mas o GIL tornou isso inviável para paralelismo real. - .NET tinha uma implementação de user-level threads chamada "fibers", mas foi descontinuada em favor do modelo 1:1 com kernel threads.
Kernel-Level Threads (KLT)
Threads gerenciadas diretamente pelo kernel. Cada thread é uma entidade escalonável independente.
┌──────────────────────────────────────┐
│ Espaço de Usuário │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ Thr │ │ Thr │ │ Thr │ │ ← 3 threads visíveis ao programa
│ │ 1 │ │ 2 │ │ 3 │ │
│ └──┬──┘ └──┬──┘ └──┬──┘ │
├─────┼──────────┼──────────┼──────────┤
│ ▼ ▼ ▼ Kernel │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │ KLT │ │ KLT │ │ KLT │ │ ← 3 kernel threads (task_structs)
│ │ 1 │ │ 2 │ │ 3 │ │
│ └─────┘ └─────┘ └─────┘ │
│ Kernel Scheduler │ ← escalonamento pelo kernel
└──────────────────────────────────────┘
Vantagens:
- Paralelismo real: Threads podem executar simultaneamente em diferentes cores
- I/O não bloqueia outras threads: Se thread 1 bloqueia em I/O, threads 2 e 3 continuam executando
- Escalonamento justo: O kernel aplica as mesmas políticas (CFS, etc.) a todas as threads
Desvantagens:
-
Overhead de criação: Cada thread requer alocação de
task_struct, stack de kernel (~8-16KB), e entrada na tabela de processos - Context switch mais caro: Requer transição user→kernel→user
- Escalabilidade limitada: Criar milhares de threads é viável, mas milhões não — cada uma consome memória de kernel e sobrecarrega o escalonador
- Sincronização via syscalls: Operações como mutex lock/unlock requerem traps para o kernel
Modelo 1:1 — No Linux moderno (NPTL - Native POSIX Thread Library), cada thread POSIX mapeia diretamente para uma kernel thread. Este é o modelo usado por:
- Python (cada thread Python = 1 kernel thread)
- .NET (cada thread gerenciada = 1 kernel thread)
- Java (desde Java 1.3+)
- Go (cada goroutine é multiplexada em OS threads, mas o modelo é efetivamente 1:1 para threads do kernel)
# Verificando threads de um processo
$ ls /proc/1350/task/
1350 1351 1352 1353 ← 4 threads (4 task_structs no kernel)
$ cat /proc/1350/status | grep Threads
Threads: 4
Modelos Híbridos (M:N)
O modelo M:N combina M user-level threads mapeadas em N kernel threads (onde M >> N). Busca obter o melhor dos dois mundos: escalabilidade de ULTs com paralelismo de KLTs.
┌───────────────────────────────────────────────────┐
│ Espaço de Usuário │
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │UT 1 │ │UT 2 │ │UT 3 │ │UT 4 │ │UT 5 │ │UT 6 │ │ ← M user threads
│ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ └──┬──┘ │
│ │ ╲ │ ╱ │ │ ╲ │ ╱ │ │
│ ▼ ╲ ▼ ╱ ▼ ▼ ╲ ▼ ╱ ▼ │ ← multiplexação
│ ┌─────────────┐ ┌───────┐ ┌─────────────┐ │
│ │ User Sched │ │ U.S. │ │ User Sched │ │
├──┴──────┬──────┴──┴───┬───┴──┴──────┬──────┴──────┤
│ ▼ ▼ ▼ Kernel │
│ ┌─────┐ ┌─────┐ ┌─────┐ │
│ │KLT 1│ │KLT 2│ │KLT 3│ │ ← N kernel threads
│ └─────┘ └─────┘ └─────┘ │
│ Kernel Scheduler │
└───────────────────────────────────────────────────┘
M = 6 user threads, N = 3 kernel threads → modelo 6:3 (ou 2:1)
Vantagens do M:N:
- Pode criar milhões de user threads sem sobrecarregar o kernel
- Paralelismo real (N kernel threads em N cores)
- Context switch rápido entre user threads no mesmo kernel thread
- Blocking syscalls podem ser mascaradas (a runtime move outras user threads para KLTs livres)
Desafios:
- Complexidade de implementação significativa
- Coordenação entre user scheduler e kernel scheduler
- Debugging mais difícil (stack traces podem ser confusos)
- Sincronização entre user threads e kernel primitives
O exemplo mais bem-sucedido de M:N na prática: o Go runtime.
Go runtime (modelo M:N):
G (Goroutines): G1 G2 G3 G4 G5 G6 ... G100000 ← milhões possíveis
│ │ │ │ │ │
└───┴───┼───┴───┴───┘
│
M (OS Threads): M1 M2 M3 M4 ← GOMAXPROCS kernel threads
│ │ │ │
P (Processors): P1 P2 P3 P4 ← logical processors
- G: goroutine (~2KB stack inicial, cresce dinamicamente)
- M: kernel thread (task_struct no Linux)
- P: contexto de processamento (run queue local)
Conexão com linguagens backend:
- Python: Modelo 1:1 (cada thread = kernel thread), mas o GIL impede paralelismo real para código Python puro
- .NET: Modelo 1:1 para threads, mas
Task/async-awaitimplementa um scheduler cooperativo em userspace sobre o thread pool- Go: Modelo M:N verdadeiro — goroutines são user-level threads multiplexadas em OS threads pelo Go scheduler
Implementação de Pop-up Threads
Pop-up threads são um padrão onde threads são criadas dinamicamente em resposta a eventos (tipicamente mensagens de rede chegando). Ao invés de manter um pool de threads bloqueadas em accept()/recv(), uma nova thread é "disparada" (pop-up) para tratar cada mensagem.
Modelo tradicional (thread pool blocking):
Thread 1: |── accept() ──────|── handle ──|── accept() ──────|
Thread 2: |── accept() ──────────────────────|── handle ──|
Thread 3: |── accept() ──────────────────────────────────────| ← idle, desperdiçando stack
Modelo pop-up:
msg arrives
│
▼
Dispatcher: |── wait ──|── spawn ──|── wait ──|── spawn ──|
│ │
Pop-up T1: |── handle ──| │
Pop-up T2: |── handle ──|
↑ ↑
thread criada sob demanda (sem estado prévio)
Vantagens:
- Thread começa "fresca" — sem estado anterior para salvar/restaurar
- Criação é mais rápida que acordar uma thread bloqueada (em implementações otimizadas)
- Sem overhead de threads ociosas consumindo stack
Desvantagens:
- Custo de criação pode ser alto se threads são kernel-level
- Sem limite inerente — pode criar threads demais sob carga alta (thundering herd)
Na prática, o conceito de pop-up threads inspirou modelos como:
- Goroutines em Go: extremamente baratas de criar (~2KB), usadas como pop-up threads para cada request
- Task.Run em .NET: cria uma task (executada por uma thread do pool) para cada operação
- Event-driven + thread pool: Node.js, asyncio — o event loop despacha trabalho CPU-bound para threads do pool
Thread Pools: Conceito e Benefícios
Um thread pool é um conjunto pré-alocado de threads que aguardam trabalho em uma fila. Ao invés de criar e destruir threads para cada tarefa, as threads são reutilizadas.
Thread Pool:
┌─────────────────────────────────────────────────────────────┐
│ │
│ Work Queue: [Task A] → [Task B] → [Task C] → ... │
│ │ │
│ ▼ │
│ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │Thread 1│ │Thread 2│ │Thread 3│ │Thread 4│ │
│ │ (busy) │ │ (busy) │ │(waitin)│ │(waitin)│ │
│ └────────┘ └────────┘ └────────┘ └────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Benefícios:
- Amortização do custo de criação: Threads são criadas uma vez e reutilizadas milhares de vezes
- Controle de recursos: Limita o número máximo de threads, prevenindo esgotamento de memória
- Redução de latência: Thread já existe quando o trabalho chega — não há atraso de criação
- Backpressure natural: Quando o pool está saturado, novas tarefas aguardam na fila, fornecendo um mecanismo natural de controle de carga
Dimensionamento do thread pool — uma das decisões mais impactantes para performance de backend:
Para workloads I/O-bound:
Threads ≈ N_cores × (1 + Wait_time / Service_time)
Exemplo: 8 cores, ratio wait/service = 9 (90% I/O)
Threads ≈ 8 × (1 + 9) = 80 threads
Para workloads CPU-bound:
Threads ≈ N_cores (ou N_cores + 1)
Exemplo: 8 cores, computação pura
Threads ≈ 8
Exemplos em linguagens backend:
- Python (Gunicorn): Workers (processos) com threads —
workers = 2*cores + 1,threads = 2-4por worker- .NET (ThreadPool): Auto-tuning com hill climbing algorithm — começa com
Environment.ProcessorCountthreads e ajusta dinamicamente- Go: Não usa thread pool explícito — o runtime gerencia OS threads dinamicamente (geralmente
GOMAXPROCS= número de cores)
# Monitorando thread pool em produção
# .NET: ThreadPool stats
$ dotnet-counters monitor --process-id 950 System.Runtime
ThreadPool Thread Count: 24
ThreadPool Queue Length: 0
ThreadPool Completed Items: 1,234,567
# Python: verificando threads de workers Gunicorn
$ ps -eLf | grep gunicorn | wc -l
48 # 16 workers × 3 threads cada
# Go: goroutines vs OS threads
# (via pprof ou GODEBUG=schedtrace=1000)
SCHED 1000ms: gomaxprocs=8 idleprocs=2 threads=10 idlethreads=3
runqueue=0 [2 0 1 0 0 3 0 0]
Comparação: Quando Usar Threads vs Processos
A escolha entre threads e processos é uma decisão arquitetural fundamental para aplicações backend:
| Critério | Threads | Processos |
|---|---|---|
| Isolamento | Fraco — crash em uma thread pode corromper todo o processo | Forte — crash isolado, outros processos continuam |
| Comunicação | Rápida — memória compartilhada direta | Lenta — IPC (pipes, sockets, shared memory explícita) |
| Overhead de criação | Baixo (~10-50μs) | Alto (~100-500μs) |
| Context switch | Rápido (mesmo address space) | Lento (TLB flush, troca de page tables) |
| Escalabilidade | Limitada pela memória de stack (milhares) | Limitada pela tabela de processos (milhares) |
| Debugging | Difícil (race conditions, deadlocks) | Mais simples (estados isolados) |
| Security | Mesmas permissões, uma vulnerabilidade compromete tudo | Isolamento de permissões possível |
Quando usar PROCESSOS:
├── Isolamento é crítico (ex: processando dados de múltiplos tenants)
├── O código pode crashar (ex: extensões C/C++ instáveis)
├── Precisa de security boundaries (ex: sandbox por request)
└── Linguagem tem GIL (Python) e precisa de paralelismo CPU
Quando usar THREADS:
├── Comunicação frequente entre unidades de trabalho
├── Baixa latência de criação é importante
├── Workload é I/O-bound (threads bloqueiam em I/O independentemente)
└── Memória compartilhada simplifica a arquitetura
Decisões práticas por linguagem:
- Python: Use processos (multiprocessing/Gunicorn workers) para CPU-bound; threads para I/O-bound (apesar do GIL, threads liberam o GIL durante I/O); asyncio para alta concorrência I/O
- .NET: Use threads/Tasks para tudo (sem GIL); processos apenas para isolamento extremo
- Go: Use goroutines para tudo — o runtime gerencia a complexidade; processos separados apenas para isolamento de serviços (microserviços)
Context Switching: Teoria
O context switch (troca de contexto) é o mecanismo pelo qual o kernel salva o estado de um processo/thread em execução e restaura o estado de outro, efetivamente transferindo a CPU de uma unidade de execução para outra. Embora invisível para a aplicação, o context switch é uma operação que ocorre milhares de vezes por segundo em um servidor backend — e seu custo acumulado pode ser significativo.
O que é Salvo: Anatomia de um Context Switch
Quando o kernel decide trocar o processo/thread em execução, ele precisa preservar todo o estado necessário para que o processo interrompido possa ser retomado exatamente de onde parou, como se nada tivesse acontecido.
Estado salvo por hardware (automático na troca de privilégio)
Na arquitetura x86-64, quando ocorre uma interrupção ou trap que causa transição para kernel mode, o processador automaticamente salva na kernel stack:
Stack do kernel após interrupção (x86-64):
┌─────────────────────┐ ← topo da kernel stack
│ SS (user stack seg) │
│ RSP (user stack ptr)│
│ RFLAGS │ ← flags de status (carry, zero, overflow, interrupt enable)
│ CS (code segment) │
│ RIP (program count) │ ← instrução onde o processo foi interrompido
└─────────────────────┘
Estado salvo pelo kernel (software)
O kernel salva explicitamente o restante do contexto na task_struct (ou estrutura associada como thread_struct):
Contexto salvo pelo kernel:
┌─────────────────────────────────────────────────────────┐
│ Registradores de Propósito Geral │
│ RAX, RBX, RCX, RDX, RSI, RDI, RBP │
│ R8, R9, R10, R11, R12, R13, R14, R15 │
├─────────────────────────────────────────────────────────┤
│ Program Counter (RIP) e Stack Pointer (RSP) │
├─────────────────────────────────────────────────────────┤
│ Registradores de Segmento (FS, GS — usados para TLS) │
├─────────────────────────────────────────────────────────┤
│ Estado da FPU/SSE/AVX │
│ Registradores XMM0-XMM15 (128 bits cada) │
│ Registradores YMM0-YMM15 (256 bits — AVX) │
│ Registradores ZMM0-ZMM31 (512 bits — AVX-512) │
│ MXCSR (controle SSE) │
│ x87 FPU state (legacy) │
├─────────────────────────────────────────────────────────┤
│ Estado de Debug (DR0-DR7) — se em uso │
├─────────────────────────────────────────────────────────┤
│ Informações de Escalonamento │
│ vruntime, prioridade efetiva, timeslice restante │
├─────────────────────────────────────────────────────────┤
│ Kernel stack pointer │
└─────────────────────────────────────────────────────────┘
O estado FPU/SIMD é particularmente volumoso — com AVX-512, pode ser mais de 2KB por contexto. O Linux usa lazy FPU saving (ou, em kernels modernos, eager FPU saving com XSAVE/XRSTOR) para otimizar esse custo.
O que NÃO é salvo (compartilhado entre threads do mesmo processo)
- Espaço de endereçamento (page tables) — por isso context switch entre threads é mais barato
- File descriptors
- Sinais e handlers
- Credenciais (UID/GID)
- Working directory
Continua nos próximos capítulos... :D
Conteudo parcialmente gerado com auxilio de IA generativa (eu organizei o conteudo e ela me ajudou com lero lero, novos tempos kkkk)
Referências Bibliográficas
Livros
- Desenvolvimento do Kernel do Linux — Robert Love (David Cram, trad.)
- Linux Bible — Christopher Negus
- Sistemas Operacionais Modernos — Andrew Tanenbaum
- Systems Performance, 2nd Edition — Brendan Gregg (fonte dos valores de benchmark de fork/clone/context switch)
Documentação Oficial
Ferramentas
- lmbench — benchmark de latências de OS (utilizado para medir custos de fork, clone e context switch)
Top comments (0)