DEV Community

Alex Volnei Galante
Alex Volnei Galante

Posted on

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

[!NOTE]
Última parte do Artigo 2 (Fundamentos e Virtual Memory). Nas partes anteriores cobrimos hierarquia de memória, paginação, TLB, page faults, COW e swapping. Agora fechamos com zones e NUMA, a conexão com os runtimes de Python, .NET e Go, diagnóstico e boas práticas.


Sumário


Memory Zones

Até agora tratamos a RAM como um bloco uniforme de frames. Mas o kernel precisa lidar com restrições de hardware que tornam certas regiões da memória física especiais. Por isso ele divide a memória física em zones:

┌──────────────┬─────────────────────────────────────────────────────┐
│ ZONE_DMA     │ Memória para dispositivos antigos com endereçamento  │
│              │ limitado (ex.: primeiros 16 MB em x86)               │
├──────────────┼─────────────────────────────────────────────────────┤
│ ZONE_DMA32   │ Memória endereçável por dispositivos de 32 bits      │
│              │ (primeiros 4 GB)                                     │
├──────────────┼─────────────────────────────────────────────────────┤
│ ZONE_NORMAL  │ Memória "normal", diretamente mapeada pelo kernel.   │
│              │ Onde vive a maioria das alocações.                  │
├──────────────┼─────────────────────────────────────────────────────┤
│ ZONE_HIGHMEM │ (Relíquia do x86 32-bit; irrelevante em 64 bits)     │
└──────────────┴─────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Por que isso existe? Porque alguns dispositivos de hardware (especialmente antigos) só conseguem fazer DMA (acesso direto à memória) em faixas baixas de endereço. O kernel precisa garantir que buffers de DMA venham dessas zonas específicas. Em servidores modernos de 64 bits, a esmagadora maioria das alocações vem de ZONE_NORMAL, e você raramente pensa nas outras — mas elas aparecem quando você lê /proc/zoneinfo diagnosticando pressão de memória.

# Ver as zonas e suas watermarks (min/low/high — importantes no Artigo 3)
cat /proc/zoneinfo | grep -E 'Node|zone|free|min|low|high' | head -20
Enter fullscreen mode Exit fullscreen mode

Cada zona tem suas próprias watermarks (min, low, high) que governam quando o kernel começa a recuperar memória — um mecanismo central que detalharemos no Artigo 3 (memory reclaim).


NUMA: quando a memória tem geografia

Em servidores com múltiplos sockets de CPU, a memória deixa de ser uniforme numa dimensão nova: a distância. Isso é o NUMA (Non-Uniform Memory Access).

Num sistema NUMA, cada socket de CPU (um nó NUMA) tem sua própria memória local, fisicamente próxima. Acessar a memória local é rápido; acessar a memória de outro nó (remota) passa pela interconexão entre sockets e é mais lento.

        Nó 0                             Nó 1
┌───────────────────┐                ┌───────────────────┐
│   CPUs 0-15       │                │   CPUs 16-31      │
│        │          │                │        │          │
│   ┌────┴────┐     │◀──────────────▶│   ┌────┴────┐     │
│   │ RAM     │     │  interconexão  │   │  RAM    │     │
│   │ local   │     │  (mais lenta)  │   │  local  │     │
│   └─────────┘     │                │   └─────────┘     │
└───────────────────┘                └───────────────────┘

 Acesso local:  ~100 ns          Acesso remoto: ~150-200 ns
Enter fullscreen mode Exit fullscreen mode

A diferença de latência entre acesso local e remoto pode ser de 1.5x a 2x. Para aplicações sensíveis a latência, isso importa muito.

O kernel expõe a topologia e tenta, por padrão, alocar memória no mesmo nó onde a thread está rodando (política first-touch: a página vai para o nó que a tocou primeiro, não o que a alocou).

# Ver a topologia NUMA da máquina
numactl --hardware
# available: 2 nodes (0-1)
# node 0 cpus: 0 1 2 ... 15
# node 0 size: 64000 MB
# node 1 cpus: 16 17 ... 31
# node distances:
# node   0   1
#   0:  10  21     ← distância relativa (10 = local, 21 = ~2x mais lento)
#   1:  21  10

# Estatísticas NUMA por nó (hits locais vs faltas remotas)
numastat
Enter fullscreen mode Exit fullscreen mode

A distance matrix (node distances) quantifica o custo relativo: 10 é o baseline local, e valores maiores indicam nós mais "distantes".

Automatic NUMA Balancing

Desde o Kernel 3.8, o Linux tem balanceamento automático de NUMA: ele monitora quais nós acessam quais páginas e migra páginas e threads para melhorar a localidade, dinamicamente. Isso usa uma técnica engenhosa: periodicamente desmapeia páginas (marcando PTEs como não-presentes) para forçar page faults "de amostragem" (NUMA hinting faults) que revelam o padrão de acesso.

# Verificar se está ativo (1 = ativo)
cat /proc/sys/kernel/numa_balancing
Enter fullscreen mode Exit fullscreen mode

Para muitas aplicações isso é ótimo e transparente. Mas para workloads de altíssima performance e latência previsível (bancos de dados, trading, caches grandes), o balanceamento automático pode introduzir jitter — nesses casos, faz-se pinning explícito (fixar processo e memória a um nó via numactl), que veremos nas boas práticas.


Conexão com Backend: Python (CPython)

Agora conectamos tudo com o que roda de fato em produção. Começando pelo CPython.

O modelo de memória do CPython

O CPython tem seu próprio alocador em camadas sobre o malloc do sistema:

Objetos Python
      │
      ▼
┌──────────────┐  pymalloc: alocador especializado para objetos pequenos (< 512 bytes)
│  pymalloc    │  organiza memória em "arenas" (256 KB), "pools" (4 KB = 1 página!)
└──────┬───────┘  e "blocks". Reduz overhead de malloc para os milhões de objetinhos.
       │ (objetos grandes)
       ▼
┌──────────────┐
│  malloc/mmap │  → brk/mmap → kernel → page faults → frames físicos
└──────────────┘
Enter fullscreen mode Exit fullscreen mode

Repare que um pool do pymalloc tem exatamente 4 KB — o tamanho de uma página. Não é coincidência: alinhar com a página evita desperdício e melhora a localidade.

Reference counting e page faults

Como vimos na Parte III, o refcount do CPython é uma escrita a cada toque num objeto. Isso tem duas consequências de memória:

  1. Quebra de COW em workers pre-forked (discutido na Parte III) — o grande vilão do inchaço de memória em Gunicorn/uWSGI.
  2. Dirty pages constantes: mesmo objetos "só de leitura" ficam sujos (dirty bit ligado), o que afeta reclaim e swap.

Fragmentação em aplicações long-running

Um problema clássico do CPython: memória liberada nem sempre volta ao SO. O pymalloc segura arenas, e a fragmentação da heap pode fazer o RSS de um worker só crescer, nunca diminuir, mesmo após picos de carga terminarem. É por isso que muitas equipes configuram Gunicorn com max_requests (recicla o worker após N requisições, devolvendo memória ao SO).

Diagnóstico prático

import tracemalloc

tracemalloc.start()
# ... executa a carga de trabalho ...
snapshot = tracemalloc.take_snapshot()
top = snapshot.statistics('lineno')
for stat in top[:10]:
    print(stat)   # mostra as linhas que mais alocam memória
Enter fullscreen mode Exit fullscreen mode
# Correlacionar com o kernel: page faults do processo Python
perf stat -e minor-faults,major-faults -p <PID_python>
Enter fullscreen mode Exit fullscreen mode

Mitigando o inchaço por COW

import gc

# ANTES do fork (no master), congela os objetos já criados na geração
# permanente, evitando que o GC toque seus headers e quebre o COW.
gc.freeze()
# ... então o Gunicorn/uWSGI faz fork dos workers ...
Enter fullscreen mode Exit fullscreen mode

Estratégia complementar: manter dados grandes e imutáveis fora do heap gerenciado do Python — em arrays NumPy (buffer contíguo, sem refcount por elemento), em memória mapeada (mmap), ou num serviço externo (Redis). Assim os workers realmente compartilham, sem cópia.


Conexão com Backend: .NET (CLR)

O CLR (CoreCLR) no Linux tem um modelo de memória sofisticado, dominado pelo Garbage Collector.

Managed heap vs unmanaged memory

┌─────────────────────────────────────────────┐
│                Managed Heap                 │
│  ┌──────────────┐  ┌──────────────────────┐ │
│  │ SOH          │  │ LOH (Large Object    │ │
│  │ (Gen0/1/2)   │  │  Heap, ≥ 85 KB)      │ │
│  └──────────────┘  └──────────────────────┘ │
└─────────────────────────────────────────────┘
        │ o GC gerencia; sob o capô é tudo
        ▼ mmap/VirtualAlloc → páginas do kernel
Enter fullscreen mode Exit fullscreen mode
  • O Small Object Heap (SOH) é geracional (Gen0, Gen1, Gen2), apostando que a maioria dos objetos morre jovem.
  • O Large Object Heap (LOH) guarda objetos ≥ 85 KB. Ele não é compactado por padrão (compactar objetos grandes é caro), então sofre de fragmentação externa — exatamente o conceito da Parte I reaparecendo. Buracos no LOH podem inflar o RSS.

GC e page faults

Um ciclo de GC "toca" muita memória (varrendo objetos, movendo-os na compactação do SOH). Isso pode disparar rajadas de page faults — inclusive major faults se parte da heap tiver ido para o swap. Um GC que precisa "puxar" a heap de volta do swap é uma fonte clássica de pausas longas em produção.

Workstation GC vs Server GC no Linux

  • Workstation GC: uma thread de GC, menor footprint. Bom para apps pequenas/desktop.
  • Server GC: uma heap e uma thread de GC por core, muito mais throughput — mas consome mais memória. Em containers, o Server GC pode alocar heaps por CPU do host, não do container, causando surpresas de consumo.
// runtimeconfig.json — atenção crítica em containers
{
  "configProperties": {
    "System.GC.Server": true,
    // Faz o CLR respeitar os limites de cgroup do container:
    "System.GC.HeapHardLimitPercent": 75
  }
}
Enter fullscreen mode Exit fullscreen mode

[!IMPORTANT]
Em Kubernetes/containers, garanta que o CLR "enxerga" o limite de memória do cgroup. Versões modernas do .NET detectam cgroups automaticamente, mas vale verificar — senão o Server GC dimensiona a heap pela RAM do host e você toma OOM Kill (Artigo 3).

Diagnóstico

# Contadores de GC e memória ao vivo
dotnet-counters monitor -p <PID> --counters System.Runtime

# Coletar um dump para analisar a heap
dotnet-dump collect -p <PID>
dotnet-dump analyze core_dump
# > dumpheap -stat        (objetos por tipo/tamanho)
# > gcheapstat            (fragmentação por geração/LOH)
Enter fullscreen mode Exit fullscreen mode

Conexão com Backend: Go

O runtime do Go tem um alocador inspirado no TCMalloc e um GC concorrente, com decisões que impactam diretamente o padrão de páginas.

Stack vs heap e escape analysis

O compilador Go decide, em tempo de compilação, se um valor vive na stack (barato, liberado ao retornar da função) ou "escapa" para a heap (gerenciado pelo GC). Isso é a escape analysis:

// NÃO escapa: 'x' vive na stack, zero pressão sobre o GC/heap.
func soma(a, b int) int {
    x := a + b
    return x
}

// ESCAPA: o ponteiro sobrevive à função → vai para a heap → pressão de GC.
func criaUsuario(nome string) *Usuario {
    u := Usuario{Nome: nome}
    return &u   // &u escapa
}
Enter fullscreen mode Exit fullscreen mode
# Ver as decisões de escape analysis do compilador
go build -gcflags='-m' ./...
# ./main.go:12:9: &u escapes to heap
Enter fullscreen mode Exit fullscreen mode

Menos alocações na heap = menos trabalho do GC = menos páginas sujas = menos pressão de memória no kernel. Otimizar escape analysis é otimizar a interação com o subsistema de memória do kernel.

O alocador e o kernel

O Go pede memória ao kernel em grandes blocos via mmap e os subdivide internamente em size classes (semelhante ao pymalloc e ao slab do kernel). O runtime tem seu próprio conceito de páginas (8 KB, dois frames de 4 KB) organizadas em spans.

Diagnóstico com pprof e GODEBUG

import _ "net/http/pprof"   // expõe profiles em /debug/pprof/
Enter fullscreen mode Exit fullscreen mode
# Profile de heap: quem está alocando
go tool pprof http://localhost:6060/debug/pprof/heap

# Rastreio de cada ciclo de GC (pausa, heap, etc.)
GODEBUG=gctrace=1 ./minha-app
# gc 1 @0.012s 2%: 0.018+1.2+0.003 ms clock, ... 4->5->2 MB, 5 MB goal

# Correlacionar com page faults do kernel
perf stat -e minor-faults,major-faults ./minha-app
Enter fullscreen mode Exit fullscreen mode

O GOGC (padrão 100) controla o trade-off: valores maiores = GC menos frequente = mais RAM usada, menos CPU em GC. O GOMEMLIMIT (Go 1.19+) define um teto suave de memória — essencial em containers para evitar OOM Kill, funcionando como um "freio" antes de o cgroup estourar.


Técnicas de Diagnóstico

Um resumo do arsenal para investigar memória em produção, conectando com tudo que vimos:

# 1. Visão geral do processo: virtual vs residente
pmap -x <PID>                    # mapa detalhado com RSS por região
ps -o pid,vsz,rss,cmd -p <PID>   # VSZ (virtual) vs RSS (físico)

# 2. Detalhamento por VMA: dirty pages, PSS (proporcional), swap
cat /proc/<PID>/smaps            # rico, porém verboso
grep -E 'Rss|Pss|Swap' /proc/<PID>/smaps_rollup   # resumo agregado

# 3. Page faults (minor = RAM, major = disco!)
perf stat -e minor-faults,major-faults -p <PID>

# 4. Pressão de memória do sistema
vmstat 1                         # colunas si/so (swap in/out) → alerta!
free -h

# 5. Transparent Huge Pages em uso
grep -i AnonHugePages /proc/<PID>/smaps_rollup

# 6. NUMA: acessos locais vs remotos
numastat -p <PID>
Enter fullscreen mode Exit fullscreen mode

PSS (Proportional Set Size) merece destaque: quando várias instâncias compartilham páginas (código, COW), o RSS conta a página inteira para cada processo, superestimando o total. O PSS divide páginas compartilhadas proporcionalmente, dando uma medida mais honesta de "quanto este processo realmente custa" de memória.


Boas Práticas

  1. Dimensione heaps explicitamente em containers. Configure GOMEMLIMIT (Go), HeapHardLimitPercent (.NET) e limites de worker (Python/Gunicorn max_requests). Nunca confie que o runtime vai adivinhar o limite do cgroup — verifique.

  2. Use huge pages para databases e caches grandes, mas com critério. Habilite HugeTLB explícito para o banco; desabilite THP (madvise ou never) se sua stack (Redis, Mongo, PostgreSQL) recomenda, para evitar jitter de latência.

  3. NUMA pinning para aplicações latency-sensitive. Fixe processo e memória ao mesmo nó:

   numactl --cpunodebind=0 --membind=0 ./minha-app-critica
Enter fullscreen mode Exit fullscreen mode
  1. Preserve o COW em workers Python pre-forked: gc.freeze() antes do fork, dados grandes fora do heap gerenciado.

  2. Configure swap conscientemente em containers. Em SSD/NVMe, um swap moderado pode ser um amortecedor útil (kernels 5.14+ o tornaram eficiente), mas ajuste vm.swappiness conforme o workload (aprofundamos no Artigo 3).


Estudos de Caso

1. Python — Otimizando um cliente Redis com huge pages

Uma aplicação de cache intensivo com grandes estruturas em memória sofria com TLB misses (working set de vários GB, muitas páginas de 4 KB). Habilitar huge pages para a região de dados reduziu a pressão sobre a TLB, cortando a latência de cauda (p99) de acessos ao cache. Diagnóstico feito com perf stat -e dTLB-load-misses.

2. .NET — Tuning de GC para uma API de alta vazão

Uma API ASP.NET Core em Kubernetes tomava OOM Kill esporádico. Investigação com dotnet-counters mostrou o Server GC dimensionando a heap pela RAM do host (não do container). Correção: HeapHardLimitPercent no runtimeconfig + verificação da detecção de cgroup. RSS estabilizou abaixo do limite e o OOM sumiu.

3. Go — Reduzindo allocation pressure em um stream processor

Um processador de streams tinha ciclos de GC frequentes (visíveis em GODEBUG=gctrace=1) causando picos de CPU. go build -gcflags='-m' revelou allocations desnecessárias na heap (buffers escapando em hot path). Reuso via sync.Pool e ajuste de GOGC reduziram a frequência de GC e a quantidade de páginas sujas por segundo.


Continua na Parte V
: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)