Uffaaa cansativo não? bem eu quiz trazer com bastante profundidade o tema pra dar a devida imersão, só os fortes sobrevivem kkkkk.
Sumário
Princípios de cache e buffering
Antes do page cache do Linux, vale relembrar os princípios de cache que Tanenbaum apresenta — porque o page cache é uma aplicação direta deles:
- Cache hit vs miss: o dado buscado está no cache (hit, rápido) ou precisa vir da fonte lenta (miss).
- Write-through: toda escrita vai imediatamente para a camada lenta (disco). Seguro, mas lento.
- Write-back: escritas ficam no cache e são gravadas no disco mais tarde, em lote. Rápido, mas há uma janela de risco (dados na RAM ainda não persistidos).
- Read-ahead (prefetching): trazer dados antes de serem pedidos, apostando na localidade espacial.
Historicamente, o Unix tinha dois caches separados: o buffer cache (para blocos do disco) e o page cache (para páginas de arquivos mapeados). Isso causava duplicação e incoerência. Desde o Kernel 2.4, o Linux os unificou: hoje há um único page cache que serve tanto o I/O de arquivos quanto o mapeamento de memória.
O Page Cache
O page cache é a estrutura que guarda, na RAM, páginas de arquivos que foram lidos ou escritos. É, provavelmente, o mecanismo com maior impacto na performance de I/O de qualquer aplicação backend — e o mais mal compreendido.
Quando sua aplicação lê um arquivo:
1ª leitura de arquivo.dat:
read() → page cache MISS → busca no disco (lento, major cost) →
copia para o page cache → entrega à aplicação
2ª leitura do MESMO arquivo (por qualquer processo!):
read() → page cache HIT → entrega direto da RAM (nanossegundos)
SEM tocar o disco
O page cache é compartilhado por todo o sistema. Se o processo A lê um arquivo e o processo B lê o mesmo arquivo depois, B aproveita o cache aquecido por A. É por isso que a segunda execução de um teste, um build, ou uma query que lê os mesmos arquivos é sempre mais rápida.
"Free memory is wasted memory"
Aqui está a fonte de confusão número um de quem administra servidores Linux. Você roda free -h e vê:
total usado livre compart. buff/cache disponível
Mem: 15Gi 3Gi 500Mi 200Mi 11Gi 11Gi
"Só 500 MB livres?! Vou tomar OOM!" — não. Aqueles 11 GB em buff/cache são page cache: cópias de arquivos que o kernel manteve na RAM porque a memória estava ociosa. Essa memória é disponível (coluna disponível/available): no instante em que a aplicação precisar, o kernel descarta o cache limpo e entrega os frames. É por isso que a coluna que importa é a available, não a free.
A filosofia do Linux é: memória livre é memória desperdiçada. RAM parada não ajuda ninguém; melhor usá-la como cache e liberá-la sob demanda. Um servidor "saudável" com uptime longo naturalmente tem quase toda a RAM em page cache.
# A coluna que realmente importa é 'available'
free -h
# Detalhe do page cache no /proc/meminfo
grep -E 'Cached|Buffers|Dirty|Writeback' /proc/meminfo
# Cached: 11534336 kB ← page cache
# Dirty: 45000 kB ← modificado, ainda não gravado no disco
# Writeback: 0 kB ← sendo gravado agora
Read-ahead: leitura especulativa
Quando o kernel detecta um padrão de leitura sequencial, ele antecipa: em vez de trazer só a página pedida, traz também as próximas para o page cache, apostando que serão lidas em breve (localidade espacial em ação). Quando o pedido chega, a página já está lá.
# Ver/ajustar o tamanho do read-ahead de um dispositivo (em KB)
cat /sys/block/nvme0n1/queue/read_ahead_kb # ex.: 128
# Leituras sequenciais grandes (ETL, backup, scan de tabela) se beneficiam
# de read-ahead maior; acesso aleatório (banco OLTP) prefere valores menores.
Write-back: escritas atrasadas e dirty pages
Quando você escreve num arquivo, o dado não vai imediatamente ao disco. Ele é marcado como dirty (página suja) no page cache, e um conjunto de threads do kernel (historicamente pdflush, hoje as flusher threads por dispositivo) grava as páginas sujas no disco assincronamente, mais tarde. Isso é write-back, e é o que torna write() tão rápido.
O trade-off é durabilidade: dados sujos vivem só na RAM até o flush. Um crash ou queda de energia nessa janela perde os dados. É por isso que bancos de dados chamam fsync() — para forçar a gravação imediata e garantir persistência (voltaremos a fsync no Artigo 4).
# Parâmetros que controlam o write-back
sysctl vm.dirty_ratio # % de RAM suja que BLOQUEIA a aplicação escrevendo
sysctl vm.dirty_background_ratio # % de RAM suja que ACORDA as flusher threads
sysctl vm.dirty_expire_centisecs # idade máxima de uma página suja antes do flush
# Forçar flush de tudo agora
sync
[!WARNING]
Em servidores com muita RAM,dirty_ratioalto pode acumular gigabytes de dados sujos. Quando o flush finalmente dispara, ele satura o disco e causa picos de latência ("write storms"). Em workloads write-heavy, muitas vezes reduz-sedirty_ratio/dirty_background_ratiopara suavizar a gravação.
Multi-Generational LRU (MGLRU)
Por décadas, o Linux usou um esquema de duas listas LRU (active/inactive) para decidir quais páginas do cache manter — uma aproximação do LRU que estudamos na Parte V. A partir do Kernel 6.1, chegou o MGLRU (Multi-Generational LRU), que organiza as páginas em múltiplas gerações por idade, aproximando o LRU real com muito mais precisão e menor custo de CPU.
O MGLRU melhora significativamente a decisão de reclaim sob pressão, reduzindo tanto o thrashing quanto o uso de CPU em varredura. Para backend, o ganho é transparente: sob pressão de memória, o kernel toma decisões melhores sobre o que manter em cache. Distros modernas o habilitam por padrão.
# Verificar se o MGLRU está habilitado
cat /sys/kernel/mm/lru_gen/enabled # 0x0007 = habilitado
Buffered I/O vs Direct I/O
- Buffered I/O (o padrão): passa pelo page cache. Ganha caching, read-ahead e write-back automáticos. É o que você quer 95% do tempo.
-
Direct I/O (
O_DIRECT): contorna o page cache, transferindo direto entre o disco e o buffer da aplicação. Faz sentido quando a aplicação gerencia seu próprio cache (bancos de dados como Oracle/PostgreSQL com grande shared_buffers) e o page cache seria só duplicação desperdiçada — ou para não "poluir" o cache com dados que só serão lidos uma vez.
Buffered: app ──▶ page cache ──▶ disco (caching, mas dupla cópia)
Direct: app ──────────────────▶ disco (zero-copy, mas sem caching)
madvise e fadvise: dando dicas ao kernel
Você pode orientar o kernel sobre seu padrão de acesso, para ele otimizar o page cache:
-
madvise(MADV_SEQUENTIAL)— "vou ler sequencialmente" → read-ahead agressivo. -
madvise(MADV_RANDOM)— "acesso aleatório" → desliga read-ahead (evita trazer páginas inúteis). -
madvise(MADV_DONTNEED)— "não preciso mais disto" → libera as páginas. -
posix_fadvise(POSIX_FADV_DONTNEED)— descarta do page cache um trecho de arquivo já processado (ótimo em ETL para não entupir o cache). -
madvise(MADV_HUGEPAGE)— pede huge pages para aquela região (lembra da Parte II).
import os
fd = os.open("arquivo_grande.dat", os.O_RDONLY)
# "Vou ler isto sequencialmente, faça read-ahead agressivo"
os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_SEQUENTIAL)
# ... processa o arquivo ...
# "Já terminei, pode descartar do page cache" (não entope a RAM)
os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
os.close(fd)
Essas dicas serão centrais nos exemplos de ETL e processamento de arquivos da Parte VIII.
O Slab Allocator
Até agora falamos da memória das aplicações. Mas o próprio kernel precisa alocar memória constantemente para suas estruturas internas: task_struct (lembra do Artigo 1?), inodes, dentries, buffers de rede (sk_buff), descritores de arquivo. Como ele faz isso?
O buddy system: a base
Na camada mais baixa, o kernel gerencia memória física em potências de 2 de páginas (1, 2, 4, 8... frames), usando o buddy system. Quando precisa de um bloco, ele encontra o menor bloco disponível que sirva; se for grande demais, o divide pela metade (os dois "buddies"). Ao liberar, blocos buddy livres são fundidos de volta.
Pedido de 1 bloco (4 KB), só há um bloco de 16 KB livre:
[ 16 KB ]
│ split
[ 8 KB ][ 8 KB ]
│ split
[4KB][4KB][ 8 KB ]
▲
entrega este; os buddies ficam livres para futuras fusões
O buddy system combate a fragmentação externa (juntando buddies), mas sofre de fragmentação interna: pedir 5 KB obriga a alocar um bloco de 8 KB, desperdiçando 3 KB. E aqui está o problema: o kernel raramente precisa de páginas inteiras — ele precisa de milhares de objetos pequenos (uma task_struct tem alguns KB; um inode, centenas de bytes). Usar o buddy system direto para isso seria um desperdício gigantesco.
# Estado do buddy system (blocos livres por ordem/potência de 2)
cat /proc/buddyinfo
Por que slab? O problema dos objetos pequenos
O slab allocator (conceito originado no Solaris, por Jeff Bonwick) resolve isso com duas ideias:
Caching de objetos por tipo: mantém "caches" dedicados para cada tipo de objeto frequente (um cache de
task_struct, um deinode, etc.). Cada cache pega páginas inteiras do buddy system (os "slabs") e as fatia em objetos daquele tamanho, sem desperdício.Reaproveitamento com estado: objetos liberados não são destruídos — voltam ao cache já parcialmente inicializados, prontos para reuso. Alocar/liberar vira quase de graça: só pegar/devolver da lista livre do slab.
Cache slab de 'task_struct':
┌──────── slab (1+ páginas do buddy) ────────┐
│ [obj][obj][obj][obj][obj][obj][obj][obj] │
│ usado livre usado livre livre usado ... │
└────────────────────────────────────────────┘
│
listas: slabs cheios / parciais / vazios
Isso dá dois ganhos enormes: elimina a fragmentação interna para objetos pequenos e melhora a localidade de cache (objetos do mesmo tipo ficam agrupados). O kmalloc do kernel é implementado sobre caches slab de tamanhos padronizados (kmalloc-8, kmalloc-16, kmalloc-32...).
# Ver todos os caches slab, uso e número de objetos
sudo cat /proc/slabinfo
# name <active_objs> <num_objs> <objsize> ...
# task_struct 142 160 9088 ...
# dentry 45230 48300 192 ...
# inode_cache 30120 31000 600 ...
# Visão amigável e ordenada por consumo
sudo slabtop
SLUB vs SLAB vs SLOB
O Linux teve três implementações da ideia:
| Implementação | Característica | Uso |
|---|---|---|
| SLAB | Original, com filas por CPU complexas. Bom em cache, mas com muito overhead de metadados. | Legado |
| SLUB | Simplificado, menos metadados, escala melhor em muitos cores, melhor para debugging. | Padrão atual |
| SLOB | Minimalista, para sistemas embarcados com pouquíssima RAM. | Removido em kernels recentes |
O SLUB é o padrão em praticamente todas as distribuições modernas — mais simples, mais escalável em máquinas com muitos núcleos, e com melhores ferramentas de diagnóstico.
Shrinkers
Quando a memória fica escassa (Parte VII), o kernel precisa recuperar memória não só do page cache, mas também dos caches slab. Os shrinkers são callbacks que cada subsistema registra para liberar objetos recuperáveis sob pressão — por exemplo, o cache de dentry/inode (o slab reclaimable) pode encolher para devolver páginas.
# Slab reclaimable (pode ser recuperado sob pressão) vs unreclaimable
grep -E 'SReclaimable|SUnreclaim' /proc/meminfo
Isso conecta diretamente com a Parte VII: sob pressão de memória, o reclaim aciona os shrinkers para encolher slabs recuperáveis, junto com o descarte de page cache.
Debugging: poisoning e leak detection
O SLUB oferece ferramentas para caçar bugs de memória do kernel: poisoning (preenche objetos livres com um padrão conhecido, tipo 0x6b, para detectar use-after-free) e detecção de vazamentos. Úteis para quem escreve drivers/módulos, mas bom saber que existem quando você investiga um SUnreclaim que só cresce (um vazamento de slab no kernel).
Continua na Parte VII, onde juntamos tudo: como o kernel realmente recupera memória sob pressão (kswapd, direct reclaim, watermarks, LRU lists), a métrica moderna de PSI (Pressure Stall Information), e o processo que todo dev backend teme e precisa entender — o OOM Killer.
:D
Conteúdo parcialmente gerado com auxílio de IA generativa (me ajudou a organizar tudo isso kkkk)
Top comments (0)