DSA DSA In Depth
0%
case timer-wheelsheapssystems-internals

Production Case Study: Hierarchical Hashed Timer Wheels vs Four-Heap Timers (Go runtime, Kafka, Linux)

7 min read Module 3.1: Binary Trees, Traversals, BSTs & Priority Heaps Updated Oct 23, 2026
📄 Cheat Sheet
Implementations of Hashed & Hierarchical Timing Wheels (Varghese & Lauck Paper)
Systems We Love (Julian Squires) Implementations of Hashed & Hierarchical Timing Wheels (Varghese & Lauck Paper) Watch on YouTube ↗

Explains George Varghese and Tony Lauck's SOSP '87 paper classifying timer algorithms and deriving O(1) Hierarchical Hashed Timing Wheels.

0:00 / --:--

Architectural Tradeoff: 4-Ary Min-Heap vs Hierarchical Timing Wheel

Why do the Linux kernel (kernel/time/timer.c), Netty (HashedWheelTimer), and Apache Kafka (TimingWheel) use Timing Wheels for network timeouts, whereas Go's runtime (runtime/time.go) and the Linux high-resolution timer subsystem (hrtimer) use 4-ary Heaps or Red-Black Trees?

  • 4-Ary Min-Heap (Go Runtime / libev): Stores [when, period, fn, seq] entries in a flat 4-ary array. Gives exact nanosecond resolution and $O(1)$ lookup of the next deadline (timers[0].when), allowing an idle OS thread to sleep until the exact nanosecond via epoll_wait / kqueue with zero periodic tick wakeups. However, inserting or rescheduling costs $O(\log_4 N)$.
  • Single-Level Hashed Wheel (Netty): A circular array of $W$ buckets (e.g., $W = 512$, tick $\Delta t = 10\text{ ms}$). A timer expiring in $\tau$ ticks is appended in $O(1)$ to bucket $(\text{cursor} + \tau) \bmod W$ with remainingRounds = τ / W. Cancellation is an $O(1)$ doubly-linked list unlink! However, if $\tau \gg W$, ticking through empty or high-round entries wastes CPU.
  • Hierarchical Timing Wheel (Kafka Purgatory / Varghese Scheme 6): Stacks multiple wheels like an odometer (e.g., Wheel 0: $20 \times 1\text{ ms}$; Wheel 1: $20 \times 20\text{ ms}$; Wheel 2: $20 \times 400\text{ ms}$). Long-horizon timers sit silently in upper wheels and cascade downward only when their coarse bucket expires. And how does Kafka avoid waking up an idle CPU every $1\text{ ms}$? By inserting only the non-empty buckets themselves (at most $20 \times 4 = 80$ buckets total!) into a tiny Java DelayQueue (Min-Heap), so the heap size is $O(M \cdot W) = O(1)$ instead of $O(N)$!
Timer Architecture Start Timer Cancel Timer (Hot Path!) Next Expiration Wait Production Adopters
Binary Min-Heap ($d = 2$) O(log₂ N) O(log₂ N) O(1) Exact Sleep Java ScheduledThreadPoolExecutor
4-Ary Min-Heap + Lazy Delete O(log₄ N) O(1) Atomic Flag O(1) Exact Sleep Go runtime/time.go, libev
Hierarchical Timing Wheel O(1) O(1) Intrusive Unlink O(1) Amortized Cascade Linux timer_list, Netty, Envoy
Kafka Wheel + Bucket DelayQueue O(1) O(1) O(log B) (B ≪ N buckets) Apache Kafka DelayedOperationPurgatory

Amortized $O(1)$ Cascade Proof for $L$-Level Hierarchical Timing Wheels

Consider an $L$-level hierarchical wheel where level $\ell \in \{0, \dots, L-1\}$ has $W$ slots and tick granularity $\Delta_\ell = \Delta_0 \cdot W^\ell$, covering a maximum horizon $T_{\max} = \Delta_0 \cdot W^L$. Assign each active timer at level $\ell$ a potential $\Phi(\text{timer}) = \ell$.

  • Insertion (startTimer): Computes the highest level $\ell < L$ where $\Delta_\ell$ divides the remaining delay, appends the timer to a doubly-linked bucket list in $O(1)$ actual time, and sets initial potential $\Phi \le L - 1 = O(1)$.
  • Cancellation (stopTimer): Unlinks the intrusive list node in $O(1)$ actual time and decreases $\Phi$ by $\ell \ge 0$.
  • Downward Cascade (advanceClock): Whenever a coarse bucket at level $\ell > 0$ expires and re-hashes its timers into level $\ell - 1$, each cascading timer spends $O(1)$ actual re-insertion work while its potential drops by $\Delta \Phi = -1$, paying for the cascade completely! Since $L = \lceil \log_W(T_{\max}/\Delta_0) \rceil$ is a small constant (typically $L = 4$ or $5$), every timer operation is $O(L) = O(1)$ amortized!
Fibonacci Heap Consolidation / Cascading Cuts & Hierarchical Timer Wheel
O(1) Amortized decreaseKey · O(1) Timer Wheel

Compare Fredman-Tarjan Fibonacci Heaps (Φ = t(H) + 2m(H), degree-table consolidation, and cascading cuts) against Varghese-Lauck O(1) Hierarchical Timing Wheels used in Linux kernel hrtimers and Kafka.

Step 1 / 9
Custom Input:
Timeline

Invariant & State

Potential Function Φ(H) = t(H) + 2m(H): Lazy insert & meld simply splice into root list in O(1) actual time (ΔΦ = +1).
🌳 Live 2D Hierarchical Tree Topology — Fibonacci Heap Consolidation / Cascading Cuts & Hierarchical Timer Wheel Visited/Active Pivot/Root Resolved
root 3 [0] 7 [1] 10 [2] 18 [3] 12 [4] 24 [5]
Lazy Root List (t(H)=4 trees, m(H)=1 marked node y): Potential Φ = t + 2m = 6
Min Root [3] (deg 0)
Root [7] (deg 0)
Root [10]—child [18] (deg 1)
Root [12]—marked [24*] (deg 1)
Heap Operation
Before extractMin / decreaseKey
Amortized Cost
Φ = 4 + 2(1) = 6
Degree Invariant
Max Degree D(N) ≤ ⌊log_φ N⌋
[Step 1] Potential Function Φ(H) = t(H) + 2m(H): Lazy insert & meld simply splice into root list in O(1) actual time (ΔΦ = +1).
Level 1 Wheel (Coarse: 20ms/slot, Horizon 160ms): 0..19 20..39 40..59 60..79 Level 0 Wheel (Fine: 1ms/slot, Horizon 20ms) — Cascade Down ↓ t=20 t=21 t=22 t=23 ... Go runtime/time.go Per-P 4-Ary Heap + Lazy Delete: • Each logical processor P owns a private 4-ary heap • Cancelling a timer atomically sets status = timerDeleted • No lock contention or immediate siftDown on cancel! Kafka Hybrid: Wheel (O(1) insert/cancel) + Heap of Buckets • 1,000,000 timers share ≤ 80 active buckets in DelayQueue • Reduces O(log 1,000,000) = 20 steps to O(log 80) ≈ 6!
flowchart TD Add["Insert Timer(expMs)"] --> Check{"expMs < now + tickMs ?"} Check -- "Yes" --> Fire["Fire Immediately"] Check -- "No" --> InRange{"expMs < now + wheelSpan ?"} InRange -- "Yes (Fits Level ℓ)" --> Bucket["slot = (expMs / tickMs) % W Append to Bucket[slot] (O(1)) If bucket was empty: DelayQueue.offer(bucket)"] InRange -- "No (Beyond Horizon)" --> Overflow["Delegate to Overflow Wheel (Level ℓ + 1) with tickMs' = wheelSpan"]

Hardware Reality: Why Go Switched from a Global 4-Heap to Per-P 4-Heaps with Lazy Status Flags

Prior to Go 1.10, all goroutines shared a single global 4-ary timer heap protected by one mutex—causing massive cross-core cache line bouncing whenever thousands of HTTP handlers called context.WithTimeout. Go first sharded into 64 heaps (Go 1.10) and then in Go 1.14 attached a 4-ary timer heap directly to each scheduler processor P, pairing it with atomic state transitions (timerWaiting $\to$ timerDeleted / timerModified). When a goroutine cancels a timer on another P, it merely flips an atomic integer in $O(1)$ without locking or mutating the remote 4-ary heap; the owning P compacts deleted entries lazily when advancing its clock!

Implementation: O(1) Intrusive Hierarchical Timing Wheel Level

// C++20: O(1) Insert / O(1) Cancel Intrusive Timing Wheel Level (Linux / Kafka Style)
#include <cstdint>
#include <array>

struct TimerNode {
    uint64_t expireMs = 0;
    TimerNode *prev = nullptr, *next = nullptr;
    bool active = false;

    // O(1) Cancellation via intrusive doubly-linked list unlink
    void cancel() noexcept {
        if (!active) return;
        if (prev) prev->next = next;
        if (next) next->prev = prev;
        prev = next = nullptr;
        active = false;
    }
};

template <size_t W = 64> // Power-of-2 wheel size for bitmask modulo
class TimingWheelLevel {
    static_assert((W & (W - 1)) == 0, "Wheel size W must be a power of 2");
    std::array<TimerNode, W> sentinels{}; // Dummy head nodes per slot
    uint64_t currentTick = 0;
    uint64_t tickShift = 0;               // tickMs = 1ULL << tickShift

public:
    explicit TimingWheelLevel(uint64_t shift = 0) : tickShift(shift) {
        for (auto& s : sentinels) s.prev = s.next = &s;
    }

    // O(1) Timer insertion into circular bucket
    void schedule(TimerNode* node, uint64_t expireMs) noexcept {
        node->cancel();
        node->expireMs = expireMs;
        size_t slot = (expireMs >> tickShift) & (W - 1);
        TimerNode* head = &sentinels[slot];
        node->next = head->next;
        node->prev = head;
        head->next->prev = node;
        head->next = node;
        node->active = true;
    }
};

Failure Modes & Adversarial Pitfalls

  • Linux Kernel 2.6 cascade() Latency Spike: For two decades, the Linux kernel used a 5-level cascade wheel (tv1..tv5) that guarded both timeouts (cancelled 99.9% of the time) and periodic sleeps (which always fire). Every $256\text{ ticks}$, re-hashing an entire upper wheel bucket caused unpredictable latency spikes during softirq processing. In Linux 4.8, Thomas Gleixner redesigned timer_list to sacrifice fine expiration accuracy on distant timeouts (increasing bucket slack at higher levels) so upper wheels never cascade downward at all!
  • Empty-Tick Polling Burning Battery/CPU: Never run a while loop that wakes up every $1\text{ ms}$ to advance a Timing Wheel on an idle system. Either track a bitmask of non-empty slots (using std::countr_zero / TZCNT to jump directly to the next occupied slot in $O(1)$) or push non-empty buckets into a small DelayQueue heap like Kafka.
Check yourself Question 1 of 4
Concept Check Workload Asymmetry of Network Request Timers recall

According to Varghese & Lauck's SOSP '87 study and production telemetry in Netty/Envoy/Kafka, which timer operations dominate CPU cost in high-throughput network servers?

⚡ Interactive Challenge · Staff Level

Algorithmic Design & Complexity Check

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

Why did Linux kernel 4.8 replace the classic 2.6 cascading hierarchical timer wheel (`tv1..tv5`) with a non-cascading multi-level wheel for `timer_list` timeouts?

Key Takeaways

99.9% of network request timeouts are cancelled before firing, making O(1) cancellation more critical than expiration sorting.
Hierarchical Timing Wheels achieve O(1) start, O(1) intrusive-unlink cancel, and O(1) amortized tick advancement by discretizing time into multi-level circular ring buffers.
Apache Kafka combines a Hierarchical Timing Wheel with a DelayQueue (Min-Heap) of non-empty buckets, bounding heap operations to the tiny number of active buckets rather than millions of individual timers.
Go's runtime uses per-P 4-ary Min-Heaps with atomic lazy deletion to preserve exact nanosecond sleep accuracy without global lock contention.
Was this lesson helpful? Spot an issue or have a suggestion? Let us know on GitHub.
Report an issue