DEV Community

Alex Volnei Galante
Alex Volnei Galante

Posted on

Kernel Linux para Desenvolvedores Backend - Processos & Threads Parte III

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

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     │
└─────────┴─────────┴─────────┴───────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/await e 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
│  └──────────────────────────────┘    │
└──────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
└──────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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-await implementa 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)
Enter fullscreen mode Exit fullscreen mode

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)│            │
│   └────────┘  └────────┘  └────────┘  └────────┘            │
│                                                             │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Benefícios:

  1. Amortização do custo de criação: Threads são criadas uma vez e reutilizadas milhares de vezes
  2. Controle de recursos: Limita o número máximo de threads, prevenindo esgotamento de memória
  3. Redução de latência: Thread já existe quando o trabalho chega — não há atraso de criação
  4. 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
Enter fullscreen mode Exit fullscreen mode

Exemplos em linguagens backend:

  • Python (Gunicorn): Workers (processos) com threads — workers = 2*cores + 1, threads = 2-4 por worker
  • .NET (ThreadPool): Auto-tuning com hill climbing algorithm — começa com Environment.ProcessorCount threads 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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
└─────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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                                    │
└─────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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

Documentação Oficial

Ferramentas

Top comments (0)