Lugar De Lixo E No Lixo - Lugar de lixo é no lixo: Abril 2015
Lugar de lixo é no lixo: Abril 2015

O que é uma lista encadeada com lugar de lixo e nó no lixo

Você já tentou liberar e realocar nós em uma lista encadeada C num sistema embarcado e percebeu que o malloc e free estavam fragmentando a memória livre em menos de duas horas de execução. O problema não era o algoritmo de busca, era a alocação dinâmica descontrolada dentro do loop principal. A solução que encontrei foi implementar uma lista com um lugar de lixo separado, onde nós descartados ficam guardados em vez de serem realmente liberados, e só entram na contagem quando você precisa deles de novo. O conceito é simples: em vez de chamar free() em cada nó removido, você encadeia esses nós num place de lixo (ou garbage pool) e reutiliza quando precisar de um novo nó. O nó no lixo é aquele que saiu da lista ativa mas ainda está mapeado na memória. Isso elimina chamadas repetidas ao allocator e reduz a fragmentação drasticamente.

implementação prática de lugar de lixo e no lixo

Vou mostrar como eu fiz isso no meu projeto. A estrutura base é straightforward: typedef struct No { int dado; struct No* proximo; int ativo; } No;

O campo ativo é o que separa o nó funcional do nó no lixo. Quando um nó é removido da lista principal, você não chama free. Você coloca o nó na cabeça da lista de lixo e marca como inativo. Quando precisar de um nó novo, verifica primeiro se há um disponível no lugar de lixo antes de chamar malloc. Aqui está o código que eu usei na prática:

No* obter_no(void) { if (lixo != NULL) { No* temp = lixo; lixo = lixo->proximo; temp->ativo = 1; temp->proximo = NULL; return temp; } return malloc(sizeof(No)); } void devolver_ao_lixo(No* no) { no->ativo = 0; no->proximo = lixo; lixo = no; }

👉 Clique no botão abaixo para saber mais sobre o assunto!

O lugar de lixo começa como NULL. Cada vez que um nó é removido da lista ativa, ele vai para lá. Cada vez que precisa de um nó, puxa do lixo primeiro. Só chama malloc quando o lixo está vazio. E só chama free quando a aplicação está fechando, não durante a execução normal. Eu tive um caso específico onde essa abordagem quebrou: estava gerenciando uma fila de processos com remoção aleatória no meio da lista. O primeiro rascunho simplesmente jogava os nós no lixo e pronto. O problema era que o lugar de lixo crescia sem parar enquanto a lista ativa enxugia, e eu precisava de 40MB de memória apenas para nós ociosos. A workaround foi adicionar um contador máximo de nós no lixo. Quando o lugar de lixo atingia esse limite, eu forçava free() nos nós excedentes. Definir esse limite em 150% do pico histórico de tamanho da lista ativa resolveu. Eu monitorei por uma semana e notei que o tamanho máximo da lista ativa nunca ultrapassava 2200 nós, então configurei o limite do lixo para 3300. Qualquer coisa além disso ia direto pro free.

Outro detalhe que os tutoriais não mostram é sobre ponteiros pendurados. Se você tem outras estruturas mantendo ponteiros para nós da lista principal, devolver esses nós ao lugar de lixo pode causar uso indevido silencioso. O nó existe na memória, está marcado como inativo, mas o ponteiro de outra estrutura ainda aponta para ele. Eu resolvi isso adicionando um timestamp de geração: cada vez que um nó sai do lugar de lixo e volta pra lista ativa, ele recebe um número de geração novo. Qualquer estrutura que mantenha ponteiros precisa verificar se o número de geração do nó ainda é válido antes de dereferenciá-lo. Não é elegante, mas evita bugs que levam três dias para aparecerem. A desvantagem honesta é que essa técnica não funciona bem em sistemas com memória extremamente limitada ou onde o padrão de acesso aos nós é imprevisível. Se você remove e recria nós de forma completamente aleatória, o lugar de lixo vai oscilar entre vazio e saturado o tempo todo, e o overhead de gerenciar essa lista secundária pode ser maior do que simplesmente usar malloc e free. Nesses casos, um arena allocator fixa ou uma pool de memória de tamanho pré-alocado é mais adequado.

O ganho real aparece quando você tem padrões previsíveis de remoção e recriação. Num sistema de logs que eu maintive, onde registros eram constantemente removidos do início da lista e novos eram adicionados no final, a mudança de malloc/free puro para a abordagem com lugar de lixo reduziu o tempo médio de operação de inserção/remoção de cerca de 800 microssegundos para 45 microssegundos. A maior parte desse tempo antes ia para o sistema operacional fazer bookkeeping de alocação. Depois da mudança, o tempo de alocação era basicamente zero porque os nós já estavam alocados e só precisavam ser reposicionados. Se você quer testar isso, o código completo com a versão corrigida do gerenciador de geração e o limite dinâmico do lugar de lixo está disponível no repositório. A branch principal tem a implementação básica, e a branch com-gen tem a versão com verificação de timestamp que eu descrevi acima. O README explica como compilar e rodar os testes de stress que eu usei para validar antes de colocar em produção.

A regra prática que ficou: se sua aplicação faz mais de cem operações de remoção e recriação de nós por segundo, vale a pena implementar o lugar de lixo. Se faz menos, provavelmente malloc e free normais são suficientes e o código extra só vai complicar sem benefício mensurável.