← Gestión de Memoria Bloque 10 · malloc por dentro · Tema 48 de 52

Tema 48

Fragmentación del heap y otros asignadores

Un programa puede conservar pocos objetos vivos y, sin embargo, ocupar mucha memoria. Puede haber redondeo dentro de los bloques, huecos que no se pueden unir, páginas retenidas por unos pocos supervivientes o espacio guardado en cachés. En este tema separarás esas causas, medirás patrones de vida útil y construirás dos estrategias especializadas: un pool de objetos iguales y una región que se libera por fases. También compararás los principios de jemalloc, TCMalloc y mimalloc sin suponer que cambiar de asignador resuelve cualquier carga.

1. Qué puede significar «memoria desperdiciada»

En los temas 46 y 47 vimos dos organizaciones muy diferentes: una lista libre sencilla y un asignador con clases, cachés y arenas. En ambos casos es necesario distinguir el tamaño útil de los objetos del espacio que hace falta para administrarlos.

CausaEjemploQué podría ayudar
Fragmentación internaUn objeto pide 33 bytes y ocupa un slot de 48Revisar tamaños y granularidad de clases
Fragmentación externaHay varios huecos, pero ninguno alcanza para una peticiónFusionar vecinos o reorganizar las vidas útiles
Ocupación parcial de páginasUn objeto vivo impide reutilizar una página completa para otra claseAgrupar objetos con destinos y duraciones semejantes
RetenciónChunks libres en una caché privada o región que se conservaEvaluar límites y políticas de devolución
MetadatosCabeceras, mapas de slots y estructuras de arenasAmortizar administración sin perder capacidad de diagnóstico
FugaEl objeto sigue asignado y ya no se conserva una referencia para liberarloCorregir la propiedad y vida útil en la aplicación

Estas causas pueden coexistir. Un RSS alto no permite identificar cuál domina. Tampoco conviene llamar «fuga» a toda memoria que no desaparece después de free ni llamar «caché» a un objeto que la aplicación olvidó liberar.

La solución depende del patrón: cambiar un límite del asignador no recupera objetos perdidos; fusionar huecos no reduce el redondeo de las clases; un pool puede agilizar objetos iguales y desperdiciar capacidad si se reserva demasiado.

2. Definir qué estamos midiendo

Antes de calcular porcentajes, define las magnitudes y su ámbito. En un experimento podemos llevar estas cuentas:

SímboloMagnitudCómo obtenerla
LBytes solicitados de los objetos vivosContabilidad de la aplicación
CCapacidad de datos asignada a esos objetos en un modelo o diagnósticoClases conocidas o una API de diagnóstico compatible
REspacio de una región o conjunto de regiones administradasEstado del asignador o del modelo, con ámbito definido
PPáginas residentes o su tamaño en bytesEstadísticas del sistema operativo
ejemplo de métricas, no una identidad universal del RSS
Exceso de capacidad de los objetos vivos = C - L
Exceso relativo respecto de lo pedido = (C - L) / L, si L > 0
Utilización de una región = L / R, si R > 0

Si L = 1000 y C = 1280:
hay 280 bytes de capacidad adicional, un 28 % de lo pedido.

No mezcles denominadores: 280 / 1280 describe una fracción diferente. Tampoco sumes en C toda la memoria de arenas si pretendes medir solo el redondeo de los objetos vivos. Un malloc_usable_size puede servir como diagnóstico dentro de su implementación, pero no describe por sí solo metadatos, caches, reservas ni todo el costo físico.

En una única región de lista libre también puede ser útil observar 1 - mayor_hueco / total_libre, cuando total_libre es mayor que cero y ambos tamaños se miden de la misma forma. Es un indicador de dispersión de esos huecos, no una medida universal de fragmentación de glibc o de todo el proceso.

Los valores de R y P pueden evolucionar de forma distinta: páginas descartadas pueden bajar el respaldo físico manteniendo direcciones reservadas; páginas todavía no tocadas pueden existir virtualmente sin estar residentes. El próximo tema profundizará en RSS, PSS y herramientas de medición.

3. Fragmentación interna y clases de tamaño

Una clase de tamaño entrega slots de capacidad fija. Es más sencillo administrar una página de slots iguales que cualquier combinación de longitudes, pero se redondean las peticiones.

clases ilustrativas de 32, 64 y 128 bytes
Petición 31 -> clase 32  ->  1 byte adicional
Petición 33 -> clase 64  -> 31 bytes adicionales
Petición 65 -> clase 128 -> 63 bytes adicionales

1000 objetos de 33 bytes:
L = 33000; C = 64000; exceso = 31000 bytes.

Son clases elegidas para el ejemplo, no una tabla real de jemalloc o glibc. Los asignadores suelen usar granularidades más finas en distintos intervalos para equilibrar desperdicio y cantidad de clases.

El tamaño de una estructura C incluye su propio relleno de alineación. Si sizeof(struct Dato) vale 40, malloc recibe 40 aunque sus campos sumen menos. Ese relleno del tipo es distinto del que agrega el asignador. Reordenar campos puede reducir tamaño, pero una reducción solo será efectiva sobre la capacidad asignada si cambia de clase o mejora la densidad pertinente.

No se debe escribir en los bytes adicionales porque un diagnóstico muestre más capacidad. Si necesitas un objeto mayor, reserva ese tamaño o redimensiónalo por su API. Las cifras de implementación no sustituyen el contrato de la aplicación.

4. Huecos, páginas y supervivientes

En una lista libre, liberar dos objetos separados por uno vivo deja huecos que no pueden fusionarse. En un esquema de páginas por clase, puede quedar capacidad libre para objetos de esa clase, pero no una página vacía que pueda reasignarse a una clase mayor.

páginas de cuatro slots · X sobrevive y el punto está libre
Supervivientes dispersos:
[ X . . . ] [ X . . . ] [ X . . . ] [ X . . . ]
Cuatro objetos vivos mantienen cuatro páginas parcialmente ocupadas.

Supervivientes agrupados:
[ X X X X ] [ . . . . ] [ . . . . ] [ . . . . ]
Las otras tres páginas quedan enteramente libres en este modelo.

A los supervivientes que mantienen unidades mayores parcialmente ocupadas se los suele describir como anclas de esas páginas o slabs. Liberar el resto no permite moverlos si la aplicación conserva punteros directos que el asignador no puede actualizar.

Una página no vacía puede permitir descartar subrangos o reutilizar slots según la organización, y una página vacía no tiene que devolverse inmediatamente al núcleo: puede conservarse por política. «Reutilizable para la misma clase», «reasignable a otra clase» y «devuelto al sistema» son estados diferentes.

La relación entre orden de asignación y ubicación no es una garantía de malloc. El experimento siguiente cambia patrones lógicos; sus páginas reales dependen de la implementación, de metadatos y de otras asignaciones del proceso.

5. Experimento con patrones de vida útil

Reservaremos 8192 objetos de 1024 bytes, un total útil de 8 MiB. Después conservaremos 512 objetos: uno de cada 16 en el modo disperso, o los primeros 512 en el agrupado. Ambos tienen los mismos 524 288 bytes útiles vivos, pero un patrón distinto de supervivencia.

En esa fase pediremos además 64 bloques de 64 KiB, los liberaremos y finalmente retiraremos todos los supervivientes. El programa usa la API estándar de asignación, para poder repetirlo con otra implementación. La lectura de /proc/self/status lo hace específico de Linux.

patron_heap.c · la contabilidad útil es exacta; RSS y VmSize son observaciones
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

#define OBJETOS 8192
#define TAM 1024
#define GRANDES 64
#define TAM_GRANDE (64UL * 1024)

static int mostrar(const char *fase, size_t utiles, int pausa)
{
    FILE *f = fopen("/proc/self/status", "r");
    if (f == NULL) { perror("status"); return 1; }
    char linea[256];
    size_t rss = 0, virtual = 0;
    int hay_rss = 0, hay_virtual = 0;
    while (fgets(linea, sizeof linea, f) != NULL) {
        if (sscanf(linea, "VmRSS: %zu kB", &rss) == 1) hay_rss = 1;
        if (sscanf(linea, "VmSize: %zu kB", &virtual) == 1) hay_virtual = 1;
    }
    int error = ferror(f);
    if (fclose(f) != 0) error = 1;
    if (error || !hay_rss || !hay_virtual) {
        fprintf(stderr, "No se pudieron leer las métricas\n"); return 1;
    }
    printf("%s: utiles=%zu B; VmRSS=%zu KiB; VmSize=%zu KiB\n",
           fase, utiles, rss, virtual);
    if (pausa) { puts("Pulsa Enter para continuar."); getchar(); }
    return 0;
}

int main(int argc, char *argv[])
{
    if ((argc != 2 && argc != 3) ||
        (strcmp(argv[1], "disperso") != 0 && strcmp(argv[1], "agrupado") != 0) ||
        (argc == 3 && strcmp(argv[2], "pausa") != 0)) {
        fprintf(stderr, "Uso: %s disperso|agrupado [pausa]\n", argv[0]);
        return 1;
    }
    setvbuf(stdout, NULL, _IONBF, 0);
    int disperso = strcmp(argv[1], "disperso") == 0;
    int pausa = argc == 3;
    unsigned char *objetos[OBJETOS] = { 0 };
    void *grandes[GRANDES] = { 0 };
    size_t utiles = 0;
    int error = 0;
    printf("PID: %ld; modo: %s\n", (long) getpid(), argv[1]);
    error |= mostrar("Inicio", utiles, pausa);
    for (size_t i = 0; i < OBJETOS; i++) {
        objetos[i] = malloc(TAM);
        if (objetos[i] == NULL) { perror("malloc"); error = 1; goto limpiar; }
        volatile unsigned char *p = objetos[i];
        for (size_t j = 0; j < TAM; j++) p[j] = 7;
        utiles += TAM;
    }
    error |= mostrar("Todos vivos", utiles, pausa);
    for (size_t i = 0; i < OBJETOS; i++) {
        int conservar = disperso ? i % 16 == 0 : i < OBJETOS / 16;
        if (!conservar) {
            free(objetos[i]); objetos[i] = NULL; utiles -= TAM;
        }
    }
    error |= mostrar("Supervivientes", utiles, pausa);
    for (size_t i = 0; i < GRANDES; i++) {
        grandes[i] = malloc(TAM_GRANDE);
        if (grandes[i] == NULL) { perror("malloc grande"); error = 1; goto limpiar; }
        volatile unsigned char *p = grandes[i];
        for (size_t j = 0; j < TAM_GRANDE; j += 1024) p[j] = 9;
        utiles += TAM_GRANDE;
    }
    error |= mostrar("Con bloques grandes", utiles, pausa);
    for (size_t i = 0; i < GRANDES; i++) {
        free(grandes[i]); grandes[i] = NULL; utiles -= TAM_GRANDE;
    }
    for (size_t i = 0; i < OBJETOS; i++) {
        if (objetos[i] != NULL &&
            (objetos[i][0] != 7 || objetos[i][TAM - 1] != 7)) {
            fprintf(stderr, "Datos de un superviviente alterados\n"); error = 1;
        }
    }
    error |= mostrar("Solo supervivientes", utiles, pausa);
limpiar:
    for (size_t i = 0; i < GRANDES; i++) free(grandes[i]);
    for (size_t i = 0; i < OBJETOS; i++) free(objetos[i]);
    error |= mostrar("Todo liberado", 0, pausa);
    return error ? 1 : 0;
}
terminal · cada patrón en un proceso nuevo
$ gcc -std=c17 -Wall -Wextra -O2 patron_heap.c -o patron_heap
$ ./patron_heap disperso
$ ./patron_heap agrupado
$ ./patron_heap disperso pausa

La cuenta útil pasa por 0, 8 388 608, 524 288, 4 718 592, 524 288 y 0 bytes. RSS y VmSize deben medirse; no se fija una salida universal. La separación de 1024 bytes al tocar los bloques grandes es una decisión de la prueba, no una consulta del tamaño de página.

Los campos de status son aproximaciones con limitaciones de contabilidad; para una investigación más detallada se puede consultar smaps, como veremos en el tema 49. Abrir y leer status también utiliza biblioteca y puede alterar algo el estado. Compara fases y repetición, no atribuyas cada variación a un hueco específico.

El experimento no obliga a que los objetos consecutivos ocupen páginas consecutivas ni prueba que un patrón siempre gane. Sirve para mantener iguales los tamaños útiles mientras cambia la duración y ubicación lógica de los supervivientes. Las trazas y estadísticas específicas del asignador ayudan a interpretar lo que ocurrió.

6. Pools de objetos de tamaño fijo

Pool de objetos es una reserva dividida en slots capaces de contener un tipo o una clase de objetos. Obtener y devolver un slot puede reducirse a retirar o insertar un elemento de una lista libre.

Si todos los objetos tienen el mismo tamaño, no hace falta buscar un hueco variable ni dividirlo. Cada slot libre sirve para la siguiente petición del mismo tipo. La alineación se obtiene del tipo que ocupa el slot y la administración puede reutilizar el espacio del objeto cuando está libre.

El costo no desaparece: hay que dimensionar el pool, tratar el agotamiento y decidir si puede crecer. Un slot de 64 bytes usado para un objeto de 20 desperdicia capacidad. Un pool reservado para una clase tampoco presta automáticamente sus slots libres a otra clase.

Cuando se agrupan slots de una clase en unidades mayores, se suele hablar de slabs o estructuras similares. Vaciar una unidad completa permite considerar su reasignación o devolución, pero la política concreta depende del asignador. Una «página» del asignador no coincide necesariamente con una página base del sistema operativo.

7. Un pool tipado completo en C

Este pool tiene 128 slots para struct Registro. Una unión permite que cada celda contenga un registro vivo o un enlace cuando está libre. La reserva es estática y su alineación proviene del propio tipo; no se construyen objetos sobre un arreglo arbitrario de bytes.

pool_registros.c · reserva fija, agotamiento y reutilización
#include <stdio.h>
#include <stdlib.h>

#define CAPACIDAD 128

struct Registro {
    double valor;
    unsigned id;
    char etiqueta[32];
};

typedef union Celda Celda;
union Celda {
    struct Registro dato;
    Celda *siguiente;
};

struct Pool {
    Celda celdas[CAPACIDAD];
    Celda *libres;
    size_t usados;
};

static void pool_init(struct Pool *p)
{
    for (size_t i = 0; i < CAPACIDAD; i++)
        p->celdas[i].siguiente = i + 1 < CAPACIDAD ? &p->celdas[i + 1] : NULL;
    p->libres = &p->celdas[0];
    p->usados = 0;
}

static struct Registro *pool_obtener(struct Pool *p)
{
    if (p->libres == NULL) return NULL;
    Celda *c = p->libres;
    p->libres = c->siguiente;
    c->dato = (struct Registro) { 0 };
    p->usados++;
    return &c->dato;
}

static void pool_devolver(struct Pool *p, struct Registro *r)
{
    if (r == NULL) return;
    /* Precondición: r es un registro vivo de este pool. */
    Celda *c = (Celda *) r;
    c->siguiente = p->libres;
    p->libres = c;
    p->usados--;
}

static void exigir(int condicion)
{
    if (!condicion) { fputs("Comprobación fallida\n", stderr); exit(EXIT_FAILURE); }
}

int main(void)
{
    static struct Pool pool;
    struct Registro *vivos[CAPACIDAD];
    pool_init(&pool);
    for (size_t i = 0; i < CAPACIDAD; i++) {
        vivos[i] = pool_obtener(&pool);
        exigir(vivos[i] != NULL);
        vivos[i]->id = (unsigned) i;
        vivos[i]->valor = (double) i / 2;
    }
    exigir(pool_obtener(&pool) == NULL);
    for (size_t i = 0; i < CAPACIDAD; i += 2) {
        pool_devolver(&pool, vivos[i]); vivos[i] = NULL;
    }
    exigir(pool.usados == CAPACIDAD / 2);
    for (size_t i = 0; i < CAPACIDAD; i += 2) {
        vivos[i] = pool_obtener(&pool);
        exigir(vivos[i] != NULL && vivos[i]->id == 0);
        vivos[i]->id = 1000 + (unsigned) i;
    }
    for (size_t i = 1; i < CAPACIDAD; i += 2)
        exigir(vivos[i]->id == i && vivos[i]->valor == (double) i / 2);
    for (size_t i = 0; i < CAPACIDAD; i++) {
        pool_devolver(&pool, vivos[i]); vivos[i] = NULL;
    }
    exigir(pool.usados == 0);
    printf("Registro: %zu B; slot: %zu B; área de slots: %zu B\n",
           sizeof(struct Registro), sizeof(Celda), sizeof pool.celdas);
    puts("Agotamiento, reutilización y conservación de datos correctos.");
    return 0;
}
terminal · programa C17 sin dependencias adicionales
$ gcc -std=c17 -Wall -Wextra -Wpedantic -O2 pool_registros.c -o pool_registros
$ ./pool_registros
Registro: ... B; slot: ... B; área de slots: ... B
Agotamiento, reutilización y conservación de datos correctos.

Obtener y devolver son O(1); preparar la lista cuesta O(capacidad). El programa inicializa los campos de cada registro entregado. La unión asegura una alineación adecuada para ese registro, no para cualquier tipo con requisitos arbitrarios.

No hay llamadas a malloc por objeto ni devolución de memoria al sistema: el área estática permanece durante el proceso. Devolver solo hace reutilizable un slot. El código no detecta doble devolución, punteros ajenos o carreras y no debe reinicializarse con objetos vivos. Es un pool de un solo hilo con precondiciones explícitas.

8. Regiones de aplicación y liberación por fases

Si muchos objetos nacen durante una tarea y dejan de ser necesarios juntos, se puede utilizar una región lineal: un cursor avanza al reservar y se restablece al terminar la fase. También se la llama arena de aplicación o asignador monotónico.

No es la arena interna de glibc del tema 47. Aquí la aplicación decide la vida útil conjunta. No hay free individual ni búsqueda de huecos; a cambio, un objeto que termina antes sigue consumiendo su espacio hasta el reinicio de la región.

asignación por cursor
[ objeto A ][ alineación ][ objeto B ][ objeto C ] | cursor | espacio libre

Fin de la fase: todos los punteros se descartan y el cursor vuelve a cero.
El respaldo puede conservarse para otra fase o desmapearse al destruir.

Es apropiada para datos temporales de una petición, análisis o compilación cuando sus referencias no escapan a la fase. No sirve sin cambios si un objeto debe sobrevivir al reinicio. En C, cerrar archivos y liberar recursos externos sigue siendo responsabilidad del programa; mover el cursor no ejecuta limpiezas por objeto.

9. Una región lineal en C

Este ejemplo reserva 64 KiB con mmap y redondea cada petición a la alineación de max_align_t. El estado vive fuera de los objetos. Reiniciar invalida lógicamente todas las reservas anteriores y no borra sus bytes ni devuelve RAM.

region_fase.c · objetos con una vida útil común
#define _DEFAULT_SOURCE
#include <errno.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>

#define REGION_BYTES (64UL * 1024)

struct Region {
    unsigned char *base;
    size_t usado;
    size_t capacidad;
};

static int region_init(struct Region *r)
{
    if (r->base != NULL) { errno = EBUSY; return -1; }
    void *p = mmap(NULL, REGION_BYTES, PROT_READ | PROT_WRITE,
                   MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (p == MAP_FAILED) return -1;
    if ((uintptr_t) p % _Alignof(max_align_t) != 0) {
        munmap(p, REGION_BYTES); errno = EINVAL; return -1;
    }
    r->base = p;
    r->usado = 0;
    r->capacidad = REGION_BYTES;
    return 0;
}

static void *region_pedir(struct Region *r, size_t n)
{
    if (n == 0) return NULL;
    if (r->base == NULL) { errno = EINVAL; return NULL; }
    size_t a = _Alignof(max_align_t);
    if (n > SIZE_MAX - (a - 1)) { errno = ENOMEM; return NULL; }
    size_t total = ((n + a - 1) / a) * a;
    if (total > r->capacidad - r->usado) { errno = ENOMEM; return NULL; }
    void *p = r->base + r->usado;
    r->usado += total;
    return p;
}

static void region_reset(struct Region *r)
{
    /* Precondición: ya no se utilizarán objetos de la fase anterior. */
    r->usado = 0;
}

static int region_destroy(struct Region *r)
{
    if (r->base == NULL) return 0;
    if (munmap(r->base, REGION_BYTES) == -1) return -1;
    *r = (struct Region) { 0 };
    return 0;
}

int main(void)
{
    struct Region r = { 0 };
    if (region_init(&r) == -1) { perror("region_init"); return 1; }
    int *indices = region_pedir(&r, 100 * sizeof *indices);
    double *valores = region_pedir(&r, 200 * sizeof *valores);
    char *nombre = region_pedir(&r, 1000);
    if (indices == NULL || valores == NULL || nombre == NULL) {
        perror("region_pedir"); region_destroy(&r); return 1;
    }
    for (int i = 0; i < 100; i++) indices[i] = i;
    for (int i = 0; i < 200; i++) valores[i] = i * 0.5;
    strcpy(nombre, "datos temporales de una fase");
    printf("%s; último índice %d; último valor %.1f; usado %zu B\n",
           nombre, indices[99], valores[199], r.usado);

    /* Fin de la fase: ninguna referencia anterior se volverá a usar. */
    indices = NULL; valores = NULL; nombre = NULL;
    region_reset(&r);
    int *nuevo = region_pedir(&r, sizeof *nuevo);
    if (nuevo == NULL) { perror("nueva fase"); region_destroy(&r); return 1; }
    *nuevo = 2026;
    printf("Nueva fase: %d; usado %zu B\n", *nuevo, r.usado);
    if (region_destroy(&r) == -1) { perror("region_destroy"); return 1; }
    return 0;
}
terminal · Linux con compilación C17
$ gcc -std=c17 -Wall -Wextra -Wpedantic -O2 region_fase.c -o region_fase
$ ./region_fase
datos temporales de una fase; último índice 99; último valor 99.5; usado ... B
Nueva fase: 2026; usado ... B

Las operaciones de reservar y reiniciar son O(1) con la región ya creada. La alineación desperdicia algunos bytes y un objeto descartado anticipadamente no se recupera de manera individual. El código controla desbordamiento y agotamiento, pero no admite varios hilos, crecimiento automático ni alineaciones extendidas.

La función destroy exige que no queden usuarios de los objetos, aunque no lleve un contador para verificarlo. No se debe pasar ningún puntero de esta región a free. Poner las tres variables locales en NULL ilustra el final de la fase, pero una aplicación real debe asegurarse de que no existan otras referencias escapadas.

10. jemalloc, TCMalloc y mimalloc

Los asignadores de propósito general combinan ideas de clases, regiones, cachés y devolución de memoria. No hay uno que garantice menos memoria y menor tiempo para todos los patrones.

AsignadorIdeas de su diseño documentadoQué conviene observar
glibcChunks, bins, tcache y arenas según versiónRetención por hilo, arenas y políticas de mapeo y recorte
jemallocClases de tamaño, slabs para objetos pequeños, arenas, cachés y políticas de purgaBytes asignados, activos, residentes y retenidos con su propia API
TCMalloc de GoogleCaché frontal por CPU o modo por hilo, transferencia y administración de spansModo de caché, clases, uso de spans y comportamiento bajo concurrencia
mimallocListas libres distribuidas por páginas del asignador y tratamiento de liberaciones locales o concurrentesLocalidad, unidades parcialmente ocupadas y política de purga

jemalloc ofrece controles y estadísticas mediante mallctl, además de informes como malloc_stats_print. Sus contabilidades de allocated, active, resident y retained tienen definiciones propias; algunas estadísticas necesitan refrescarse y pueden depender de cómo se compiló la biblioteca. No deben compararse por nombre con un campo de glibc sin leer la definición.

TCMalloc no debe interpretarse como «siempre una caché por hilo» solo por su nombre histórico. La implementación de Google documenta un modo por CPU y un modo por hilo. Tampoco debe confundirse automáticamente con la implementación de gperftools: aunque comparten antecedentes y nombres, son proyectos y versiones diferentes.

mimalloc distribuye listas por unidades del asignador para favorecer localidad y reducir contención, y contempla liberaciones desde otros hilos. Sus páginas internas y segmentos no deben equipararse sin más a las páginas de la MMU. La posibilidad y el momento de purgar respaldo dependen de su configuración y versión.

Todos mantienen el contrato de vida útil: el usuario no puede acceder después de liberar ni mezclar familias de asignación incompatibles. Un asignador con buen comportamiento en un benchmark puede retener más memoria o dar otras latencias en una aplicación con tamaños y duraciones diferentes.

11. Cómo comparar asignadores

El programa patron_heap usa malloc y free, sin llamar a mallinfo2 ni malloc_trim. Así se puede repetir el mismo trabajo con otra biblioteca que reemplace esa API, si la plataforma y la forma de enlace lo permiten.

terminal · localizar bibliotecas ya instaladas, sin instalar ni modificar el sistema
$ ldconfig -p | grep -E 'jemalloc|mimalloc|tcmalloc'
$ ./patron_heap disperso
$ LD_PRELOAD=/ruta/real/a/libjemalloc.so.2 ./patron_heap disperso
$ LD_PRELOAD=/ruta/real/a/libjemalloc.so.2 ./patron_heap agrupado

La ruta es un marcador: reemplázala por una biblioteca compatible que exista en tu entorno. La precarga es una técnica para ejecutables dinámicos que admitan esa interposición; no es una instrucción universal para integrar todo asignador. Revisa los mensajes del cargador y, si hace falta, los mapas o el diagnóstico específico para comprobar que el reemplazo se cargó.

No ejecutes el ejemplo de estadísticas de glibc del tema 47 y atribuyas automáticamente sus campos a una biblioteca precargada. Para diagnósticos internos utiliza la API del asignador efectivo; para una primera comparación, conserva contabilidad de la aplicación y métricas del sistema con el mismo significado.

  1. Definir el trabajo. Mantén tamaños, objetos vivos, orden y cantidad de operaciones comparables.
  2. Registrar el entorno. Biblioteca efectiva, versión, opciones, número de hilos y restricciones de memoria.
  3. Separar fases. Distingue crecimiento, régimen estable, liberación parcial, reposo y finalización.
  4. Separar instrumentación y tiempo. Un volcado, strace o lectura de proc puede cambiar los costos.
  5. Repetir. Alterna variantes en procesos nuevos y evalúa dispersión, no solo el mejor resultado.
  6. Comparar varias métricas. Memoria útil, pico y estado estable, tiempo y latencias relevantes.

El máximo de RSS de toda la ejecución no es el RSS actual de una fase: un pico no desciende después de free. Y una caída del RSS tras una purga puede trasladar trabajo a futuros accesos. Medir solo el momento posterior al recorte puede ocultar ese costo.

12. Simulador de huecos y slabs

El modelo dispone de 2048 bytes repartidos en ocho páginas didácticas de 256. Puedes elegir una lista variable, con alineación de 16 y fusión de vecinos, o slabs con clases de 32, 64, 128 y 256. Se omiten metadatos para aislar el redondeo y la ocupación parcial.

Simulador · misma vida útil, distinta organización

Cambiar estrategia reinicia el estado; cada demostración comienza desde cero.

Útil vivo LCapacidad viva CRelleno C − LBytes libresMayor petición admisible, hasta 256Páginas vacías
PáginaEstado o claseSlots u objetos vivos

Objetos vivos:

IDOffsetPedidoCapacidad

    La demostración pide 32 objetos de 64 bytes, libera 24 y conserva ocho. Con supervivientes dispersos queda uno en cada página; con los agrupados quedan las primeras dos páginas ocupadas y seis vacías. Al final intenta una petición de 256 bytes.

    En el caso disperso, esa petición falla en ambas estrategias: la lista variable tiene huecos de 192 bytes y los slabs conservan todas las páginas clasificadas para 64. Después pide manualmente 128: la lista puede atenderla; los slabs no tienen una página vacía ni un slot de esa clase. En ambos había 1536 bytes libres antes de esas peticiones.

    En el caso agrupado, la petición final de 256 sí puede atenderse. Cambia también el tamaño manual a 33 o 65 para observar diferencias de redondeo. La identidad de este modelo sin metadatos es L + relleno + bytes libres = 2048.

    Una página vacía vuelve al conjunto disponible del modelo; no se simula una llamada al núcleo ni una medición de RSS. En la lista variable un objeto puede atravesar páginas; en slabs un slot pertenece a una sola página y esta mantiene su clase mientras tenga objetos vivos. No se modelan cachés por hilo, migración, purga ni reglas internas de un asignador concreto.

    13. Elegir y evaluar una estrategia

    Patrón de la aplicaciónEstrategia que merece probarsePregunta de validación
    Objetos iguales, uso frecuente y máximo conocidoPool tipado¿Cuánta capacidad queda reservada sin utilizar y qué pasa al agotarse?
    Temporales que mueren juntosRegión por fase¿Escapa algún puntero a la siguiente fase?
    Vidas largas y cortas mezcladasSeparación por vida útil o por subsistema¿Disminuyen páginas parcialmente ocupadas sin reservar demasiadas regiones?
    Varios hilos y liberación cruzadaAsignador y configuración adecuados al patrón real¿Mejoran contención y retención con los mismos productores y consumidores?
    Demasiadas copias o estructuras sobredimensionadasRevisar la representación de datos¿El ahorro viene de pedir menos bytes antes de cambiar el asignador?

    Crear un pool para cada subsistema también puede multiplicar reservas vacías. Separar todo por hilos puede mejorar una ruta y dificultar reutilizar espacio entre ellos. Una región por fase elimina liberaciones individuales, pero retiene hasta el final los objetos que terminaron temprano. La decisión debe considerar el patrón completo.

    Antes de cambiar una implementación de propósito general, revisa si el crecimiento corresponde a objetos vivos necesarios, fuga, redondeo o retención. Mantén una prueba de corrección y mide también el costo que reaparece después de devolver memoria. Una reducción de RSS durante un instante no basta para afirmar que el sistema completo mejoró.

    Fuentes para contrastar mecanismos y métricas: el manual de jemalloc y su sitio del proyecto, el diseño de TCMalloc y su descripción de modos, la documentación de mimalloc, y los manuales de proc/PID/status, malloc_usable_size y malloc_trim.

    14. Resumen

    15. Preguntas de repaso

    Responde antes de abrir cada explicación.

    1. ¿Un RSS alto después de free demuestra una fuga?

    No. Puede haber retención, páginas parcialmente ocupadas, metadatos y otros consumos. Una fuga se relaciona con objetos que siguen asignados y cuya propiedad se perdió.

    2. ¿Qué diferencia hay entre relleno de la estructura y de la clase?

    El primero ya forma parte de sizeof del tipo C. El segundo aparece al ubicar la petición en una capacidad administrada mayor. Son capas distintas.

    3. ¿Por qué muchos slots libres no garantizan una página reutilizable para otra clase?

    Porque pueden estar distribuidos entre páginas que aún conservan objetos vivos de la clase original. Sin moverlos, esas páginas no se reclasifican en el modelo.

    4. ¿Qué ventaja obtiene un pool de objetos iguales?

    No necesita buscar ni dividir huecos variables. Puede retirar y devolver slots mediante una lista libre en O(1), con capacidad y tipo definidos.

    5. ¿Devolver un registro al pool estático libera RAM al sistema?

    No. Hace reutilizable ese slot. El área estática continúa existiendo durante la ejecución.

    6. ¿Reiniciar una región ejecuta la limpieza de cada objeto?

    No. Solo restablece el cursor en este diseño. Los recursos externos y la ausencia de referencias escapadas son responsabilidad de la aplicación.

    7. ¿La arena por fase es una arena de glibc?

    No. La primera es una estrategia de vida útil definida por la aplicación. La segunda es una estructura interna de un asignador de propósito general.

    8. ¿TCMalloc siempre usa una caché por hilo?

    No. La implementación de Google documenta modos por CPU y por hilo. La versión, entorno y proyecto efectivo deben identificarse.

    9. ¿Se pueden comparar directamente campos con nombres semejantes de dos asignadores?

    No. Hay que revisar sus definiciones y ámbitos. Bytes activos, residentes, retenidos y solicitados pueden representar conjuntos diferentes.

    10. ¿El máximo de RSS permite conocer la memoria actual después de liberar?

    No. Conserva el pico de la ejecución. Para estudiar fases necesitas medidas actuales, interpretando sus límites.

    11. ¿Por qué el experimento de patrones no garantiza una ventaja del modo agrupado?

    Porque la ubicación real no viene determinada solo por el orden lógico de peticiones. La biblioteca, su estado, las cachés y otras reservas influyen.

    12. ¿Cambiar de asignador elimina la necesidad de comprobar límites y propiedad?

    No. El contrato de malloc y free sigue exigiendo tamaños válidos, una única liberación y ausencia de accesos posteriores o concurrentes incompatibles.

    Para practicar

    1. Calcula L, C y el exceso relativo para 2000 objetos de 65 bytes en una clase ilustrativa de 80. Explica el denominador utilizado.
    2. Ejecuta los dos patrones de patron_heap en procesos nuevos. Comprueba que la cuenta útil coincide y registra diferencias de RSS por fase sin atribuirles una causa única.
    3. Agrega una fase de trabajo repetida sobre los supervivientes. Evalúa si el consumo vuelve a crecer antes de concluir que un recorte mejoró la carga.
    4. En el pool, verifica que los objetos vivos siempre ocupen celdas distintas y que la lista recupere las 128 celdas tras liberar todo.
    5. Amplía la región lineal con comprobaciones de petición cero, SIZE_MAX y agotamiento sin modificar el cursor en un fallo.
    6. En el simulador, ejecuta supervivientes dispersos con ambas estrategias. Después intenta 128 bytes y explica los resultados.
    7. Compara el mismo patrón con supervivientes agrupados. Cuenta páginas vacías y distingue capacidad interna de devolución al núcleo.
    8. Prueba peticiones de 33 y 65 bytes y calcula el relleno con cada estrategia del modelo.
    9. Si tienes otra biblioteca instalada, repite patron_heap con ella y registra versión, forma de integración y métricas comparables. No uses contabilidad de glibc como si describiera automáticamente al reemplazo.