MÓDULO 04 · CONCEITO 02 DE 14

Threads, processos e o modelo do SO

Recap profundo do que está debaixo de toda concorrência. Scheduler, preempção, context switch, stack memory — o que o SO te dá de graça e o que ele cobra em cache miss e troca de contexto.

Tempo de leitura ~22 min Pré-requisito Conceito 01 + Módulo 01 (sistema) Próximo Coroutines, fibers e execução suspensa

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:

  1. 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.
  2. Atualiza estruturas internas do kernel (run queue, accounting de CPU usado, prioridade, vruntime).
  3. 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).
  4. Restaura registradores da próxima thread do kernel stack.
  5. 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.

princípio orientador

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.

C# — Tasks no ThreadPool (M:N)
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.

Python — asyncio (M:1 cooperativo)
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.

Go — goroutines (M:N)
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.

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.

armadilha em produção

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

  1. Compare o custo de threading entre modelos. Escreva o programa "10.000 unidades de trabalho que esperam 1 segundo" em quatro versões: threading.Thread em Python (vai falhar ou ser muito lento), asyncio em Python, Task em C# e goroutines em Go. Meça memória (RSS) e tempo total. Documente a diferença.
  2. Observe context switches em tempo real. Rode vmstat 1 em um terminal. Em outro, dispare seu programa concorrente. Veja a coluna cs responder. 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 em sy (system time, gasto no kernel) crescer.
  3. 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

  1. livro Operating Systems: Three Easy Pieces — Remzi & Andrea Arpaci-Dusseau (gratuito). pages.cs.wisc.edu/~remzi/OSTEP — Parte I cobre virtualização (CPU, memória) com clareza incomum. Capítulos sobre scheduler e threads são leitura obrigatória.
  2. livro Systems Performance — Brendan Gregg (2ª ed., 2020). Referência definitiva para investigar performance em Linux moderno. Cobre perf, BPF, USE method, observabilidade de threading e schedulers.
  3. livro Linux Kernel Development — Robert Love (3ª ed., 2010). Datado em alguns detalhes (CFS, não EEVDF), mas a explicação dos mecanismos de scheduling e threading é a melhor disponível em livro.
  4. livro Modern Operating Systems — Andrew S. Tanenbaum & Herbert Bos (4ª ed., 2014). Texto canônico de SO. Caps. 2 e 6 cobrem processes, threads e deadlock com profundidade acadêmica e exemplos práticos.
  5. artigo What Every Programmer Should Know About Memory — Ulrich Drepper (2007). people.freebsd.org/~lstewart/articles/cpumemory.pdf — 100+ páginas sobre caches, NUMA, false sharing. Antigo, ainda atual em fundamentos.
  6. artigo The Linux Scheduler: a Decade of Wasted Cores — Lozi et al. (2016). people.ece.ubc.ca/sasha/papers/eurosys16-final29.pdf — Estudo influente que motivou várias correções no CFS. Bom para apreciar a complexidade real do scheduler.
  7. artigo Why Project Loom? — Ron Pressler (2020). cr.openjdk.org/~rpressler/loom/Loom-Proposal.html — Justificativa de virtual threads em Java. Argumentação clara sobre o custo de threads kernel e por que M:N voltou.
  8. artigo Go's work-stealing scheduler — Dmitry Vyukov (design doc, 2012). docs.google.com/document/d/1TTj4T2JO42uD5ID9e89oa0sLKhJYD0Y_kqxDv3I3XMw — Como o runtime do Go multiplexa goroutines em kernel threads.
  9. docs Linux man pages — pthread(7), sched(7), clone(2). man7.org — As fontes primárias sobre threading e scheduling em Linux. Lê-se em uma sessão e calibra entendimento.
  10. docs Brendan Gregg's Linux Performance. brendangregg.com/linuxperf.html — Mapa visual de quais ferramentas observam quais subsistemas. Imprima e cole na parede.
  11. vídeo Concurrency in Go (talk) — Rob Pike, GopherCon. YouTube. Pike explica como o scheduler do Go funciona em alto nível. Cobre M:N na prática.
  12. vídeo EEVDF in Linux 6.6 — talks de Linux Plumbers Conference 2023. YouTube. Apresentação técnica da substituição do CFS por EEVDF, com benchmarks e motivação.