[!NOTE]
Continuação da série. Na Parte II cobrimos paginação, tabelas de páginas multinível (PGD/PUD/PMD/PTE), TLB e segmentação. Agora entramos na parte dinâmica: o que acontece em tempo de execução quando seu processo toca a memória.
Sumário
- Page Faults: o coração da memória virtual
- Demand Paging
- Copy-on-Write: por que fork() é barato
- Zero-fill on Demand
- O Memory Layout de um processo real
- Paging e Swapping modernos
- Referências Bibliográficas
Page Faults: o coração da memória virtual
Na Parte II vimos o bit Present na PTE. Quando a MMU tenta traduzir um endereço virtual e encontra uma PTE com Present = 0 (ou uma violação de permissão), ela dispara uma exceção de hardware: o page fault. O controle passa imediatamente ao kernel, que decide o que fazer.
É crucial entender: page faults são normais e esperados. Eles não são erros — são o mecanismo pelo qual a memória virtual funciona. Seu processo dispara milhares deles por segundo, e a maioria é resolvida em nanossegundos.
Minor vs Major faults
A distinção mais importante para diagnóstico em produção:
Minor fault (soft fault): a página já está na RAM, mas não havia mapeamento na tabela de páginas daquele processo. O kernel só precisa criar a PTE. É rápido — não envolve disco. Exemplos: primeira escrita numa página recém-alocada, compartilhamento de uma biblioteca já carregada por outro processo, acesso a uma página que estava no page cache.
Major fault (hard fault): a página não está na RAM e precisa ser trazida do disco (do swap, ou de um arquivo mapeado). Envolve I/O de disco, então é ordens de magnitude mais lento — pode custar milissegundos. Uma rajada de major faults é um sintoma clássico de pressão de memória ou thrashing.
# Ver minor e major faults de um processo (colunas min_flt e maj_flt)
ps -o min_flt,maj_flt,cmd -p <PID>
# Monitorar page faults ao vivo
perf stat -e minor-faults,major-faults -p <PID>
# Faults do sistema inteiro (campos pgfault, pgmajfault)
grep -E 'pgfault|pgmajfault' /proc/vmstat
Regra de ouro para produção: um número alto de minor faults é geralmente inofensivo (é só a aplicação alocando memória). Um número alto e crescente de major faults é um alarme — significa que seu processo está batendo no disco para acessar memória, o que destrói a latência.
Agora vamos falar a verdade pura, 99% dos desenvolvedores nunca fizeram esse tipo de troubleshooting, mas é fundamental para backend: entender page faults, demand paging, COW e zero-fill on demand é o que separa um engenheiro que entende de memória de um que só sabe alocar objetos e esperar que o GC resolva.
A sequência de tratamento
Quando um page fault ocorre, o handler do kernel (do_page_fault no x86) segue, simplificadamente:
1. A MMU dispara o fault e informa o endereço que causou o problema (registrador CR2 no x86).
2. O kernel consulta as VMAs (Virtual Memory Areas) do processo:
- O endereço pertence a alguma região válida do processo?
└── NÃO → SIGSEGV (segmentation fault). Processo geralmente morre.
└── SIM → continua.
3. O acesso é permitido? (ex: escrita numa página read-only)
└── Violação real → SIGSEGV
└── É Copy-on-Write → copia a página, marca como writable (ver adiante)
4. A página está na RAM?
└── SIM (minor) → cria/atualiza a PTE, retoma a execução
└── NÃO (major) → aloca frame, busca do disco/swap, cria PTE, retoma
Repare que o mesmo mecanismo — o page fault — serve a vários propósitos: detectar acessos inválidos (SIGSEGV), implementar demand paging, viabilizar Copy-on-Write e trazer páginas do swap. É um dos designs mais elegantes de todo o kernel.
Demand Paging
Munidos do conceito de page fault, entendemos o demand paging: o kernel não carrega um programa inteiro na memória quando ele inicia. Em vez disso, carrega páginas sob demanda, conforme são realmente acessadas.
Quando você executa um binário de 200 MB:
- O kernel mapeia o arquivo no espaço virtual do processo (via
mmap), mas quase nada é carregado na RAM ainda. As PTEs estão com Present = 0. - A execução começa. A primeira instrução dispara um page fault → o kernel carrega aquela página do binário.
- Conforme a execução avança e toca novas páginas, cada uma é trazida sob demanda.
O resultado: um processo com 200 MB de binário pode iniciar tocando só alguns MB — exatamente o seu working set inicial. Isso torna a inicialização rápida e economiza RAM enormemente, especialmente quando várias instâncias do mesmo binário rodam (elas compartilham as páginas de código, read-only, na RAM).
Conexão backend: é por isso que o RSS (Resident Set Size) de um processo recém-iniciado é muito menor que o tamanho virtual (VSZ). O processo "reservou" um espaço virtual grande, mas só uma fração está fisicamente residente.
# VSZ (virtual) vs RSS (residente físico)
ps -o vsz,rss,cmd -p <PID>
# VSZ RSS CMD
# 2415616 85320 python app.py → reservou ~2.4 GB virtual, usa ~85 MB físico
Copy-on-Write: por que fork() é barato
Este é, talvez, o conceito de memória virtual com maior impacto direto em backend, especialmente no mundo Python.
Quando um processo faz fork(), o kernel precisa criar um filho com uma cópia do espaço de endereçamento do pai. Copiar gigabytes de memória a cada fork() seria proibitivo. A solução é o Copy-on-Write (COW):
Logo após fork():
Pai ──┐
├──▶ [Página compartilhada, marcada READ-ONLY na PTE de ambos]
Filho ─┘
Quando um deles TENTA ESCREVER:
1. A escrita numa página read-only dispara um page fault
2. O kernel percebe que é COW (não uma violação real)
3. Copia a página para um novo frame
4. Marca a cópia como writable para quem escreveu
5. Retoma a execução
Pai ──▶ [Página original]
Filho ──▶ [Cópia privada] ← só agora houve cópia real
Ou seja: pai e filho compartilham fisicamente todas as páginas até que uma delas seja modificada. Só as páginas efetivamente escritas são copiadas. Páginas apenas lidas (como o código) nunca são copiadas.
O impacto em workers pre-forked (Python/Gunicorn/uWSGI)
Servidores Python usam o modelo pre-fork: um processo master carrega a aplicação e faz fork() de vários workers. Em teoria, graças ao COW, os workers compartilham toda a memória do master (código, dados carregados no import), consumindo pouca RAM adicional cada.
Na prática, há uma armadilha traiçoeira: o reference counting do CPython. Toda vez que o Python toca um objeto — mesmo só para ler — ele incrementa e decrementa o campo ob_refcnt na struct do objeto. Isso é uma escrita na página onde o objeto vive, o que quebra o COW: a página é copiada, mesmo que você só quisesse ler o objeto.
# Antipadrão para COW em Python pre-forked:
# um objeto grande no master, "só de leitura" nos workers.
CONFIG_GIGANTE = carregar_dados_enormes() # no master, antes do fork
def handler(request):
# Mesmo APENAS LENDO, o refcount de CONFIG_GIGANTE (e dos objetos
# internos tocados) é incrementado/decrementado → escrita na página
# → COW dispara → a página "compartilhada" é copiada para o worker.
return CONFIG_GIGANTE[request.chave]
O resultado é o famoso fenômeno de workers Python que incham com o tempo: memória que deveria ser compartilhada acaba sendo copiada worker a worker. Estratégias de mitigação (usar estruturas fora do heap gerenciado, como arrays NumPy ou mmap, mover dados para um processo/serviço externo como Redis) serão discutidas na Parte IV, junto com a conexão CPython.
[!TIP]
Ogc.freeze()(Python 3.7+) move os objetos existentes para uma geração "permanente" antes do fork, evitando que o garbage collector toque os headers deles e quebre o COW. É uma otimização valiosa para workers pre-forked.
Zero-fill on Demand
Quando seu programa pede memória nova (via malloc/brk/mmap anônimo), o kernel não aloca frames físicos imediatamente. Ele apenas registra que aquela região virtual existe. Mais que isso: por segurança, memória nova precisa ser zerada (senão você leria lixo — possivelmente dados sensíveis de outro processo).
O kernel otimiza isso com uma página especial: a zero page. Todas as páginas anônimas recém-alocadas são inicialmente mapeadas (read-only) para uma única página física preenchida com zeros:
malloc(1 GB):
- Nenhum frame físico é alocado ainda.
- Todas as páginas virtuais apontam (read-only) para a MESMA zero page.
- RSS quase não muda!
Primeira ESCRITA em uma página:
- Page fault (COW sobre a zero page)
- Aí sim o kernel aloca um frame real, zerado, e o torna privado/writable.
Conexão backend: é por isso que malloc de 1 GB retorna instantaneamente e o RSS mal se move. A memória só é "materializada" quando escrita pela primeira vez. Isso explica por que ferramentas de monitoramento mostram um processo "alocando" muita memória virtual mas com RSS baixo — e por que o consumo real de RAM cresce conforme a aplicação usa (escreve) a memória, não quando a reserva. Isso é o overcommit de memória, e é também por que o OOM Killer (Artigo 3) pode aparecer "do nada": o kernel prometeu mais memória do que tem, apostando que nem todos vão escrever tudo.
O Memory Layout de um processo real
Vamos juntar tudo e olhar como um processo Linux organiza seu espaço virtual. Aqueles "segmentos" lógicos da Parte II aparecem aqui, implementados sobre paginação:
- Text: o código executável. Read-only e compartilhável entre processos.
- Data / BSS: variáveis globais. Data tem valores iniciais no binário; BSS é zero-fill on demand.
-
Heap: cresce "para cima" via
brk/sbrk. É onde vivem os objetos dinâmicos (o heap domalloc, do CPython, do CLR, do Go). -
mmap region: onde ficam bibliotecas compartilhadas, arquivos mapeados e grandes alocações (allocadores modernos usam
mmappara blocos grandes em vez debrk). - Stack: cresce "para baixo". Cada thread tem sua própria stack.
Lendo /proc/pid/maps
O Linux expõe esse layout num arquivo legível. É uma das ferramentas de diagnóstico de memória mais úteis que existem:
cat /proc/self/maps
55a3c0000000-55a3c0021000 r--p 00000000 08:01 1311 /usr/bin/python3.11 ← text (read-only)
55a3c0021000-55a3c0200000 r-xp 00021000 08:01 1311 /usr/bin/python3.11 ← código executável
55a3c0400000-55a3c0500000 rw-p 00000000 00:00 0 ← heap (anônimo, rw)
7f2a4c000000-7f2a4c021000 r-xp 00000000 08:01 2201 /lib/libc.so.6 ← libc mapeada
7ffe1a3c0000-7ffe1a3e1000 rw-p 00000000 00:00 0 [stack] ← stack
Cada linha é uma VMA (Virtual Memory Area) — uma região contígua com as mesmas permissões. As colunas de permissão (r, w, x, p=private/s=shared) refletem exatamente os bits de proteção das PTEs que vimos na Parte II. Para detalhamento de uso físico por região, existe /proc/pid/smaps (mostra RSS, PSS, dirty pages por VMA) — voltaremos a ele nas técnicas de diagnóstico da Parte IV.
mmap: o canivete suíço da memória
A syscall mmap mapeia algo diretamente no espaço de endereçamento do processo. Ela é fundamental e aparece em todo lugar:
-
mmap anônimo: aloca memória "pura" (é o que
mallocusa para blocos grandes). - mmap de arquivo: mapeia um arquivo na memória, permitindo lê-lo/escrevê-lo como se fosse um array — o kernel cuida do I/O via page faults e page cache (tema central do Artigo 3).
- mmap compartilhado: várias instâncias mapeiam a mesma região → memória compartilhada entre processos.
import mmap
# Mapeia um arquivo grande sem carregá-lo inteiro na RAM.
# O acesso dispara page faults que trazem só as páginas tocadas.
with open("dados_gigantes.bin", "rb") as f:
mm = mmap.mmap(f.fileno(), 0, prot=mmap.PROT_READ)
primeiro_registro = mm[0:1024] # só esta página é trazida do disco
mm.close()
Cada linguagem tem seu equivalente: MemoryMappedFile no .NET, syscall.Mmap/mmap no Go. Exploraremos esses padrões no Artigo 3, sobre page cache.
ASLR
O ASLR (Address Space Layout Randomization) é uma defesa de segurança: a cada execução, o kernel randomiza as posições da stack, do heap, das bibliotecas e (com PIE) do próprio código. Isso dificulta ataques que dependem de saber endereços fixos (como ROP/return-to-libc).
# Status global do ASLR (2 = totalmente ativado, o padrão)
cat /proc/sys/kernel/randomize_va_space
# Rode 'cat /proc/self/maps | grep stack' duas vezes:
# o endereço da [stack] muda a cada execução → ASLR em ação.
Kernels 6.x trouxeram melhorias na entropia e granularidade do ASLR. Para backend, o impacto é quase sempre transparente — mas vale saber que ele existe quando você vê endereços diferentes a cada boot da aplicação, e que desabilitá-lo (algo que às vezes se faz para debugging) reduz a segurança.
Paging e Swapping modernos
Fechamos com o swapping — mas agora, com paginação, ele é muito mais refinado que o swapping clássico de processos inteiros da Parte I. O kernel move páginas individuais entre a RAM e o disco, não processos inteiros.
Quando a memória fica escassa, o kernel escolhe páginas "frias" (pouco usadas — lembra dos bits Accessed/Dirty?) e as move para a área de swap no disco, liberando frames. Se a página for acessada depois, um major fault a traz de volta. Os algoritmos que decidem quais páginas remover são um tópico rico — e são exatamente o assunto de abertura do Artigo 3 (algoritmos de substituição, working set, thrashing).
Páginas anônimas vs file-backed
Uma distinção que o kernel faz o tempo todo, e que você precisa conhecer:
Páginas file-backed: têm uma "casa" no disco — um arquivo. Código, bibliotecas, arquivos mapeados. Quando o kernel precisa liberá-las, se estiverem limpas (não modificadas), basta descartá-las — o conteúdo pode ser relido do arquivo original quando necessário. Não precisam de swap.
Páginas anônimas: não têm arquivo de origem — heap, stack, memória de
malloc. Elas não têm para onde voltar a não ser o swap. Se não há swap configurado e a memória acaba, o kernel não tem como liberá-las → é aí que o OOM Killer entra em cena.
# Ver uso de swap do sistema
free -h
# total usado livre compart. buff/cache
# Mem: 15Gi 8Gi 1Gi 200Mi 6Gi
# Swap: 4Gi 512Mi 3Gi
# Swap por processo (VmSwap)
grep VmSwap /proc/<PID>/status
O parâmetro vm.swappiness controla o quão agressivamente o kernel prefere fazer swap de páginas anônimas versus descartar páginas de cache — mas esse é um assunto de tuning que aprofundaremos no Artigo 3, junto com memory reclaim e pressão de memória.
Kernels 5.14+ trouxeram melhorias importantes no swap para dispositivos rápidos (SSD/NVMe), tornando o swap muito menos "assustador" do que era na era dos HDDs — em SSD, o swap pode ser uma ferramenta legítima de gestão de memória, não apenas um último recurso.
Continua na Parte IV, onde fechamos o Artigo 2 com Memory Zones e NUMA, a conexão profunda com os runtimes de Python, .NET e Go, técnicas de diagnóstico (/proc/pid/smaps, pmap, perf, numastat), boas práticas e estudos de caso reais.
:D
Conteúdo parcialmente gerado com auxílio de IA generativa (me ajudou a organizar tudo isso kkkk)

Top comments (0)