DEV Community

Alex Volnei Galante
Alex Volnei Galante

Posted on

Kernel Linux para Desenvolvedores Backend - Gerenciamento de Memória Parte III

[!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

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

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

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:

  1. 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.
  2. A execução começa. A primeira instrução dispara um page fault → o kernel carrega aquela página do binário.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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

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

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]
O gc.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.
Enter fullscreen mode Exit fullscreen mode

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:

Layout Básico de Memoria

  • 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 do malloc, do CPython, do CLR, do Go).
  • mmap region: onde ficam bibliotecas compartilhadas, arquivos mapeados e grandes alocações (allocadores modernos usam mmap para blocos grandes em vez de brk).
  • 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
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

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

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

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

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)

Referências Bibliográficas

Top comments (0)