Intrusive Linked Lists

Eliminate node wrappers and heap allocations entirely. Learn how the Linux kernel uses intrusive structures to weave physical memory into multiple logical lists simultaneously.

Ryan McCombe
Published

In our exploration of linked data structures, we have treated the "node" as an unavoidable necessity. Whether we allocate it on the global heap using std::list(), pack it into a custom std::vector() using indices, or clump elements together in an unrolled list, the fundamental architecture has remained the same.

The data structure owns the data. It wraps our payload inside a structural envelope.

But what if we don't want the data structure to own our objects? In highly optimized environments, objects are usually pre-allocated in massive slabs of memory, like the we built earlier.

When an object already exists in physical memory, and we want it to be included in a collection, wrapping it inside a separate logical "node" introduces a severe performance bottleneck. In this lesson, we will explore strategies that eliminate the wrapper.

The Wrapper Problem

Let's assume we have a Player struct that is heavily managed by a custom memory allocator. We want to keep track of all active players by putting them into a linked list.

If we use a standard non-intrusive container like std::list<Player>, the list will allocate a brand new node on the heap and copy our player data into it:

struct Player {
  int ID;
  float Health;
};

// ...

Player p{1, 100.0f};

// The list allocates a new node and copies 'p' into it
ActivePlayers.push_back(p);

We now have two copies of the player, which is rarely what we want. To solve this, developers often instinctively switch to a list of pointers: std::list<Player*>. This allows the list to reference our original object, without copying it:

Player* p = PlayerPool.Spawn(1, 100.0f);

// The list allocates a new node, and stores the pointer
ActivePointerPlayers.push_back(p);

This is a slightly better design, but it creates a disaster for the CPU's cache prefetcher: double pointer chasing. When we iterate through a std::list<Player*>, the CPU must now perform two distinct trips to main RAM for every single element:

  1. Follow the Next pointer to find the physical memory address of the list node. (Cache Miss).
  2. Read the Player* payload from that node, and follow that pointer to find the physical memory address of the actual player data. (Cache Miss).

We are paying the memory latency penalty twice. Furthermore, we have added complexity - we now need to manage and optimize the memory allocation of both the Player objects and the Node objects that link to them.

Inverting the Relationship

If wrapping the data inside the node is slow, we can simply invert the relationship: we can embed the node inside the data. An intrusive data structure dictates that the payload itself must provide the structural links. Instead of the list providing the Prev and Next pointers, our Player struct will physically hold them, just like it would hold any other variable.

First, we'll define a completely generic ListHook. This is nothing but a pair of links - it contains no payload data whatsoever:

dsa_core/include/dsa/IntrusiveList.h

#pragma once

struct ListHook {
  ListHook* Prev{nullptr};
  ListHook* Next{nullptr};
};

To keep things simple and focused on the new topics, we're representing our links as full 64-bit pointers to the global heap in this lesson. However, intrusive lists can be combined with previous techniques for even more optimization, such as storing the players in an and using rather than pointers.

Once we have our ListHook available, we intrude upon our Player struct. We add a ListHook directly into the physical memory layout of our entity:

dsa_app/main.cpp

#include <dsa/IntrusiveList.h>

struct Player {
  int ID;
  float Health;

  // We embed the linkage directly into the data
  ListHook Hook; 
};

By doing this, we have permanently fused the structural metadata with the payload. When the CPU fetches the ListHook to find the next element in the chain, the Player data (ID and Health) is sitting in the same 64-byte physical cache line.

Double indirection is eliminated - when we have the hook, we have the data.

Using offsetof

We now face a complex problem. We want to build a generic IntrusiveList container that works for any data type. Our linked list logic is only going to wire ListHook pointers together - it won't know what a Player is.

When we iterate through our list, we will have a pointer to a ListHook. But the application developer doesn't care about the hook; they want a pointer to their Player object. How do we convert a ListHook* back into the Player* that holds that hook?

We cannot use a standard static_cast or dynamic_cast, because Player does not inherit from ListHook. ListHook is just a raw variable sitting somewhere inside the physical memory of Player.

Fortunately, when the compiler builds our Player struct, it assigns a strict, unchangeable physical byte offset to every member.

struct Player {
  int ID;          // Offset 0 (Size: 4 bytes)
  float Health;    // Offset 4 (Size: 4 bytes)
  ListHook Hook;   // Offset 8 (Size: 16 bytes)
};

If we have a pointer to Hook, and we know that Hook always sits exactly 8 bytes away from the start of the Player struct, we can simply subtract 8 bytes from the hook's memory address. The resulting memory address is guaranteed to be the start of the Player object that owns that Hook.

Fortunately, we don't need to calculate this offset for every type we want a linked list to intrude upon. The C standard library provides the helpful offsetof(type, member) macro for this. We provide the name of the type (Player) and the name of the member within that type (Hook) and it calculates the byte offset for us at compile time.

We'll use offsetof soon but, for now, let's write a generic helper function that takes a ListHook pointer and the calculated offset to return a pointer to the object that holds that ListHook.

Our offset is measured in bytes, so we want our pointer arithmetic to also use bytes. To do this, we'll reinterpret the hook pointer to a raw std::byte pointer, subtract the provided offset, and cast the resulting physical address into our desired T*:

dsa_core/include/dsa/IntrusiveList.h

#pragma once
#include <cstddef>  // for std::byte, size_t, offsetof

struct ListHook {
  ListHook* Prev{nullptr};
  ListHook* Next{nullptr};
};

// Convert a Hook pointer back into the Parent object pointer
template <typename T>
T* GetParent(ListHook* Hook, size_t Offset) {
  if (!Hook) return nullptr;

  // Treat the memory address as raw bytes
  std::byte* RawAddress = reinterpret_cast<std::byte*>(Hook);

  // Slide the pointer backward by the calculated offset
  std::byte* ParentAddress = RawAddress - Offset; 

  // Cast the raw memory back into the typed object
  return reinterpret_cast<T*>(ParentAddress); 
}

Building the Container

We can now build our IntrusiveList container. Our list needs to know two things at compile time: the type of object it is storing (T), and the physical offset of the ListHook within that type.

Requiring consumers to provide low-level details about their memory layout isn't ideal. We'd like to calculate this ourselves, but determining where the ListHook is within their type, especially when we don't even know what they've called it, is quite difficult. Additionally, in the next section, we'll allow objects to be in multiple lists, meaning there can be multiple ListHook members within T.

Future C++ capabilities, particularly static reflection, will allow us to improve this API. But, for now, the simplest implementation is to ask consumers to use the offsetof macro themselves and provide the result to HookOffset:

dsa_core/include/dsa/IntrusiveList.h

// ...

// T is the payload type
// HookOffset is the physical byte offset of the ListHook within T
template <typename T, size_t HookOffset>
class IntrusiveList {
private:
  ListHook* Head{nullptr};

  // Internal helper to extract the hook from a given object
  ListHook* ExtractHook(T* Item) const {
    std::byte* RawAddress = reinterpret_cast<std::byte*>(Item);
    return reinterpret_cast<ListHook*>(RawAddress + HookOffset);
  }

public:
  IntrusiveList() = default;
};
// Example Usage
#include <dsa/IntrusiveList.h>

struct Player {
  int ID;
  float Health;
  ListHook Hook;
};

int main() {
  using PlayerList = IntrusiveList<Player, offsetof(Player, Hook)>;
  PlayerList Players;
}

Implementing Insertions

Let's implement PushFront(). In a standard list, this method allocates a new node. In our intrusive list, the application developer already created the object. They simply hand us a pointer to it.

Our list calculates where the hook lives inside that object, and wires the Prev and Next pointers to attach it to the front of the chain:

dsa_core/include/dsa/IntrusiveList.h

// ...

template <typename T, size_t HookOffset>
class IntrusiveList {
  // ...

public:
  // ...

  void PushFront(T* Item) {
    if (!Item) return;

    // Find the hook inside the provided item
    ListHook* ItemHook = ExtractHook(Item);

    // Wire the new hook to point to the current Head
    ItemHook->Next = Head;
    ItemHook->Prev = nullptr;

    // If the list wasn't empty, wire the old Head backward
    if (Head) {
      Head->Prev = ItemHook;
    }

    // Update the Head pointer
    Head = ItemHook;
  }
};

We performed four simple pointer assignments. There is no call to new. There is no hidden chunk header metadata being created. There is no search through an implicit free list. Because no memory is allocated, this function can never fail or throw an exception. It executes deterministically in roughly 2 CPU cycles.

Deleting Elements

To remove an element, we disconnect the hook from the chain, bridging the gap between its Prev and Next neighbors:

dsa_core/include/dsa/IntrusiveList.h

// ...

template <typename T, size_t HookOffset>
class IntrusiveList {
  // ...

public:
  // ...

  void Remove(T* Item) {
    if (!Item) return;

    ListHook* ItemHook = ExtractHook(Item);

    // Disconnect the Previous node
    if (ItemHook->Prev) {
      ItemHook->Prev->Next = ItemHook->Next;
    } else {
      // If there is no Prev, we were the Head. Update Head.
      Head = ItemHook->Next;
    }

    // Disconnect the Next node
    if (ItemHook->Next) {
      ItemHook->Next->Prev = ItemHook->Prev; 
    }

    // Clear our own links for safety
    ItemHook->Prev = nullptr;
    ItemHook->Next = nullptr;
  }
};

Notice what Remove() does not do. It does not call delete.

An intrusive list does not own the memory. When we remove a Player from the list, we are simply untying the knot that held it in the chain. The physical Player object still exists wherever the application developer originally placed it (e.g., in a pre-allocated object pool). The responsibility for destroying the payload remains entirely with the user.

Traversal

To traverse the list, we walk the chain of ListHook pointers. For each hook, we use our GetParent() offset math to reconstruct the original T* object pointer, and pass it to the user's callback. Because the hook and the payload share the same memory address, reading Item->ID does not trigger a secondary cache miss:

dsa_core/include/dsa/IntrusiveList.h

// ...

template <typename T, size_t HookOffset>
class IntrusiveList {
  // ...

public:
  // ...

  template <typename Func>
  void Traverse(Func Callback) const {
    ListHook* Current = Head;

    while (Current != nullptr) {
      // Recover the typed object pointer from the raw hook
      T* Item = GetParent<T>(Current, HookOffset); 

      Callback(Item);

      Current = Current->Next;
    }
  }
};

Multiple Memberships

We have successfully eliminated the wrapper node. But intrusive structures have another interesting architectural advantage.

Imagine we are building a game engine. We have an Entity struct. Every frame, the physics engine needs to iterate over all active physics bodies. Every frame, the renderer needs to iterate over all visible meshes.

An Entity needs to exist in both the PhysicsList and the RenderList at the same time.

If we use std::list, we must maintain two completely separate collections of Entity* pointers. That means two allocations per entity, two separate chains of wrapper nodes, and twice the amount of pointer-chasing latency.

With an intrusive design, we simply embed two ListHook variables directly into the physical memory layout of the Entity:

struct Entity {
  int ID;

  // Data for the Physics system
  float Velocity;
  ListHook PhysicsHook; 

  // Data for the Render system
  bool IsVisible;
  ListHook RenderHook; 
};

We now have two distinct structural connectors living inside the same block of memory. We can thread this single physical object through two completely independent logical lists.

Let's see this in action. We use offsetof to configure two distinct IntrusiveList types. One list is threaded through the PhysicsHooks of our objects, and the other is threaded through the RenderHooks:

dsa_app/main.cpp

#include <iostream>
#include <dsa/IntrusiveList.h>

struct Entity {
  int ID;
  ListHook PhysicsHook;
  ListHook RenderHook;
};

int main() {
  // Configure the Physics list using the PhysicsHook offset
  using PhysicsList = IntrusiveList<
    Entity,
    offsetof(Entity, PhysicsHook) 
  >;

  // Configure the Render list using the RenderHook offset
  using RenderList = IntrusiveList<
    Entity,
    offsetof(Entity, RenderHook) 
  >;

  PhysicsList ActivePhysics;
  RenderList ActiveRender;

  // Assume these entities live in a pre-allocated array or pool
  Entity E1{1};
  Entity E2{2};

  // E1 participates in both systems
  ActivePhysics.PushFront(&E1);
  ActiveRender.PushFront(&E1);

  // E2 only participates in physics (e.g., an invisible wall)
  ActivePhysics.PushFront(&E2);

  std::cout << "Physics Entities: ";
  ActivePhysics.Traverse([](Entity* e) {
    std::cout << e->ID << " ";
  });

  std::cout << "\nRender Entities: ";
  ActiveRender.Traverse([](Entity* e) {
    std::cout << e->ID << " ";
  });

  return 0;
}
Physics Entities: 2 1
Render Entities: 1

By adding a mere 16 bytes of data (a second hook) to our object, we can participate in an infinite number of highly optimized systems without making a single allocation. The physical Entity never moves, and we never chase a secondary pointer to find the payload.

The Lifecycle Trade-off

This power comes with a strict responsibility. Because an intrusive list does not own the objects it links together, the application architecture must guarantee that an object is removed from all intrusive lists before its memory is destroyed or recycled.

If E1 is destroyed or its memory pool slot is overwritten while the PhysicsList still holds a pointer to E1.PhysicsHook, the application has a dangling pointer. The next time the physics system traverses the list, it will read garbage memory.

Benchmarking

Let's benchmark our IntrusiveList against the standard library's std::list<Entity*>. We will pre-allocate 100,000 entities in a std::vector to simulate an object pool. We will first measure how long it takes to create the lists containing all of these objects:

benchmarks/main.cpp

#include <benchmark/benchmark.h>
#include <list>
#include <vector>
#include <dsa/IntrusiveList.h>

struct TestEntity {
  int Value;
  ListHook Hook;
};

static void BM_StdListPointers_Insert(benchmark::State& state) {
  std::vector<TestEntity> Pool(100000);
  for (int i = 0; i < 100000; ++i) Pool[i].Value = 1;

  for (auto _ : state) {
    std::list<TestEntity*> L;
    for (int i = 0; i < 100000; ++i) {
      // Allocates a node wrapper for every single pointer
      L.push_front(&Pool[i]); 
    }
  }
}

static void BM_Intrusive_Insert(benchmark::State& state) {
  std::vector<TestEntity> Pool(100000);
  for (int i = 0; i < 100000; ++i) Pool[i].Value = 1;

  for (auto _ : state) {
    IntrusiveList<TestEntity, offsetof(TestEntity, Hook)> L;
    for (int i = 0; i < 100000; ++i) {
      // Zero allocations. Pure pointer wiring.
      L.PushFront(&Pool[i]); 
    }
  }
}

#define BENCHMARK_MS(func) \
  BENCHMARK(func)->Unit(benchmark::kMillisecond)

BENCHMARK_MS(BM_StdListPointers_Insert);
BENCHMARK_MS(BM_Intrusive_Insert);
-----------------------------------
Benchmark                       CPU
-----------------------------------
BM_StdListPointers_Insert   5.16 ms
BM_Intrusive_Insert        0.075 ms

The std::list is suffocating under the weight of creating 100,000 wrapper nodes, while the intrusive list simply updates pointers that already exist within each TestEntity.

Next, once we've created the lists, let's benchmark how long it takes to traverse through them:

benchmarks/main.cpp

// ...

static void BM_StdListPointers_Traverse(benchmark::State& state) {
  std::vector<TestEntity> Pool(100000);
  std::list<TestEntity*> L;
  for (int i = 0; i < 100000; ++i) {
    Pool[i].Value = 1;
    L.push_front(&Pool[i]);
  }

  for (auto _ : state) {
    int sum = 0;
    // Pointer chase 1 - follow the link to get the next node
    for (TestEntity* e : L) {
      // Pointer chase 2, follow the link from node to object
      sum += e->Value; 
    }
    benchmark::DoNotOptimize(sum);
  }
}
BENCHMARK(BM_StdListPointers_Traverse);

static void BM_Intrusive_Traverse(benchmark::State& state) {
  std::vector<TestEntity> Pool(100000);
  IntrusiveList<TestEntity, offsetof(TestEntity, Hook)> L;
  for (int i = 0; i < 100000; ++i) {
    Pool[i].Value = 1;
    L.PushFront(&Pool[i]);
  }

  for (auto _ : state) {
    int sum = 0;
    // Pointer chase 1 - follow the link to get the next Hook
    L.Traverse([&](TestEntity* e){
      // No more chasing - Hook and Value are in the same cache line
      sum += e->Value; 
    });
    benchmark::DoNotOptimize(sum);
  }
}
BENCHMARK(BM_Intrusive_Traverse);
--------------------------------------
Benchmark                          CPU
--------------------------------------
BM_StdListPointers_Traverse   0.215 ms
BM_Intrusive_Traverse         0.100 ms

While both structures suffer from the erratic pointer chasing of a linked list, the intrusive variation only misses the cache once per element, whilst std::list<TestEntity*> misses twice. It is forced to fetch the wrapper node from the heap, and then fetch the payload from the vector pool, doubling the pointer-chasing inefficiency.

Complete Code

Here is the complete implementation of our zero-allocation IntrusiveList:

Files

dsa_core
dsa_app
Select a file to view its content

Advanced: Hook Positioning

Remember that when the CPU fetches memory, it doesn't just pull the 8 bytes of our ListHook. It pulls an entire 64-byte cache line from main RAM. We can exploit this by being strategic about where we place our hooks within the struct.

If some system is iterating through the PhysicsHook chain, it's likely to be interested in physics-related variables. Therefore, those variables should sit directly adjacent to the hook in the struct definition. When the CPU fetches the hook, the payload data comes along for the ride in the same cache line, eliminating memory stalls:

struct Player {
  int ID;

  // Grouped for a single cache-line fetch
  float VelocityX;
  float VelocityY;
  ListHook PhysicsHook; 

  // Grouped for the renderer
  bool IsVisible;
  int MeshID;
  ListHook RenderHook; 
};

If there is no obvious spatial grouping we can apply, placing the hook at the very top of the type has some benefits.

If the hook is the very first member, it has an offset of 0, meaning its physical memory address is the same as the object's address. This removes the need for offsetof and associated pointer arithmetic, which simplifies the code and saves CPU cycles.

struct Player {
  // Offset 0: The hook and the player share the same address
  ListHook Hook; 

  int ID;
  float Health;
};

Summary

In this lesson, we tackled the challenges of data structures that do not own the objects they organize.

  1. Standard containers like std::list wrap data in nodes. Storing pointers (std::list<T*>) avoids payload copying but causes devastating double indirection and cache thrashing.
  2. An intrusive data structure embeds the node metadata (ListHook) directly inside the payload struct.
  3. We used the offsetof macro to calculate the physical distance between the embedded hook and the start of the object, allowing us to convert a ListHook* back into a typed object pointer.
  4. Because the structure only rewires pre-existing physical memory, insertions are guaranteed zero-allocation and O(1)O(1).
  5. By embedding multiple hooks, a single physical object can exist in multiple logical lists simultaneously with minimal performance overhead.

We have now explored contiguous chunks, index-based pools, unrolled fat nodes, and intrusive structures. All of these techniques optimize the physical layout of linear sequences.

In the next lesson, we will push linked architecture beyond flat chains. We will trade some of our memory capacity to build algorithmic "express lanes" and introduce the probabilistic linking of skip lists.

Next Lesson
Lesson 53 of 53

Skip Lists and Probabilistic Linking

Trade memory capacity for faster search speeds. Learn how probabilistic express lanes create highly concurrent, self-balancing data structures.

Have a question about this lesson?
Answers are generated by AI models and may not be accurate