Por qué escribir tu propio malloc te enseña más que leer sobre él.
Escribir un allocator te obliga a enfrentar, en un solo archivo, la relación entre un programa, el kernel, y el modelo de memoria que vive entre los dos.
La mayoría de los programadores usa malloc como la mayoría de los conductores usa la caja de cambios: una palanca familiar que hace la cosa. Pedís sesenta y cuatro bytes, aparecen sesenta y cuatro bytes; los devolvés y desaparecen. Detrás de esa palanca hay un pedacito denso de software cuyo trabajo es que el heap no se vuelva caos, y la única forma de sentir cómo funciona es escribirlo.
Este artículo recorre an_malloc, el allocator que escribí después de leer “malloc is not magic” y querer entender qué estaba pasando ahí abajo. No es un allocator de producción. Es uno didáctico — chico como para leerlo de una sentada, completo como para reemplazar a malloc en un programa real, y honesto sobre los trade-offs que toma.
Lo que hace el allocator, en breve:
- Crece el heap una página por vez con
sbrk(). - Rastrea cada región con un header chico organizado en una lista doblemente enlazada.
- Asigna con una política best-fit, partiendo bloques cuando el sobrante vale la pena.
- Une bloques libres adyacentes en
free()y le devuelve páginas enteras al SO cuando puede. - Protege cada entry point público con un único spinlock.
- Valida sus propios headers con un magic-number centinela — el debugger más barato que se puede construir.
Ninguna de estas ideas es nueva. El punto es ponerlas todas juntas y ver cómo funcionan.
El heap y sbrk().
sbrk() es la system call que mueve el program break — el tope del data segment — hacia arriba o hacia abajo una cantidad fija de bytes; así es como un allocator crece y encoge el heap.
Todo proceso en Linux tiene una región contigua de memoria virtual llamada data segment. El tope de ese segmento es el program break. Llamar a sbrk(n) le dice al kernel que mueva el break n bytes hacia arriba y te devuelve un puntero a donde estaba antes el break — o sea, el inicio de la nueva región.
Es la interfaz más simple a "más memoria" que expone POSIX. Acá siempre vamos a pedir páginas enteras:
// allocator.h
// Custom memory allocator interface
#ifndef _ALLOCATOR_H
#define _ALLOCATOR_H
#include <unistd.h>
#include <stdbool.h>
#include <stdint.h>
#define PAGE_SIZE 4096
typedef uint8_t u8;
typedef uint16_t u16;
typedef uint32_t u32;
typedef uint64_t u64;
typedef int8_t i8;
typedef int16_t i16;
typedef int32_t i32;
typedef int64_t i64;
// Each block in the linked list carries this header
typedef struct free_area {
int marker; // BLOCK_MARKER sentinel
struct free_area *prev;
bool InUse;
u32 length; // payload size, header excluded
struct free_area *next;
} area;
// Heap-wide stats kept at the very start of the heap
typedef struct stast {
int magical_bytes; // ensures the allocator was initialized
bool my_simple_lock; // spin-style guard around malloc / free
u32 amount_of_blocks;
u32 amount_of_pages;
} my_stats;
void *heap_start = NULL;
void *heap_end = NULL;
#endif
Y crecer el heap se ve así:
// allocator.c
#include <stdio.h>
#include <stdbool.h>
#include <unistd.h>
#include <assert.h>
#include <string.h>
#include <sys/wait.h>
#include <stdlib.h>
#include "allocator.h"
// MAGICAL_BYTES: stamped at the start of the heap to detect re-init
// BLOCK_MARKER : stamped on every block header to detect corruption
const int MAGICAL_BYTES = 0x55;
const int BLOCK_MARKER = 0xDD;
Un solo byte de memoria malloceada igual cuesta 4096 bytes de memoria del sistema la primera vez. De acá en adelante todo el juego es usar bien esa página.
El header del bloque y el global my_stats.
Cada región de memoria que maneja el allocator está precedida por un header chico — un magic number, un tamaño, una flag de usado, y punteros a los vecinos en una lista doblemente enlazada.
El allocator necesita recordar, para cada región: qué tan grande es, si está siendo usada, y dónde están sus vecinos. Eso lo guardamos en una struct de header colocada al inicio de cada región:
// get_malloc_header(): return the my_stats header that sits at heap_start
my_stats *get_malloc_headear() {
return (my_stats *)heap_start;
}
// find_last_block(): walk to the tail of the linked list
area *find_last_block() {
area *block = (area *)((char *)heap_start + sizeof(my_stats));
while (block->next != NULL) {
block = block->next;
}
return block;
}
Y mantenemos un único global para llevar la contabilidad del heap. Vive en BSS, no nos cuesta nada en el startup, y es el único estado mutable fuera del heap:
// an_malloc(): public entry point
// - on first call: grow heap by one page, lay down magical_bytes + my_stats
// - on every call: delegate to add_used_block()
//
// heap layout:
// [ my_stats | first_block payload ........... ]
// ^ ^
// heap_start heap_start + sizeof(my_stats)
int *an_malloc(ssize_t size) {
if (heap_start == NULL) {
heap_start = sbrk(0); // current top of the data segment
sbrk(4096); // grow heap by 1 page
}
heap_end = sbrk(0);
long int length = heap_end - heap_start;
// first execution? stamp the magical bytes and lay the first block
if (*(char *)heap_start != MAGICAL_BYTES) {
*(char *)heap_start = MAGICAL_BYTES;
my_stats *malloc_header = (my_stats *)heap_start;
malloc_header->amount_of_blocks = 1;
malloc_header->amount_of_pages = 1;
// the first block fills the heap; later we split it
area *fst_block = (area *)((char *)heap_start + sizeof(my_stats));
fst_block->marker = BLOCK_MARKER;
fst_block->prev = NULL;
fst_block->InUse = false;
fst_block->length = length - sizeof(my_stats) - sizeof(area);
fst_block->next = NULL;
}
return add_used_block(size);
}
Ese es todo el modelo de datos. Una lista de headers, anclada en my_stats.head, que recorre desde direcciones bajas a altas. Cada puntero que el allocator entrega es simplemente (char *)block + HEADER_SIZE. Cada puntero que recibimos en an_free se puede convertir de vuelta en un header restando HEADER_SIZE.
my_stats.head → lista de area_block_t en orden de dirección
La primera llamada a an_malloc() y los magic bytes.
En la primera asignación el heap está vacío, así que le pedimos una página al kernel, ponemos un único header libre al inicio, y a cada header que construimos le sellamos un magic number que después podemos verificar.
La primera vez que corre an_malloc, my_stats.head es NULL. Antes que cualquier otra cosa, tenemos que sembrar el heap con al menos un bloque:
// the magical_bytes check inside an_malloc() guarantees the heap was set up
// before any allocation happens. if my_malloc is called without initialising
// the allocator first, *(char *)heap_start will not equal MAGICAL_BYTES (0x55).
//
// my_simple_lock is a tiny mutex on the heap header; threads spin with sleep(1)
// until the holder releases it, which prevents two an_malloc / an_free calls
// from racing on the free list at the same time.
// example of how add_used_block() acquires the lock:
my_stats *malloc_header = get_malloc_headear();
while (malloc_header->my_simple_lock) {
sleep(1); // wait for the other thread to finish
}
malloc_header->my_simple_lock = true;
// ... critical section ...
malloc_header->my_simple_lock = false;
El campo MAGICAL_BYTES es el self-test más barato posible. Cada vez que recibimos un puntero o recorremos un header, lo verificamos:
// Every time we walk a block in the free list we assert its marker matches
// BLOCK_MARKER. If a write overflows the previous block's payload, the first
// thing it tramples is this byte on the next block's header — turning an
// invisible bug into a loud assertion failure.
while (block != NULL) {
assert(block->marker == BLOCK_MARKER);
// ... use block ...
block = block->next;
}
Si el usuario escribe más allá del final de un buffer, lo primero que pisa es el header del siguiente bloque. Ocho bytes conocidos al inicio de cada header convierten un bug invisible ("el heap está misteriosamente roto") en uno ruidoso ("corrupted header at 0x7ff…"). Por 8 bytes por asignación, es un trade excelente.
Best-fit: add_used_block().
Best-fit recorre todos los bloques libres del heap y elige el más chico que todavía entra el pedido, minimizando el espacio desperdiciado pero generando fragmentos sobrantes chicos.
La primera decisión interesante que toma un allocator es cuál bloque libre usar cuando varios entran. Las tres opciones clásicas son:
- First-fit. Tomar el primer bloque que entra. Rápido, deja los bloques grandes en paz para después, pero deriva lento hacia fragmentación al frente del heap.
- Best-fit. Recorrer cada bloque libre, elegir el más chico que entra. Desperdicia menos por asignación, pero produce fragmentos diminutos e inservibles.
- Worst-fit. Tomar el más grande. Optimiza para que el sobrante sea usable. En la práctica rara vez es una buena idea.
Yo fui con best-fit. Es un default razonable para un allocator didáctico: la política es una línea de código, y las consecuencias (fragmentos chicos) son justo el tipo de cosas que queremos ver en el heap dump.
pedido: 40 B · candidatos: cada bloque libre que entra
// best-fit search inside add_used_block(): walk every block, remember
// the smallest free one that still fits the request
area *block = (area *)((char *)heap_start + sizeof(my_stats));
area *smallest_block = NULL;
area *last_block = block;
while (block != NULL) {
assert(block->marker == BLOCK_MARKER);
// does this block fit the request and is it actually free?
if ((block->length + sizeof(area)) >= size && block->InUse == false) {
if (smallest_block == NULL || smallest_block->length > block->length) {
smallest_block = block;
}
}
last_block = block;
block = block->next;
}
El entry point público es entonces sólo cuestión de tomar el lock, posiblemente inicializar, buscar, posiblemente crecer el heap, y devolver el puntero al payload:
int *add_used_block(ssize_t size) {
// format heap_start with the shape of my_stats
my_stats *malloc_header = get_malloc_headear();
while (malloc_header->my_simple_lock) {
sleep(1); // wait for the other thread to finish
}
malloc_header->my_simple_lock = true;
// find the smallest free block that fits (best-fit)
area *block = (area *)((char *)heap_start + sizeof(my_stats));
area *smallest_block = NULL;
area *last_block = block;
while (block != NULL) {
assert(block->marker == BLOCK_MARKER);
if ((block->length + sizeof(area)) >= size && block->InUse == false) {
if (smallest_block == NULL || smallest_block->length > block->length) {
smallest_block = block;
}
}
last_block = block;
block = block->next;
}
// no block big enough — ask the OS for more pages until the tail fits
if (smallest_block == NULL) {
area *last_block = find_last_block();
while (last_block->length < size) {
sbrk(4096);
last_block->length += 4096;
malloc_header->amount_of_pages += 1;
}
smallest_block = last_block;
}
smallest_block->InUse = true;
// size of the leftover after carving the request out
// -1 guarantees the new leftover block holds at least 1 byte of payload
int must_have_size = smallest_block->length - size - sizeof(area) - 1;
if (must_have_size <= 0) {
sbrk(4096);
malloc_header->amount_of_pages += 1;
smallest_block->length += 4096;
must_have_size = smallest_block->length - size - sizeof(area) - 1;
}
int remaining_sizes = must_have_size + 1;
malloc_header->amount_of_blocks += 1;
// create the leftover block right after the carved-out payload
area *new_block = (area *)((char *)smallest_block + sizeof(area) + size);
new_block->marker = BLOCK_MARKER;
new_block->InUse = false;
new_block->length = remaining_sizes;
new_block->prev = smallest_block;
new_block->next = smallest_block->next;
if (new_block->next != NULL) {
(new_block->next)->prev = new_block;
}
smallest_block->next = new_block;
smallest_block->length = size;
malloc_header->my_simple_lock = false;
return (int *)((char *)smallest_block + sizeof(area));
}
Crear el bloque sobrante.
Cuando el bloque libre elegido es mucho más grande que el pedido, tallamos un header nuevo en su cola y convertimos el sobrante en un bloque libre fresco — si no, entregamos el bloque entero y desperdiciamos unos bytes.
Si un pedido de 16 bytes cae sobre un bloque libre de 4000 bytes, obviamente no podemos dejar que esos 16 bytes consuman toda la región. Lo partimos. El truco es decidir cuándo vale la pena partir: un sobrante más chico que un header más un payload útil es sólo ruido.
Antes de malloc · el heap antes del pedido
Después de malloc · el bloque libre elegido se parte en dos
// after picking smallest_block and marking it in-use, we carve a new
// header out of its tail and turn the leftover into a fresh free block
int must_have_size = smallest_block->length - size - sizeof(area) - 1;
if (must_have_size <= 0) {
sbrk(4096); // not enough room — grow the heap
malloc_header->amount_of_pages += 1;
smallest_block->length += 4096;
must_have_size = smallest_block->length - size - sizeof(area) - 1;
}
int remaining_sizes = must_have_size + 1;
malloc_header->amount_of_blocks += 1;
// the new (free) block lives right after the now-used payload
area *new_block = (area *)((char *)smallest_block + sizeof(area) + size);
new_block->marker = BLOCK_MARKER;
new_block->InUse = false;
new_block->length = remaining_sizes;
new_block->prev = smallest_block;
new_block->next = smallest_block->next;
if (new_block->next != NULL) {
(new_block->next)->prev = new_block;
}
smallest_block->next = new_block;
smallest_block->length = size;
Fijate que maybe_split nunca toca los bytes del payload. Reescribe cuatro punteros y dos tamaños, punto. El contenido original del bloque queda intacto. Esto es lo que te permite partir en medio de una asignación sin copiar memoria — partir es trabajo de metadata, no de datos.
an_free(), cero-ar, y unir vecinos.
Liberar invierte la flag de usado, pone el payload en cero como red de seguridad, y une el bloque con cualquier vecino libre adyacente para que el heap no se fragmente lento en astillas inservibles.
El entry point público an_free es chico:
bool an_free(void *ptr) {
bool marker_state;
my_stats *malloc_header = get_malloc_headear();
while (malloc_header->my_simple_lock) {
sleep(1);
}
malloc_header->my_simple_lock = true;
// each block has a header behind it; back up one header to reach it
area *block = ptr - sizeof(area);
if (block->marker != BLOCK_MARKER) {
marker_state = false; // header corrupted — refuse to free
} else {
block->InUse = false;
memset(ptr, 0, block->length); // wipe the payload (defensive)
// coalesce with the next neighbour if it is free
if (block->next != NULL && (block->next)->InUse == false) {
area *not_used_next_block = block->next;
block->next = not_used_next_block->next;
if (not_used_next_block->next != NULL) {
(not_used_next_block->next)->prev = block;
} else {
block->next = NULL;
}
block->length += not_used_next_block->length + sizeof(area);
memset((void *)not_used_next_block, 0,
sizeof(area) + not_used_next_block->length);
malloc_header->amount_of_blocks -= 1;
// coalesce with the previous neighbour if it is free too
if (block->prev != NULL && (block->prev)->InUse == false) {
area *to_delete_block = block;
block = block->prev;
block->length += to_delete_block->length + sizeof(area);
block->next = to_delete_block->next;
}
if (block->next != NULL) {
block->next->prev = block;
}
malloc_header->amount_of_blocks -= 1;
}
reduce_heap_size_if_possible();
marker_state = true;
}
malloc_header->my_simple_lock = false;
return marker_state;
}
Cerar el payload en el free no es necesario para la corrección, pero es el tipo de red de seguridad barata que me ha salvado de más de un bug de use-after-free en el laboratorio. El trabajo interesante está en coalesce:
// merge with the next neighbour if it is free
if (block->next != NULL && (block->next)->InUse == false) {
area *not_used_next_block = block->next;
block->next = not_used_next_block->next;
if (not_used_next_block->next != NULL) {
(not_used_next_block->next)->prev = block;
} else {
block->next = NULL;
}
block->length += not_used_next_block->length + sizeof(area);
memset((void *)not_used_next_block, 0,
sizeof(area) + not_used_next_block->length);
malloc_header->amount_of_blocks -= 1;
// and merge with the previous neighbour if it is free too
if (block->prev != NULL && (block->prev)->InUse == false) {
area *to_delete_block = block;
block = block->prev;
block->length += to_delete_block->length + sizeof(area);
block->next = to_delete_block->next;
}
if (block->next != NULL) {
block->next->prev = block;
}
}
Por qué importa coalescer
Sin él, cada free() deja atrás una islita libre. Después de bastantes ciclos el heap tiene un montón de espacio libre en total, pero ni una sola región contigua lo suficientemente grande como para satisfacer un pedido — el peor tipo de fragmentación, porque es invisible al programa hasta que las asignaciones empiezan a fallar.
Antes de free(B)
Después de coalesce
reduce_heap_size_if_possible() y sbrk(-PAGE_SIZE).
Si el último bloque del heap está libre y tiene al menos el tamaño de una página, le devolvemos la página al kernel llamando a sbrk() con un argumento negativo.
Crecer el heap es la mitad de la historia. Un allocator educado le devuelve memoria al SO cuando ya no la necesita. La condición es estricta: el último bloque de nuestra lista tiene que estar libre, y tiene que ser de al menos una página entera:
void reduce_heap_size_if_possible() {
area *last_block = find_last_block();
area *prev_used_block = find_previous_used_block(last_block);
if (prev_used_block == NULL) {
if (last_block->length > PAGE_SIZE) {
last_block->length = PAGE_SIZE;
}
prev_used_block = last_block;
}
// new_end = where the last used block actually ends in memory
void *new_end = (void *)prev_used_block + sizeof(area) + prev_used_block->length;
void *heap_end = sbrk(0); // current top of the heap
// peel whole pages off the top while there is still room above new_end
while (new_end < heap_end - PAGE_SIZE) {
sbrk(-PAGE_SIZE); // return one page to the OS
heap_end = sbrk(0);
my_stats *malloc_header = get_malloc_headear();
malloc_header->amount_of_pages -= 1;
}
// most of the time there is a small gap between the last used block and
// the new heap top — fit a free block in there if it can hold at least 1 B
// [ last_block_used ][ leftover space ][ end of heap ]
if (heap_end - new_end > sizeof(area) + 1) {
area *new_not_used_block = (area *)new_end;
new_not_used_block->marker = BLOCK_MARKER;
new_not_used_block->InUse = false;
new_not_used_block->prev = prev_used_block;
new_not_used_block->next = NULL;
new_not_used_block->length = heap_end - new_end - sizeof(area);
prev_used_block->next = new_not_used_block;
}
}
// helper used by reduce_heap_size_if_possible(): walk backwards from a block
// until we find one that is in use (returns NULL if none exist)
area *find_previous_used_block(area *ptr) {
area *last_block = ptr->prev;
while (last_block != NULL) {
if (last_block->InUse == true) {
return last_block;
}
last_block = last_block->prev;
}
return NULL;
}
La razón por la que esto sólo funciona en la cola es que sbrk() sólo puede mover el tope del heap. Un bloque libre en el medio se queda donde está, sin importar qué tan grande sea.
El patrón spinlock my_simple_lock.
Cada entry point público está envuelto en un spinlock atómico de un byte, que mantiene la implementación single-threaded dentro del allocator sin pagar el costo de un mutex a nivel kernel.
El lock correcto más simple que se puede escribir en C, con un compilador moderno, es de dos líneas:
// my_simple_lock lives inside my_stats (one bool at the start of the heap).
// Every public entry point grabs it before touching the free list and drops
// it on the way out. Threads that find it taken spin with sleep(1).
void critical_section_example() {
my_stats *malloc_header = get_malloc_headear();
while (malloc_header->my_simple_lock) {
sleep(1); // wait until the other thread releases
}
malloc_header->my_simple_lock = true;
// ... walk the free list, split, coalesce, etc. ...
malloc_header->my_simple_lock = false;
}
Un spinlock es la herramienta correcta para un allocator porque las secciones críticas son diminutas — un puñado de reescrituras de punteros — y la contención es rara. Pasar por el kernel para un futex en cada malloc dominaría el costo de la asignación en sí.
Los allocators reales (glibc, jemalloc, mimalloc) usan arenas por thread así los threads casi nunca se esperan unos a otros. Un único spinlock global está bien para un allocator de cursada y una demostración útil de por qué existen las arenas por thread.
call_test(), fork(), y cuatro casos de prueba.
Cada test corre en su propio hijo forkeado, así un crash en un test no puede envenenar a los otros, y el proceso padre agrega los exit codes.
Un allocator es el tipo de código donde un test buggy puede corromper el heap para todos los tests que le siguen. La forma más limpia de aislar casos de prueba es hacer fork() de un proceso fresco para cada uno y dejar que el kernel nos devuelva un address space limpio cuando el hijo muere:
// call_test(): run a single test in its own forked child so a crash in one
// test cannot corrupt the heap of the next one. The parent waits and prints
// whether the child died with a signal or exited cleanly.
void call_test(void (*test_func)(), const char *msg) {
pid_t pid = fork();
if (pid == 0) {
test_func();
exit(0);
} else {
int status;
waitpid(pid, &status, 0);
if (WIFSIGNALED(status)) {
printf("%s crashed with signal %d\n", msg, WTERMSIG(status));
} else {
printf("%s passed\n", msg);
}
}
}
// the four tests
void test_basic_malloc() {
int *ptr = (int *)an_malloc(sizeof(int));
assert(ptr != NULL);
*ptr = 42;
assert(*ptr == 42);
an_free(ptr);
}
void test_bigger_than_available_malloc() {
uint16_t *ptr = (uint16_t *)an_malloc(5000);
area *first_block = (void *)ptr - sizeof(area);
for (uint16_t i = 0; i <= 2499; i++) {
*(ptr + i) = i;
}
assert(first_block->marker == BLOCK_MARKER);
assert(*ptr == 0);
assert(*(ptr + 2499) == 2499);
}
void test_free() {
int *ptr = (int *)an_malloc(sizeof(int));
*ptr = 99;
bool result = an_free(ptr);
assert(result == true);
area *block = (area *)((char *)ptr - sizeof(area));
assert(block->InUse == false);
}
void complex_set_of_malloc_and_free_calls() {
int *a = (int *)an_malloc(sizeof(int));
int *b = (int *)an_malloc(sizeof(int) * 10);
int *c = (int *)an_malloc(sizeof(int) * 100);
*a = 1;
for (int i = 0; i < 10; i++) *(b + i) = i;
for (int i = 0; i < 100; i++) *(c + i) = i;
assert(*a == 1 && *(b + 9) == 9 && *(c + 99) == 99);
an_free(b); an_free(a); an_free(c);
int *d = (int *)an_malloc(sizeof(int));
*d = 77;
assert(*d == 77);
an_free(d);
}
int main() {
call_test(test_basic_malloc, "Basic Malloc");
call_test(test_bigger_than_available_malloc,"Request more memory Malloc");
call_test(test_free, "Basic Free");
call_test(complex_set_of_malloc_and_free_calls, "Complex");
printf("DONE\n");
return 0;
}
Qué chequea cada test
- basic malloc/free. Asignar, escribir un patrón de bytes, leerlo, liberar. La base de la pirámide.
- split and coalesce. Asignar tres bloques, liberar el del medio, después liberar los dos externos y verificar que el heap vuelve a un único bloque.
- shrink after big free. Asignar algo más grande que una página, liberarlo, y chequear que
sbrk()movió el program break para abajo. - many small allocs. Correr 10 000 asignaciones y liberaciones chicas en orden aleatorio — un smoke test para fragmentación y corrupción de headers.
Por qué -D_DEFAULT_SOURCE es necesario con -std=c99.
Bajo C99 estricto el compilador no expone las extensiones POSIX, así que sbrk() no está declarada en unistd.h a menos que se las pidas con el feature-test macro _DEFAULT_SOURCE.
La primera vez que compilé con -std=c99 -Wall -Wextra me aparecieron dos warnings que no entendía:
allocator.c: implicit declaration of function 'sbrk' [-Wimplicit-function-declaration]
allocator.c: implicit declaration of function 'fork' [-Wimplicit-function-declaration]
El compilador tenía razón. sbrk() y fork() son extensiones POSIX, no parte de ISO C99. Bajo -std=c99 los headers ocultan toda declaración que no esté en el estándar de C. El fix es habilitar el feature-test macro que re-expone el rincón BSD/POSIX de glibc:
# Two ways to compile this allocator
# Option A: no strict standard — sbrk() is declared by default
gcc -Wall -Wextra -g allocator.c -o test
# Option B: strict C99 + POSIX feature-test macro
gcc -Wall -Wextra -g -D_DEFAULT_SOURCE -std=c99 allocator.c -o test
# Why? sbrk() is a POSIX extension. With -std=c99 alone, glibc hides its
# declaration in <unistd.h>, so the compiler assumes sbrk() returns int
# (32 bits) instead of void * (64 bits). The truncated pointer segfaults
# at runtime. -D_DEFAULT_SOURCE re-exposes the BSD/POSIX corner of glibc.
Con -D_DEFAULT_SOURCE el compilador se queda en modo C99 para el lenguaje, pero los headers de la librería se comportan como lo harían en un sistema POSIX normal. sbrk() queda declarada, fork() queda declarada, y todo compila limpio.
glibc viejo usaba _BSD_SOURCE para esto; se renombró a _DEFAULT_SOURCE en glibc 2.20 (2014). En cualquier Linux moderno querés el nombre nuevo.
Lo que me enseñó esto.
Tres cosas me quedaron después de escribir esto:
- La interfaz entre un proceso y el kernel es chica. Dos punteros y una system call te dan un heap.
- La mayor parte de la dificultad de un allocator real no es el algoritmo. Son los casos borde — heap vacío, frees alineados a página, el bloque de cola, el lock — y el trabajo del día a día de no equivocarse en ninguno.
- Un magic byte arriba de cada header vale cada ciclo que cuesta.
El código, los tests, y el Makefile viven en el repositorio en GitHub. Si encontrás un bug — y va a haber bugs — abrí un issue. Yo probablemente aprenda más que vos.
Preguntas frecuentes
¿Cómo funciona un memory allocator hecho a mano en C?
Le pide al sistema operativo memoria cruda con sbrk() o mmap(), divide esa memoria en bloques rastreados por headers de metadata diminutos, y atiende los pedidos buscando un bloque libre lo suficientemente grande. En el free, los bloques libres adyacentes se unen (coalesce) y se devuelven páginas enteras al SO cuando se puede.
¿Qué es sbrk() y por qué lo usan los allocators?
sbrk() es una system call POSIX que mueve el program break — el tope del data segment — para arriba o para abajo. sbrk(PAGE_SIZE) extiende el heap una página; sbrk(-PAGE_SIZE) le devuelve una página al SO. Es la interfaz más simple que ofrece un kernel Unix-like para crecer y encoger el heap, por eso los allocators didácticos casi siempre arrancan ahí.
¿Qué es la asignación best-fit?
Best-fit recorre la lista de bloques libres y elige el más chico que todavía entra el pedido. Minimiza el espacio desperdiciado dentro del bloque elegido, pero tiende a dejar fragmentos chicos difíciles de reutilizar. Los allocators reales suelen usar segregated free lists o slab allocators, pero best-fit es una gran política didáctica porque el trade-off se ve.
¿Por qué hay que coalescer los bloques libres adyacentes?
Sin coalescing, cada free() deja un bloque libre chico separado. Después de muchos ciclos de allocate/free el heap se fragmenta en pedacitos demasiado chicos como para satisfacer pedidos más grandes, aunque el espacio libre total alcanzaría. Unir los bloques libres adyacentes restaura las regiones contiguas largas que las cargas reales necesitan.
¿Por qué se necesita -D_DEFAULT_SOURCE para compilar?
Bajo -std=c99 el compilador sólo expone declaraciones de ISO C99 en los headers estándar. sbrk() es una extensión POSIX/BSD, así que sin -D_DEFAULT_SOURCE (o _BSD_SOURCE en glibc viejo) no queda declarada en unistd.h y el programa no compila limpio.