DEV Community

Cover image for Kernel Linux para Desenvolvedores Backend - Gerenciamento de Memória Parte I
Alex Volnei Galante
Alex Volnei Galante

Posted on Edited on

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

Se você acompanhou a série sobre Processos & Threads, já entende como o kernel do Linux cria a ilusão de que cada processo tem uma CPU só para si. Agora vamos atacar a segunda grande ilusão que o kernel constrói: a de que cada processo tem uma memória infinita e exclusiva só para ele.

Assim como fizemos com processos, o objetivo aqui não é decorar estruturas do kernel, mas entender como o código que escrevemos em produção — seja em Python, .NET ou Go — interage com o subsistema de gerenciamento de memória, e como esse entendimento nos torna melhores em diagnosticar problemas de performance, consumo de RAM e latência.


Motivação

Toda aplicação backend, sem exceção, é limitada por dois recursos fundamentais: CPU e memória. Já cobrimos CPU na série anterior. Agora vamos falar de memória — o recurso que, quando mal compreendido, causa os problemas mais silenciosos e traiçoeiros em produção: memory leaks que derrubam containers no meio da madrugada, o temido OOM Killer matando seu worker sem explicação aparente, latência de cauda (tail latency) inexplicável causada por page faults, e consumo de RAM que "cresce sozinho" em aplicações long-running.

A verdade é que a maior parte dos desenvolvedores backend trata memória como uma caixa-preta: "aloco um objeto e o garbage collector cuida do resto". Mas essa abstração vaza. "E quando vaza — geralmente às 3h da manhã, com o pager tocando — quem entende o que acontece por baixo tem uma vantagem enorme".

Meu objetivo com este artigo é destrinchar como a memória virtual funciona no kernel Linux, partindo dos fundamentos teóricos (aqueles que aprendemos, ou deveríamos ter aprendido, na disciplina de Sistemas Operacionais) e conectando cada conceito com o que realmente acontece quando sua API responde a uma requisição.

Como sempre: estou escrevendo para consolidar meu próprio entendimento, mas se você tem interesse em ir além do CRUD, fique comigo.


Sumário da Parte I

  1. Por que memória virtual existe?
  2. A Hierarquia de Memória
  3. Gerenciamento de Memória sem Abstração
  4. A Abstração do Espaço de Endereçamento
  5. Memória Virtual: os Fundamentos
  6. A MMU e a Tradução de Endereços
  7. Referências Bibliográficas

Por que memória virtual existe?

Antes de mergulhar em tabelas de páginas e MMUs, precisamos entender qual problema a memória virtual resolve. E, como quase tudo em sistemas operacionais, a resposta está na história.

Imagine um mundo sem memória virtual — que, aliás, foi o mundo real dos computadores até os anos 60. Nesse mundo, quando seu programa acessa o endereço 0x1000, ele acessa fisicamente a posição 0x1000 do chip de RAM. Simples, direto e cheio de problemas:

  • Sem proteção: qualquer processo pode ler e escrever na memória de qualquer outro processo. Um bug em um programa pode corromper outro. Um programa malicioso pode ler suas senhas.
  • Sem isolamento: dois programas compilados para rodar no endereço 0x1000 não podem coexistir.
  • Limitação física rígida: você não pode executar um programa que precise de mais memória do que a RAM instalada.
  • Fragmentação: conforme processos entram e saem, a memória vira um queijo suíço de buracos.

A memória virtual resolve todos esses problemas de uma vez, introduzindo uma camada de indireção entre os endereços que o programa usa (virtuais) e os endereços físicos da RAM:

"Todos os problemas em ciência da computação podem ser resolvidos com mais um nível de indireção." David Wheeler


A Hierarquia de Memória

Para entender por que o kernel se dá ao trabalho de gerenciar memória de forma tão elaborada, é preciso entender uma verdade desconfortável do hardware: memória rápida é cara e pequena; memória barata é grande e lenta. Não existe (ainda) uma tecnologia que seja simultaneamente rápida, barata e abundante.
não me diga que chip de DDR5 que voce comprou no Kabun é rápido :D

O resultado é uma hierarquia, onde cada nível troca velocidade por capacidade:

Hirarquia de Memoria

Repare na diferença brutal de escala: um acesso à RAM (~100 ns) é cerca de 300 vezes mais lento que um acesso a registrador. E um acesso a disco (HDD, ~10 ms) é 100.000 vezes mais lento que a RAM. Para colocar em perspectiva humana: se um acesso a registrador fosse 1 segundo, um acesso a disco seria equivalente a mais de um dia.

O Princípio da Localidade

O que torna essa hierarquia viável — em vez de um desastre de performance — é o princípio da localidade, uma observação empírica sobre como programas reais acessam memória:

  • Localidade temporal: se você acessou um endereço agora, provavelmente vai acessá-lo de novo em breve. Pense em uma variável de contador dentro de um loop, ou um objeto de configuração lido a cada requisição.
  • Localidade espacial: se você acessou um endereço, provavelmente vai acessar endereços vizinhos em breve. Pense em iterar sobre um array, ou percorrer os campos de uma struct.

É por causa da localidade que caches funcionam. Quando você acessa um byte da RAM, o hardware não traz apenas aquele byte — traz uma linha de cache inteira (tipicamente 64 bytes) para os níveis mais rápidos, apostando que você vai precisar dos vizinhos.

Por que isso importa para backend? Estruturas de dados que respeitam a localidade são dramaticamente mais rápidas. Um array contíguo (list do Python com tipos primitivos via NumPy, []T em Go, Array<T> em .NET) é percorrido muito mais rápido que uma lista encadeada espalhada pela heap, mesmo que ambos tenham complexidade $O(n)$. A constante escondida na notação Big-O é, muitas vezes, a hierarquia de memória se manifestando.

# Estes dois loops têm a MESMA complexidade O(n),
# mas performance radicalmente diferente por causa da localidade espacial.

import numpy as np

# Array contíguo: localidade espacial ótima, cache-friendly
dados_contiguos = np.arange(10_000_000)
soma = dados_contiguos.sum()   # o hardware faz prefetch das linhas de cache

# Objetos Python espalhados pela heap: cada acesso pode ser um cache miss
dados_espalhados = list(range(10_000_000))   # cada int é um objeto no heap
soma = sum(dados_espalhados)   # ~10-50x mais lento, entre outros motivos, por cache misses
Enter fullscreen mode Exit fullscreen mode

Gerenciamento de Memória sem Abstração

Para valorizar a memória virtual, vale a pena entender como se vivia sem ela. Tanenbaum organiza essa evolução em etapas, e cada uma resolve uma limitação da anterior.

Monoprogramação

O modelo mais simples possível: um processo por vez. A memória é dividida entre o sistema operacional e o único programa do usuário. Quando você quer rodar outro programa, o atual precisa terminar.

┌──────────────────────┐  0xFFFF
│  Sistema Operacional │
├──────────────────────┤
│                      │
│  Programa do Usuário │
│                      │
└──────────────────────┘  0x0000
Enter fullscreen mode Exit fullscreen mode

Isso é essencialmente o MS-DOS. Funciona, mas desperdiça CPU: enquanto o único processo espera I/O (disco, rede), a CPU fica ociosa. Para um servidor backend moderno, que passa boa parte do tempo esperando o banco de dados responder, isso seria catastrófico.

Multiprogramação com Partições Fixas

Para manter a CPU ocupada, precisamos de vários processos na memória ao mesmo tempo — quando um bloqueia em I/O, outro roda. A primeira tentativa foi dividir a memória em partições fixas:

┌──────────────────────┐
│  Sistema Operacional │
├──────────────────────┤
│  Partição 1 (8 MB)   │ ← Processo A
├──────────────────────┤
│  Partição 2 (4 MB)   │ ← Processo B
├──────────────────────┤
│  Partição 3 (4 MB)   │ ← livre
└──────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Aqui surge o primeiro fantasma que assombra o gerenciamento de memória até hoje: a fragmentação.

Fragmentação Interna vs Externa

Estes dois conceitos vão reaparecer o artigo inteiro, então vale fixá-los bem:

  • Fragmentação interna: acontece quando alocamos mais memória do que o necessário e o excesso, dentro do bloco alocado, é desperdiçado. Se um processo de 3 MB é colocado numa partição de 4 MB, aquele 1 MB restante fica preso e inutilizável — está "dentro" da partição, mas ninguém pode usá-lo.

  • Fragmentação externa: acontece quando há memória livre suficiente no total, mas ela está espalhada em pequenos buracos não-contíguos, nenhum grande o bastante para atender uma requisição. Você tem 10 MB livres, mas em pedaços de 1 MB, e chega um processo que precisa de 5 MB contíguos — não cabe, mesmo havendo espaço.

Fragmentação Externa:

┌────┬──────┬────┬──────┬────┐
│ P1 │ LIVRE│ P2 │ LIVRE│ P3 │
└────┴──────┴────┴──────┴────┘
        3MB          3MB

Total livre: 6 MB, mas o maior bloco contíguo é 3 MB.
Um processo de 5 MB não cabe, mesmo havendo 6 MB livres!
Enter fullscreen mode Exit fullscreen mode

Esses conceitos não são história antiga — eles reaparecem no buddy allocator do kernel, na fragmentação da heap do seu processo, na fragmentação do Large Object Heap do .NET e na pressão de alocação do Go. Guarde-os.

Compactação de Memória

Uma solução para fragmentação externa é a compactação: mover todos os processos para um lado da memória, juntando os buracos livres em um único bloco grande.

Antes:  [P1][ livre ][P2][ livre ][P3]
Depois: [P1][P2][P3][      livre       ]
Enter fullscreen mode Exit fullscreen mode

O problema? Compactação é caríssima. Mover gigabytes de RAM leva tempo e, durante a operação, o sistema praticamente para. Além disso, esbarramos num problema fundamental: se movemos um processo de lugar, todos os endereços que ele usa internamente mudam. Como o programa continua funcionando se o endereço 0x1000 agora é fisicamente 0x5000? Isso nos leva à necessidade de abstração.


A Abstração do Espaço de Endereçamento

O problema central de todos os modelos acima é que os programas usam endereços físicos diretamente. A solução é introduzir uma abstração: o espaço de endereçamento.

Um espaço de endereçamento é o conjunto de endereços que um processo pode usar para endereçar memória. A ideia genial é: cada processo tem seu próprio espaço de endereçamento, isolado dos demais, e os endereços que ele usa (virtuais) são traduzidos para endereços físicos por baixo dos panos.

Base e Limite: a proteção primitiva

A primeira implementação dessa ideia usava dois registradores de hardware: base e limite.

  • Quando um processo é carregado, o registrador base recebe o endereço físico onde ele começa.
  • Todo endereço que o programa gera é somado à base automaticamente pelo hardware.
  • O registrador limite guarda o tamanho do processo; qualquer acesso além dele gera uma falha (proteção).
Endereço virtual gerado pelo programa:  0x1000
Registrador base:                     + 0x5000
                                       ────────
Endereço físico real:                   0x6000   (se < limite, OK)
Enter fullscreen mode Exit fullscreen mode

Isso resolve dois problemas de uma vez: relocação (o programa pode ser carregado em qualquer lugar, basta ajustar a base) e proteção (o limite impede que um processo acesse memória fora da sua região).

Relocação: estática vs dinâmica

  • Relocação estática: os endereços são ajustados uma vez, no momento da carga do programa. Simples, mas o processo não pode ser movido depois.
  • Relocação dinâmica: a tradução acontece a cada acesso, em tempo de execução, via hardware (base + limite, ou a MMU). É o que permite mover, fazer swap e compartilhar memória de forma flexível. É a abordagem dos sistemas modernos.

Swapping

Base e limite ainda esbarram num limite físico: e se a soma da memória de todos os processos exceder a RAM? A resposta clássica foi o swapping: mover processos inteiros da RAM para o disco quando não estão rodando, e trazê-los de volta quando necessário.

RAM:   [SO][ Processo A ][ Processo B ]
                              │  swap out
                              ▼
Disco: [ ............ Processo C ............ ]
                              │  swap in
                              ▼
RAM:   [SO][ Processo A ][ Processo C ]
Enter fullscreen mode Exit fullscreen mode

O problema do swapping clássico é sua granularidade grosseira: mover um processo inteiro para o disco é lento e desperdiça banda se o processo só precisava de uma fração da sua memória. É aqui que a paginação e a memória virtual entram para revolucionar tudo — mas isso é o assunto central da próxima parte.


Memória Virtual: os Fundamentos

Chegamos ao conceito central. A memória virtual parte de uma constatação: raramente um programa precisa de toda a sua memória ao mesmo tempo.

A motivação: executar o impossível

A ideia original (Fotheringham, 1961) era permitir executar programas maiores que a memória física disponível. Em vez de exigir que o programa inteiro esteja na RAM, mantemos na memória apenas as partes que estão sendo usadas agora, deixando o resto no disco. Quando o programa acessa uma parte que não está na RAM, o kernel a traz do disco sob demanda.

O Working Set

Isso funciona por causa — de novo — do princípio da localidade. Peter Denning formalizou essa intuição no conceito de working set: o conjunto de páginas que um processo está ativamente usando em um dado intervalo de tempo. O working set costuma ser bem menor que o total de memória alocada pelo processo.

Exemplo backend concreto: uma API .NET pode ter 500 MB de assemblies, bibliotecas e código carregados, mas em um dado segundo, respondendo a requisições, ela toca apenas talvez 50 MB — o hot path do código, os objetos das requisições ativas, as conexões do pool. Esse é o working set. O resto pode ficar tranquilamente no disco (ou no page cache) até ser necessário.

Espaço de endereçamento virtual

Com memória virtual, cada processo enxerga um espaço de endereçamento contíguo e enorme — em sistemas de 64 bits, teoricamente $2^{64}$ bytes, na prática 48 ou 57 bits de endereçamento (falaremos disso na próxima parte). Esse espaço é independente do hardware físico: o processo não sabe (nem precisa saber) quanta RAM a máquina realmente tem, nem onde fisicamente suas páginas estão.

Endereçamento de Memoria

Repare que endereços virtuais contíguos podem mapear para frames físicos espalhados — e que dois processos podem ter o mesmo endereço virtual apontando para frames físicos diferentes. Essa é exatamente a mágica do isolamento.


A MMU e a Tradução de Endereços

Se a cada acesso à memória é preciso traduzir um endereço virtual para físico, essa tradução precisa ser rápida — rápida a ponto de acontecer em hardware, não em software. Quem faz isso é a MMU (Memory Management Unit), um componente do processador.

MMU e TLB

O fluxo, simplificado:

  1. O núcleo da CPU gera um endereço virtual (por exemplo, ao executar mov rax, [0x00401000]).
  2. A MMU intercepta esse endereço antes que ele chegue ao barramento de memória.
  3. A MMU consulta a tabela de páginas (mantida pelo kernel) para descobrir qual frame físico corresponde àquela página virtual.
  4. Se a tradução existe e é válida, a MMU emite o endereço físico correspondente para a RAM.
  5. Se a página não está mapeada na memória física, a MMU dispara um page fault — uma interrupção que transfere o controle ao kernel para resolver a situação (buscar a página no disco, alocar uma nova página, ou matar o processo se o acesso for inválido).

Esse mecanismo é o coração de tudo o que veremos adiante. A tabela de páginas, o formato das suas entradas, como ela é organizada em múltiplos níveis, como a TLB acelera essa tradução, e o que exatamente acontece durante um page fault — tudo isso é o assunto das próximas partes.

Por ora, o essencial é ter internalizado a grande sacada: existe uma camada de tradução, feita em hardware, entre os endereços que seu programa usa e a memória física real. Toda a gestão de memória do Linux — proteção, isolamento, swap, copy-on-write, memory-mapped files, compartilhamento de bibliotecas — é construída sobre essa fundação.


Continua na Parte II, onde vamos abrir a tabela de páginas, entender a hierarquia de 4 e 5 níveis do Linux (PGD, PUD, PMD, PTE), o papel da TLB na performance e como a segmentação se encaixa nessa história.
: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)