DEV Community

Alex Volnei Galante
Alex Volnei Galante

Posted on

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

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

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

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

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

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

[!WARNING]
Em servidores com muita RAM, dirty_ratio alto 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-se dirty_ratio/dirty_background_ratio para 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
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

Por que slab? O problema dos objetos pequenos

O slab allocator (conceito originado no Solaris, por Jeff Bonwick) resolve isso com duas ideias:

  1. Caching de objetos por tipo: mantém "caches" dedicados para cada tipo de objeto frequente (um cache de task_struct, um de inode, etc.). Cada cache pega páginas inteiras do buddy system (os "slabs") e as fatia em objetos daquele tamanho, sem desperdício.

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

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

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

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)

Referências Bibliográficas

Top comments (0)