O que é um Sistema Operacional
Um sistema operacional é uma camada de software entre o hardware e os programas que rodam na máquina. Do ponto de vista do programador, de cima pra baixo, ele funciona como uma API: você chama open(), read(), fork() e não precisa saber como o disco ou a memória funcionam por baixo. Do ponto de vista do hardware, de baixo pra cima, o SO é um gestor: ele decide quem usa a CPU, quem ocupa qual região de memória, quem acessa o disco agora e quem espera.
Uma das unidades fundamentais dessa gestão é o processo.
O que é um processo
Um processo é um programa em execução. Quando você roda um script Python, o SO cria um processo e aloca pra ele:
- Um espaço de endereçamento próprio, que é a região de memória onde ficam o código, as variáveis globais, o heap (alocação dinâmica) e o stack (chamadas de função e variáveis locais).
- Um contexto de execução, que inclui os registradores da CPU, o program counter (qual instrução está sendo executada) e o estado do processo (rodando, pronto, bloqueado).
- Recursos do SO, como file descriptors, sockets e permissões.
O ponto central: cada processo tem seu espaço de memória isolado. O processo A não acessa a memória do processo B. Essa proteção é feita pelo hardware (MMU) e garantida pelo SO. Se o processo A crashar, o B continua rodando.
O que é uma thread
Uma thread é uma linha de execução dentro de um processo. Se o processo é a unidade de alocação de recursos, a thread é a unidade de escalonamento, porque é ela que a CPU efetivamente executa.
Todas as threads de um mesmo processo compartilham o espaço de endereçamento: heap, variáveis globais, código, file descriptors. O que cada thread tem de próprio é seu stack, seu program counter e seus registradores.
Threads existem porque criar um processo é caro. O SO precisa montar um novo espaço de endereçamento, copiar estruturas do kernel, criar page tables. Uma thread não precisa de nada disso porque ela reusa tudo do processo que já existe. A criação é ordens de grandeza mais rápida.
A outra razão é comunicação. Dois processos que precisam trocar dados têm que usar IPC (pipes, sockets, shared memory, filas). Duas threads do mesmo processo leem e escrevem nas mesmas variáveis diretamente, porque a memória é compartilhada.
Problemas de paralelizar com processos
Se você precisa executar tarefas em paralelo e decide criar múltiplos processos, o custo aparece em dois lugares.
O primeiro é a criação. Cada processo novo exige que o SO aloque um espaço de endereçamento, copie as estruturas internas (via fork() no Unix, que usa copy-on-write pra adiar a cópia real, mas ainda assim tem custo), registre o novo processo na tabela de processos do kernel.
O segundo, e mais relevante em runtime, é a troca de contexto. Quando o SO decide que o processo A já usou seu tempo de CPU e agora é a vez do processo B, ele precisa:
- Salvar todo o estado do processo A (registradores, program counter).
- Trocar o espaço de endereçamento. Isso significa trocar as tabelas de paginação de memória e invalidar um cache de hardware que mapeia endereços virtuais pra físicos.
- Carregar o estado do processo B.
A invalidação do cache é o que torna a troca entre processos significativamente mais cara do que entre threads. Depois da troca, o processo B começa com o cache vazio e cada acesso à memória causa um cache miss até ficar funcional de novo.
Se você tem 8 cores e cria 64 processos, o SO precisa ficar trocando processos o tempo todo. Cada troca polui o cache, e o tempo gasto gerenciando essa rotação é tempo que não está sendo usado pra computação útil. A partir de certo ponto, criar mais processos faz o sistema ficar mais lento, não mais rápido.
Problemas de paralelizar com threads
Threads são mais leves pra criar e a troca de contexto entre threads do mesmo processo é mais barata. Mas o problema das threads é outro: concorrência.
Como threads compartilham memória, duas threads podem ler e escrever na mesma variável ao mesmo tempo. Isso gera race conditions.
# Execute com: PYTHON_GIL=0 python3.13t script.py
import sys
import threading
print(f"GIL ativo: {sys._is_gil_enabled()}") # False
counter = 0
def increment():
global counter
for _ in range(1_000_000):
counter += 1
t1 = threading.Thread(target=increment)
t2 = threading.Thread(target=increment)
t1.start()
t2.start()
t1.join()
t2.join()
print(counter) # Esperado: 2.000.000. Resultado real: algo menor.
counter += 1 parece uma operação única, mas no nível da CPU são três: ler o valor, incrementar, escrever de volta. Se duas threads executam essas etapas intercaladas, uma sobrescreve o resultado da outra.
A solução padrão é usar locks (mutex):
# Mesmo script, agora com lock
counter = 0
lock = threading.Lock()
def increment_safe():
global counter
for _ in range(1_000_000):
with lock:
counter += 1
t1 = threading.Thread(target=increment_safe)
t2 = threading.Thread(target=increment_safe)
t1.start()
t2.start()
t1.join()
t2.join()
print(counter) # 2.000.000
O lock garante que só uma thread por vez executa aquele trecho. Funciona, mas serializa a execução ali. Se a maior parte do trabalho precisa de lock, o ganho de paralelismo desaparece.
E locks trazem seus próprios problemas: deadlock (duas threads esperando uma pela outra) e starvation (uma thread nunca consegue adquirir o lock). Gerenciar concorrência corretamente é um dos problemas mais difíceis de engenharia de software. A dificuldade não está em escrever o código, está em raciocinar sobre todas as possíveis intercalações de execução.
Na prática
# Execute com: PYTHON_GIL=0 python3.13t benchmark.py
import sys
import threading
import multiprocessing
import time
import os
print(f"GIL ativo: {sys._is_gil_enabled()}") # False
def cpu_work(n):
"""Simula trabalho que usa CPU."""
total = 0
for i in range(n):
total += i * i
return total
N = 10_000_000
# Sequencial
start = time.perf_counter()
cpu_work(N)
cpu_work(N)
seq_time = time.perf_counter() - start
# 2 threads
start = time.perf_counter()
t1 = threading.Thread(target=cpu_work, args=(N,))
t2 = threading.Thread(target=cpu_work, args=(N,))
t1.start(); t2.start()
t1.join(); t2.join()
thread_time = time.perf_counter() - start
# 2 processos
start = time.perf_counter()
p1 = multiprocessing.Process(target=cpu_work, args=(N,))
p2 = multiprocessing.Process(target=cpu_work, args=(N,))
p1.start(); p2.start()
p1.join(); p2.join()
process_time = time.perf_counter() - start
print(f"Sequencial: {seq_time:.2f}s")
print(f"2 threads: {thread_time:.2f}s")
print(f"2 processos: {process_time:.2f}s")
print(f"Cores: {os.cpu_count()}")
# 64 processos na mesma máquina de 8 cores
start = time.perf_counter()
processes = []
for _ in range(64):
p = multiprocessing.Process(target=cpu_work, args=(N,))
processes.append(p)
p.start()
for p in processes:
p.join()
many_process_time = time.perf_counter() - start
print(f"64 processos: {many_process_time:.2f}s")
print(f"Tempo ideal se não houvesse overhead: {seq_time * 32 / os.cpu_count():.2f}s")
Esse código roda a mesma tarefa CPU-bound de três formas. Num teste típico com múltiplos cores e GIL desabilitado, o resultado é algo como:
GIL ativo: False
Sequencial: 0.79s
2 threads: 0.42s
2 processos: 0.41s
Cores: 8
64 processos: 3.98s
Tempo ideal se não houvesse overhead: 3.14s
Sem o GIL, threads e processos dão speedup parecido pra trabalho CPU-bound. Quase metade do tempo sequencial, porque cada unidade de execução roda em um core separado. A diferença é que threads são mais leves (menos overhead de criação, sem troca de espaço de endereçamento), enquanto processos dão isolamento de memória. Se um processo crashar, os outros continuam. Se uma thread crashar, o processo inteiro morre.
Agora olha o que acontece com 64 processos na mesma máquina de 8 cores. Roda o benchmark e compara o tempo real com o tempo ideal (se não houvesse nenhum overhead de troca de contexto). A diferença entre os dois é o custo da troca de contexto, invalidação de cache e criação de processos.
No próximo post da série: o GIL do Python, por que ele existe, o que muda com ele desabilitado, e como asyncio resolve concorrência de I/O sem threads.
Referências: TANENBAUM, A. S. Sistemas Operacionais Modernos, Cap. 2. ARPACI-DUSSEAU, R. e A. Operating Systems: Three Easy Pieces (OSTEP), Part I.