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.
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:
- Follow the
Nextpointer to find the physical memory address of the list node. (Cache Miss). - 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: 1By 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 msThe 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 msWhile 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
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.
- Standard containers like
std::listwrap data in nodes. Storing pointers (std::list<T*>) avoids payload copying but causes devastating double indirection and cache thrashing. - An intrusive data structure embeds the node metadata (
ListHook) directly inside the payload struct. - We used the
offsetofmacro to calculate the physical distance between the embedded hook and the start of the object, allowing us to convert aListHook*back into a typed object pointer. - Because the structure only rewires pre-existing physical memory, insertions are guaranteed zero-allocation and .
- 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.
Skip Lists and Probabilistic Linking
Trade memory capacity for faster search speeds. Learn how probabilistic express lanes create highly concurrent, self-balancing data structures.