Intrusive Lists: el truco de Nginx y TCMalloc para eliminar overhead

Las listas enlazadas tradicionales añaden un nodo por fuera del objeto que queremos encadenar. Eso implica punteros extra, saltos de caché y, sobre todo, memoria desperdiciada. La solución que usan sistemas como Nginx o TCMalloc es mucho más agresiva: incrustar el puntero next dentro del propio objeto. De ahí el nombre intrusive: la estructura "invade" la memoria del dato.

¿Cómo se ve en código?

// Puntero next almacenado en los primeros 8 bytes del bloque
static inline void*& siguiente(void* bloque) noexcept {
    return *reinterpret_cast<void**>(bloque);
}

void* a = malloc(1024);
void* b = malloc(1024);

siguiente(a) = b;   // a → b
siguiente(b) = nullptr;

Con esta única línea hemos convertido cualquier región de memoria en un nodo de lista sin coste adicional.

Comparativa rápida

Enfoque Bytes extra por nodo Asignaciones Localidad
Lista clásica 16 (puntero data + puntero next) 2 (dato + nodo) Baja
Intrusiva 0 1 (solo el dato) Alta

Una FreeList mínima

class FreeList {
public:
    void liberar(void* p) noexcept {
        siguiente(p) = cabeza_;
        cabeza_       = p;
        ++cnt_;
    }

    void* adquirir() noexcept {
        if (!cabeza_) return nullptr;
        void* p = cabeza_;
        cabeza_ = siguiente(p);
        --cnt_;
        return p;
    }

    // Inserción masiva: O(1)
    void liberar_rango(void* primero, void* ultimo, std::size_t n) noexcept {
        siguiente(ultimo) = cabeza_;
        cabeza_           = primero;
        cnt_             += n;
    }

private:
    void* cabeza_ = nullptr;
    std::size_t cnt_ = 0;
};

Por qué escala tan bien

  1. Localidad temporal y espacial: el puntero next y el objeto residen en la misma línea de caché.
  2. Menor presión sobre el allocator: solo se pide memoria para el objeto real.
  3. Operaciones vectorizables: rangos completos se mueven con dos escrituras (siguiente(ultimo) y cabeza_).

Ejemplo real: ThreadCache simplificado

void* ThreadCache::asignar(std::size_t bytes) {
    const std::size_t idx = indice(bytes);
    if (!listas_[idx].vacia())
        return listas_[idx].adquirir();

    // CentralCache nos entrega un lote completo
    return obtener_desde_central(idx);
}

void ThreadCache::devolver(void* p, std::size_t bytes) {
    const std::size_t idx = indice(bytes);
    listas_[idx].liberar(p);

    if (listas_[idx].tamano() >= listas_[idx].maximo())
        regresar_a_central(idx);   // liberar_rango evita locks
}

Consejos de implementación

  • Asegura que sizeof(T) ≥ sizeof(void*) y alineación correcta.
  • Para depurar, reemplaza siguiente por un campo nombrado durante las compilaciones Debug.
  • Si el objeto se destruye, elimínalo de la lista antes; de lo contrario quedará un puntero colgando.
  • En entornos multihilo, protege la cabeza con un std::atomic<void*> o haz pop local por hilo.

Usos más allá del memory pool

// Pool de balas en un motor de juegos
class BulletPool {
    FreeList libres;
public:
    Bullet* disparar()  { return static_cast<Bullet*>(libres.adquirir()); }
    void reciclar(Bullet* b) { libres.liberar(b); }
};

// Cola de eventos sin nodos extra
class EventQueue {
    FreeList pendientes;
public:
    void encolar(Event* e) { pendientes.liberar(e); }
    Event* desencolar()    { return static_cast<Event*>(pendientes.adquirir()); }
};

Con este patrón, cualquier estructura que necesites gestionar de forma ligera (páginas, sockets, tareas) puede beneficiarse de listas intrusivas: cero overhead, máxima velocidad y un diseño que escala hasta el núcleo del sistema operativo.

Etiquetas: intrusive-list memory-pool tcmalloc Nginx freelist

Publicado el 9-6 18:26