DSA DSA In Depth
0%
lesson linked-listsintrusive-listslinux-kernel

Singly, Doubly & Intrusive Linked Lists (Linux Kernel list_head)

7 min read Module 1.3: Linked Structures, Monotonic Stacks/Queues & Cache-Conscious Lists Updated Oct 23, 2026
📄 Cheat Sheet
Intrusive Circular Doubly Linked Lists (Linux Kernel list_head Architecture)
Codotaku Intrusive Circular Doubly Linked Lists (Linux Kernel list_head Architecture) Watch on YouTube ↗

Deep-dive lecture covering Singly, Doubly & Intrusive Linked Lists (Linux Kernel list_head).

0:00 / --:--

Pointer-to-Pointer (Node**) & Circular Sentinel Elimination

In a naive singly or doubly linked list, every insertion or deletion checks a special-case branch: if (prev == nullptr) head = curr->next; else prev->next = curr->next;. As Linus Torvalds famously demonstrated, there are two complementary techniques that completely eliminate special-case boundary branches:

  1. Indirect Pointer-to-Pointer (Node** pp = &head) for Singly Linked Lists: Instead of tracking a predecessor node pointer Node* prev, track the address of the pointer field that points to the current node: Node** indirect = &head. Advancing moves to indirect = &((*indirect)->next), and unlinking the target node executes *indirect = (*indirect)->next uniformly—whether the node is the head of the list or in the middle!
  2. Circular Dummy Sentinel Node for Doubly Linked Lists: By maintaining a permanent sentinel node sentinel initialized to point to itself (sentinel.next = sentinel.prev = &sentinel), the list is never physically empty. Every real node sits strictly between two valid Node structs (sentinel.next is front(), sentinel.prev is back()), reducing insert_between(prev, next, node) and unlink(node) to 4 and 2 unconditional pointer stores respectively.
Systems Invariant: Intrusive list_head & The container_of Offset Identity

In a non-intrusive list (std::list<Task>), inserting a Task allocates a separate heap node containing {prev, next, Task}, and removing a Task* given only its address is $O(N)$ unless you also store a std::list::iterator alongside it. In the Linux kernel's intrusive design, the 16-byte hook struct list_head { list_head *next, *prev; } is embedded directly as a member inside struct Task:

$$\text{addr}(\text{Task}) = \text{addr}(\text{member\_ptr}) - \text{offsetof}(\text{Task},\; \text{member})$$

Because the byte offset of a struct field is a compile-time constant known to the compiler, container_of(ptr, Task, run_list) subtracts that constant integer offset from the list_head* address—recovering the enclosing Task* in zero memory dereferences! Even better, a single Task struct can embed multiple list_head fields (run_queue, all_tasks, wait_list), participating in three doubly linked lists simultaneously with zero heap allocations.

Non-Intrusive vs. Intrusive vs. Arena-Indexed Linked Lists

List Architecture Heap Allocations per Insert Unlink Given T* Pointer Multi-List Membership Cache / Memory Overhead
Non-Intrusive (std::list<T>) 1 malloc per node $O(N)$ (unless iterator stored) Requires extra wrapper nodes + pointer indirection 16B links + 16B allocator header per node
Intrusive (Linux list_head / Boost.Intrusive) 0 (embedded in T) $O(1)$ direct unlink Embed $k$ list_head fields in single T 16B per embedded hook, 0 allocator headers
32-Bit Index Pool (int32_t next, prev) 0 (contiguous flat vector) $O(1)$ by index Multiple index pairs 8B links ($50\%$ pointer savings), high L1/TLB locality

Visualizing Non-Intrusive Heap Scattering vs. Intrusive container_of

Intrusive Struct Layout: One Allocation Participates in Multiple Lists Simultaneously 0x1000: struct task_struct { uint64_t pid; // +0B uint64_t vruntime; // +8B list_head run_node; // +16B (ptr) list_head wait_node;// +32B 0x2080: struct task_struct { uint64_t pid; // +0B uint64_t vruntime; // +8B list_head run_node; // +16B (ptr) list_head wait_node;// +32B <-- next / prev --> container_of(ptr, task_struct, run_node) = (task_struct*)((char*)ptr - 16) --> 0x1000 (1 ALU sub, 0 loads!)

Circular Sentinel Doubly Linked List Splice & Unlink

flowchart LR A["Sentinel Head
(head.next = first,
head.prev = last)"] <--> B["Node 1
list_head"] B <--> C["Node 2
list_head"] C <--> A
Hardware Reality: Dependent Load Chains & Out-of-Order Execution Paralysis

Why is traversing a linked list of $N$ nodes across the heap $20\times$ to $50\times$ slower than scanning an array of $N$ elements? On a modern out-of-order CPU (with a 512-entry Reorder Buffer and 12 execution ports), an array loop computes address base + i * sizeof(T) independently for iterations $i, i+1, \dots, i+15$, issuing up to 10–12 L1/L2 memory loads in parallel (Memory-Level Parallelism, MLP). In a linked list, the address of node $k+1$ (curr = curr->next) is stored inside the payload of node $k$. The CPU cannot even know the virtual address of node $k+1$ until the load for node $k$ returns from L3/DRAM (50–250 cycles later), serializing execution to a strict MLP $= 1$ pointer-chasing stall!

Floyd Tortoise-and-Hare Cycle Detection & Pointer Reversal
O(N) Time · O(1) Space

Visualize Phase 1 (Tortoise 1× vs Hare 2× meeting inside cycle of length λ) and Phase 2 (resetting one pointer to head to locate cycle entry μ via x ≡ −c (mod λ)).

Step 1 / 8
Custom Input:
Timeline

Invariant & State

Phase 1 (Cycle Detection): dist(fast) = 2·dist(slow) = 2 vs 1
🔗 Live Pointer & State Transition Graph — Floyd Tortoise-and-Hare Cycle Detection & Pointer Reversal Active Pointer Jump/Transition Matched/Optimal
11 [0] 🐢 slow 22 [1] 🐇 fast 33 [2] μ=3 (en 44 [3] 55 [4] 66 [5] 77 [6] 88 [7]
[0]11
[1]22🐢 slow
[2]33🐇 fast
[3]44μ=3 (entry)
[4]55
[5]66
[6]77
[7]88
Tail: [0]→[1]→[2]→[3] | Cycle (λ=5): [3]→[4]→[5]→[6]→[7]↩[3]
Phase
Phase 1 Chase
Tortoise (slow)
[1] (val 22)
Hare (fast)
[2] (val 33)
Steps
1
[Step 1] Tortoise moved to [1], Hare jumped 2 steps to [2].

Production Implementation: Intrusive Circular Doubly Linked List & Torvalds Indirect Unlink

#include <cstddef>
#include <cstdint>

// Linux-kernel style intrusive circular doubly-linked list hook
struct IntrusiveListNode {
  IntrusiveListNode* next = this;
  IntrusiveListNode* prev = this;

  void init() noexcept { next = prev = this; }
  bool empty() const noexcept { return next == this; }

  static void link_between(IntrusiveListNode* prev_node,
                           IntrusiveListNode* next_node,
                           IntrusiveListNode* new_node) noexcept {
    new_node->next = next_node;
    new_node->prev = prev_node;
    next_node->prev = new_node;
    prev_node->next = new_node;
  }

  // Insert new_node right before this (if this is sentinel head, this is push_back!)
  void push_back(IntrusiveListNode* new_node) noexcept {
    link_between(this->prev, this, new_node);
  }

  // Insert new_node right after this (if this is sentinel head, this is push_front!)
  void push_front(IntrusiveListNode* new_node) noexcept {
    link_between(this, this->next, new_node);
  }

  // Branchless O(1) self-unlink!
  void unlink() noexcept {
    next->prev = prev;
    prev->next = next;
    next = prev = this;
  }
};

// Compile-time offset arithmetic (C++ equivalent of Linux kernel container_of)
#define CONTAINER_OF(ptr, Type, member)   reinterpret_cast<Type*>(reinterpret_cast<char*>(ptr) - offsetof(Type, member))

// Linus Torvalds's branchless pointer-to-pointer singly linked list removal
struct SinglyNode {
  int val;
  SinglyNode* next;
};

void remove_first_matching(SinglyNode** head_ref, int target) noexcept {
  SinglyNode** indirect = head_ref;
  while (*indirect != nullptr) {
    if ((*indirect)->val == target) {
      *indirect = (*indirect)->next; // Works identically for head node and interior nodes!
      return;
    }
    indirect = &((*indirect)->next);
  }
}
Production Pitfall: Mutating Links While Iterating Without Saving next (list_for_each_safe)

When iterating over an intrusive or manual linked list and unlinking or freeing nodes inside the loop body, writing for (auto* pos = head.next; pos != &head; pos = pos->next) { pos->unlink(); } causes an immediate infinite loop or Use-After-Free because pos->unlink() overwrites pos->next = pos (or free(pos) poisons pos->next) before the loop increment expression reads pos->next! Always use the Linux kernel's list_for_each_safe(pos, tmp, head) idiom: cache auto* next_node = pos->next; at the very top of the loop body before mutating or freeing pos.

Check yourself Question 1 of 4
Concept Check Intrusive List Container Recovery recall

In the Linux kernel's intrusive doubly linked list (`struct list_head`), how does the `container_of(ptr, type, member)` macro recover the address of the enclosing struct from a pointer to the embedded list node?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

Analyze the invariant, asymptotic bound, and hardware behavior before selecting.

Why must `list_del_rcu` preserve the deleted node's `next` pointer until the RCU grace period expires?

Key Takeaways

Intrusive linked lists (like Linux's struct list_head) embed link pointers directly inside the domain object, eliminating per-node heap allocations and enabling O(1) unlinking from an object pointer.
The container_of macro recovers the enclosing struct pointer from a member pointer in zero memory reads by subtracting compile-time offsetof(Type, member).
Using a circular dummy sentinel node in a doubly linked list—or a Node** pointer-to-pointer in a singly linked list—eliminates all null-check boundary branches on head/tail updates.
Always cache next = curr->next before unlinking, moving, or freeing curr during list traversal (the list_for_each_safe pattern).
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue