DEV Community

Alex Volnei Galante
Alex Volnei Galante

Posted on

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

Chegamos à pergunta que os artigos passados deixaram no ar. Vimos que o kernel pode mover páginas "frias" para o swap quando a memória fica escassa. Mas como ele decide quais páginas são frias? Qual página remover quando você precisa de um frame livre e todos estão ocupados?

Essa é uma das perguntas mais estudadas em Sistemas Operacionais, e a resposta certa faz a diferença entre uma aplicação que voa e uma que passa a vida esperando o disco :D.

Vamos dos algoritmos teóricos (Tanenbaum) até o que o Linux realmente implementa, DISCLAIMER: Mano isso é muito nerdice mais eu adoro kkk


Sumário


O problema da substituição de páginas

O cenário é o seguinte: a memória física está cheia, e ocorre um page fault que exige trazer uma nova página do disco. Não há frames livres. O kernel precisa escolher uma página vítima para despejar (evict), liberando um frame.

Se a página vítima estiver suja (dirty bit = 1, lembra da Parte II?), ela precisa ser gravada no disco antes de descartada. Se estiver limpa, pode ser simplesmente descartada (o conteúdo pode ser relido da origem). A escolha da vítima é crucial:

  • Se removermos uma página que será acessada logo em seguida, causamos um novo page fault imediato — desperdício.
  • Se removermos uma página que não será mais usada, acertamos em cheio.

O objetivo de todo algoritmo de substituição é, portanto, prever o futuro: remover a página que ficará mais tempo sem ser acessada. Como não temos bola de cristal, os algoritmos são aproximações dessa previsão.


Algoritmo Ótimo (OPT)

O algoritmo ótimo é simples de enunciar: remova a página que só será usada mais longe no futuro (ou nunca mais). Ele é comprovadamente ótimo — gera o menor número possível de page faults.

O problema? É impossível de implementar. Exigiria saber, no momento da decisão, todos os acessos futuros do programa. Ele só pode ser calculado a posteriori, rodando o programa uma vez, registrando os acessos, e então analisando.

Então por que estudá-lo? Porque ele é o baseline teórico. Ao avaliar um algoritmo real, comparamos seu número de faults com o do OPT: quanto mais próximo, melhor o algoritmo.

Referências: 7 0 1 2 0 3 0 4 2 3 0 3 2
3 frames. Quando precisa despejar, OPT olha o FUTURO:

Acesso ao '3' com frames [7,0,1] cheios:
  - 7 será usado? Não aparece mais → despeja o 7. ✔
Enter fullscreen mode Exit fullscreen mode

NRU: Not Recently Used

O NRU é a primeira aproximação prática, e usa dois bits que já conhecemos da PTE (Parte II):

  • R (Referenced/Accessed): ligado pelo hardware quando a página é lida ou escrita.
  • M (Modified/Dirty): ligado pelo hardware quando a página é escrita.

Periodicamente (a cada tick de clock), o kernel zera o bit R de todas as páginas. Assim, R indica se a página foi acessada recentemente. Combinando R e M, formam-se 4 classes:

Classe 0: não referenciada, não modificada  (R=0, M=0) ← melhor vítima
Classe 1: não referenciada, modificada      (R=0, M=1)
Classe 2: referenciada, não modificada      (R=1, M=0)
Classe 3: referenciada, modificada          (R=1, M=1) ← pior vítima
Enter fullscreen mode Exit fullscreen mode

O NRU remove uma página aleatória da classe não-vazia mais baixa. A intuição: prefira despejar páginas não usadas recentemente (R=0) e, entre elas, as limpas (M=0, que não precisam de write-back). Simples e razoável, ainda que grosseiro.


FIFO e a Anomalia de Belady

O FIFO (First-In, First-Out) é o mais ingênuo: mantém uma fila das páginas na ordem em que entraram e despeja a mais antiga.

O problema é óbvio: a página mais antiga pode ser justamente a mais usada (pense numa variável de configuração carregada no início e acessada o tempo todo). O FIFO a despejaria cegamente.

Mas o FIFO tem um defeito ainda mais bizarro e contraintuitivo: a Anomalia de Belady. Normalmente esperamos que mais frames de memória → menos page faults. Com o FIFO, em certas sequências de acesso, adicionar frames pode aumentar o número de page faults:

Sequência: 1 2 3 4 1 2 5 1 2 3 4 5

Com 3 frames: 9 page faults
Com 4 frames: 10 page faults  ← MAIS frames, MAIS faults! (anomalia)
Enter fullscreen mode Exit fullscreen mode

Essa anomalia é o que motivou o estudo dos stack algorithms (algoritmos de pilha), como o LRU, que possuem a propriedade de inclusão: o conjunto de páginas com $n$ frames é sempre um subconjunto do conjunto com $n+1$ frames. Algoritmos de pilha nunca sofrem da Anomalia de Belady.


Segunda Chance e o Algoritmo do Relógio

O Segunda Chance é um FIFO esperto. Antes de despejar a página mais antiga, ele checa o bit R:

  • Se R = 0: a página não foi usada recentemente → despeja.
  • Se R = 1: dá uma "segunda chance" — zera o R, move a página para o fim da fila (como se tivesse acabado de chegar) e continua procurando.

Assim, páginas frequentemente usadas sobrevivem, enquanto páginas realmente antigas e não usadas são despejadas.

O Algoritmo do Relógio (Clock) é a implementação eficiente do Segunda Chance. Em vez de mover páginas numa fila (caro), organiza os frames num anel com um ponteiro (o "ponteiro do relógio"):

              ┌──────┐
         ┌───▶│ P0 R1│───┐
         │    └──────┘   │
    ┌──────┐         ┌──────┐
    │ P7 R0│         │ P1 R1│
    └──────┘         └──────┘
         ▲               │
         │    ┌──────┐   ▼
         └────│ P... │◀──┘
              └──────┘
                 ▲
              ponteiro

Ao precisar de vítima, o ponteiro avança:
  - R=1 → zera R e avança (segunda chance)
  - R=0 → despeja esta página, ponteiro para no próximo
Enter fullscreen mode Exit fullscreen mode

O Clock é a base conceitual do que o Linux realmente usa (com refinamentos que veremos na Parte VII, nas LRU lists).


LRU: Least Recently Used

O LRU (Least Recently Used) parte de uma aposta poderosa: o passado recente é um bom preditor do futuro próximo. Ele despeja a página que há mais tempo não é acessada.

O LRU é excelente — muito próximo do ótimo na prática — mas caro de implementar exatamente. Manter a ordem exata de último acesso exigiria atualizar uma estrutura de dados a cada acesso à memória. Tanenbaum descreve as opções:

  • Com timestamps/contador: cada página guarda o "tempo" do último acesso. Despejar = achar o menor timestamp. Requer hardware que escreva o contador a cada acesso — caro.
  • Com matriz de bits: uma matriz $n \times n$ para $n$ frames, manipulada a cada referência. Inviável para muitos frames.

Por isso, sistemas reais usam LRU aproximado:

Aging (envelhecimento)

O algoritmo de aging aproxima o LRU com um contador por página. A cada tick, o kernel desloca o contador à direita e insere o bit R no topo:

Página A, ao longo de 5 ticks (bit R de cada tick à esquerda):
  tick 1 (R=1): 10000000
  tick 2 (R=0): 01000000
  tick 3 (R=1): 10100000
  tick 4 (R=0): 01010000
  tick 5 (R=0): 00101000

Página com MENOR valor de contador = usada há mais tempo = vítima.
Enter fullscreen mode Exit fullscreen mode

O aging captura bem a noção de "recentemente usada" com custo baixo, e é a inspiração direta do mecanismo de LRU do Linux. Uma limitação: o contador tem tamanho finito, então ele "esquece" o histórico além de N ticks — mas na prática isso é suficiente.


O Modelo do Working Set

Os algoritmos acima decidem qual página despejar. Mas Peter Denning propôs uma pergunta mais profunda: quantas páginas um processo realmente precisa ter na memória para rodar bem?

O working set $W(t, \Delta)$ é o conjunto de páginas que um processo acessou nas últimas $\Delta$ unidades de tempo (ou referências). É a formalização do "conjunto de trabalho atual":

$$W(t, \Delta) = {\text{páginas referenciadas no intervalo } (t - \Delta,\ t)}$$

A ideia-chave: se mantivermos o working set inteiro de um processo na memória, ele terá pouquíssimos page faults, porque suas referências, pela localidade, caem quase todas dentro do working set. Se não conseguirmos manter o working set na memória, o processo sofre — muito.

WorkingSet

O tamanho do working set varia conforme o programa muda de fase (inicialização, hot loop, shutdown). O parâmetro $\Delta$ (a janela) é um trade-off: muito pequeno perde páginas ainda úteis; muito grande retém páginas já frias.

Conexão backend: aquele exemplo da Parte I volta com força. Uma API pode ter 500 MB carregados, mas seu working set num dado segundo é talvez 50 MB. Dimensionar a RAM (ou o limite do container) abaixo do working set é a receita para thrashing.


WSClock

O WSClock combina o melhor de dois mundos: a eficiência do algoritmo do relógio com a inteligência do working set. É o algoritmo prático de referência em muitos sistemas.

Ele percorre os frames em anel (como o Clock), mas para cada página examina:

Para a página sob o ponteiro:
  - R = 1?  → zera R, atualiza "tempo do último uso", avança.
  - R = 0?  → calcula idade = tempo_atual - tempo_último_uso
      - idade ≤ Δ?  → está no working set, NÃO despeja, avança.
      - idade > Δ?  → fora do working set:
          - limpa?  → despeja (ótima vítima!)
          - suja?   → agenda write-back, avança (despeja na próxima volta)
Enter fullscreen mode Exit fullscreen mode

O WSClock só despeja páginas que estão fora do working set (não usadas há mais que $\Delta$), respeitando a localidade e evitando despejar páginas que serão logo reusadas. É, em essência, a filosofia que o Linux persegue com suas LRU lists e o mecanismo de reclaim (Parte VII).


Thrashing

Quando o working set combinado de todos os processos excede a memória física disponível, acontece o pesadelo: thrashing.

No thrashing, o sistema passa mais tempo movendo páginas entre RAM e disco do que executando trabalho útil. Cada processo despeja páginas de que ainda precisa; ao acessá-las de novo, dispara major faults; para atendê-los, despeja páginas de outro processo, que também sofre — uma reação em cadeia:

CPU útil
  100% │────────╲
       │         ╲
       │          ╲          ← "penhasco" do thrashing
       │           ╲________________
     0%│                            
       └────────────────────────────▶ grau de multiprogramação
                    ▲
              ponto de thrashing: demanda de memória > capacidade
Enter fullscreen mode Exit fullscreen mode

Como detectar: a assinatura clássica do thrashing é CPU baixa + I/O de disco (swap) altíssimo. A CPU está ociosa não por falta de trabalho, mas porque todo mundo está esperando o disco.

# Sinais de thrashing:
vmstat 1
#  r  b   swpd   free  ...  si   so   ...  us sy id wa
#  1 8  512000   2000      15000 18000     3  5  2 90
#             ▲                ▲    ▲                ▲
#    muito swap usado   swap in/out ALTOS    CPU 90% em I/O wait!

# A ferramenta moderna e muito mais precisa (PSI) veremos na Parte VII.
Enter fullscreen mode Exit fullscreen mode

Prevenção: PFF (Page Fault Frequency)

Uma técnica de controle é o PFF (Page Fault Frequency): monitorar a taxa de page faults de cada processo e ajustar dinamicamente quantos frames ele recebe:

  • Taxa de faults acima de um limite superior → o processo precisa de mais frames.
  • Taxa abaixo de um limite inferior → o processo tem frames demais; pode ceder alguns.

Se não há frames suficientes para todos, o remédio drástico é suspender (fazer swap-out completo de) um processo inteiro, liberando seus frames para os demais rodarem sem thrashing — melhor sacrificar um do que travar todos. Essa ideia ecoa no OOM Killer do Linux (Parte VII).


Políticas de Alocação de Frames

Por fim: como distribuir os frames disponíveis entre os processos?

Alocação Global vs Local

  • Alocação local: cada processo tem um conjunto fixo de frames; ao precisar despejar, escolhe a vítima apenas entre suas próprias páginas. Oferece isolamento (o comportamento de um processo não afeta os outros), mas pode ser ineficiente — um processo pode ter frames sobrando enquanto outro passa fome.

  • Alocação global: a vítima é escolhida entre as páginas de todos os processos. Mais eficiente no uso agregado da memória, mas processos competem entre si — um processo "faminto" pode roubar frames de outros.

O Linux usa fundamentalmente uma abordagem global (o reclaim opera sobre LRU lists globais por zona/nó), mas os cgroups v2 (Parte VIII) reintroduzem fronteiras locais, permitindo limitar e isolar a memória por grupo de processos — o melhor dos dois mundos para containers.

Igual vs Proporcional

Ao alocar globalmente, como dividir? Igualmente entre processos é injusto (um processo pequeno não precisa do mesmo que um grande). A alocação proporcional dá frames na proporção do tamanho de cada processo, e variantes consideram prioridade — processos mais importantes recebem mais memória.

Esses conceitos teóricos são exatamente o que os controles de cgroup (memory.low, memory.high, memory.max) implementam na prática moderna — veremos na Parte VIII.


Continua na Parte VI, onde saímos da teoria e entramos numa das estruturas mais importantes (e menos compreendidas) do Linux: o page cache — o motivo pelo qual sua segunda leitura de um arquivo é instantânea — e o slab allocator, como o próprio kernel gerencia sua memória interna.
: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)