Learn Zig Series (#123) - LRU Cache
Learn Zig Series (#123) - LRU Cache
What will I learn?
- Why a cache with a hard size limit needs to answer two questions at once -- "what is this key's value?" and "which entry have I not touched in the longest while?" -- and why neither a plain hash map nor a plain list can do both fast;
- The classic marriage that solves it: a hash map for O(1) lookup wedded to a doubly linked list for O(1) recency ordering, so every
getand everyputis constant time; - How to build that in Zig with each node individually allocated (or handed out by a memory pool), and why storing nodes inside a growable
ArrayListwould quietly hand you a bag of dangling pointers; - Making the whole thing generic over key and value with a
comptimetype function, the same shape we have leaned on since the collections episodes; - Testing the clever pointer-juggling version against a dead-simple array reference with a property test, the two-implementations-one-oracle trick we used for union-find;
- Where the design meets its C, Rust and Go cousins -- and why the borrow checker makes the naive Rust LRU one of the most famous "why won't this compile?" puzzles in the whole language;
- But first, the debt: last episode's three union-find exercises, in full compilable code ;-)
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org);
- Episode 122 (Union-Find) still fresh -- we pay off its three exercises at the top (union by rank, a rollback-able DSU, and island counting), and the pointer-stability lesson from that episode is exactly the trap we sidestep today;
- The
std.AutoHashMapcomfort from episode 22 and the allocator discipline from episodes 7 and 114 (memory pools make a cameo); - The ambition to learn Zig programming.
Difficulty
- Advanced
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline
- Learn Zig Series (#47) - Build a Shell: Parsing Commands
- Learn Zig Series (#48) - Build a Shell: Process Spawning
- Learn Zig Series (#49) - Build a Shell: Built-in Commands
- Learn Zig Series (#50) - Build a Shell: Job Control and Signals
- Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
- Learn Zig Series (#52) - HTTP Server: Router and Responses
- Learn Zig Series (#53) - HTTP Server: Static Files and MIME
- Learn Zig Series (#54) - HTTP Server: Middleware and Logging
- Learn Zig Series (#55) - ECS Game Engine: Architecture
- Learn Zig Series (#56) - ECS Game Engine: Component Storage
- Learn Zig Series (#57) - ECS Game Engine: Systems and Queries
- Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering
- Learn Zig Series (#59) - Assembler: Instruction Encoding
- Learn Zig Series (#60) - Assembler: Two-Pass Assembly
- Learn Zig Series (#61) - Assembler: Disassembler and Binary Inspector
- Learn Zig Series (#62) - File Systems: Reading Directories and Metadata
- Learn Zig Series (#63) - File Watching: Detecting Changes
- Learn Zig Series (#64) - Process Management: Fork, Exec, Wait
- Learn Zig Series (#65) - Pipes and Inter-Process Communication
- Learn Zig Series (#66) - Shared Memory and Semaphores
- Learn Zig Series (#67) - Signal Handling Deep Dive
- Learn Zig Series (#68) - Unix Domain Sockets
- Learn Zig Series (#69) - Daemonization: Background Services
- Learn Zig Series (#70) - Timers and Scheduling
- Learn Zig Series (#71) - Resource Limits and Capabilities
- Learn Zig Series (#72) - System Call Wrappers
- Learn Zig Series (#73) - seccomp and Sandboxing
- Learn Zig Series (#74) - ptrace: Process Tracing
- Learn Zig Series (#75) - Reading Kernel State from /proc and /sys
- Learn Zig Series (#76) - Mini Project: Process Monitor
- Learn Zig Series (#77) - Mini Project: File Sync Tool - Part 1
- Learn Zig Series (#78) - Mini Project: File Sync Tool - Part 2: Delta Transfer
- Learn Zig Series (#79) - Mini Project: File Sync Tool - Part 3: Network Protocol
- Learn Zig Series (#80) - Mini Project: File Sync Tool - Part 4: Polish
- Learn Zig Series (#81) - UDP Sockets and Datagrams
- Learn Zig Series (#82) - DNS Resolver from Scratch
- Learn Zig Series (#83) - DNS Server Implementation
- Learn Zig Series (#84) - HTTP/1.1 Deep Dive
- Learn Zig Series (#85) - HTTP/2 Frames and Streams
- Learn Zig Series (#86) - TLS via C Interop
- Learn Zig Series (#87) - WebSocket Protocol
- Learn Zig Series (#88) - WebSocket Server
- Learn Zig Series (#89) - MQTT Messaging Protocol
- Learn Zig Series (#90) - Protocol Buffers Serialization
- Learn Zig Series (#91) - MessagePack Format
- Learn Zig Series (#92) - gRPC Service in Zig
- Learn Zig Series (#93) - SOCKS5 Proxy
- Learn Zig Series (#94) - NAT Traversal and Hole Punching
- Learn Zig Series (#95) - Mini Project: Chat Server - Protocol Design
- Learn Zig Series (#96) - Mini Project: Chat Server - Server Core
- Learn Zig Series (#97) - Mini Project: Chat Server - Client TUI
- Learn Zig Series (#98) - Mini Project: Chat Server - Rooms and History
- Learn Zig Series (#99) - Mini Project: DNS-over-HTTPS Proxy
- Learn Zig Series (#100) - Mini Project: Port Scanner
- Learn Zig Series (#101) - Mini Project: HTTP Load Tester - Part 1
- Learn Zig Series (#102) - Mini Project: HTTP Load Tester - Part 2
- Learn Zig Series (#103) - Mini Project: Reverse Proxy - Routing
- Learn Zig Series (#104) - Mini Project: Reverse Proxy - Load Balancing
- Learn Zig Series (#105) - Mini Project: Reverse Proxy - Health Checks
- Learn Zig Series (#106) - Linked Lists: Singly and Doubly
- Learn Zig Series (#107) - Skip Lists
- Learn Zig Series (#108) - B-Trees
- Learn Zig Series (#109) - Red-Black Trees
- Learn Zig Series (#110) - Tries: Prefix Trees
- Learn Zig Series (#111) - Bloom Filters
- Learn Zig Series (#112) - Cuckoo Filters
- Learn Zig Series (#113) - Ring Buffers: Lock-Free
- Learn Zig Series (#114) - Memory Pools
- Learn Zig Series (#115) - Slab Allocators
- Learn Zig Series (#116) - Sorting Algorithms in Zig
- Learn Zig Series (#117) - Binary Search Variations
- Learn Zig Series (#118) - Graph Representation
- Learn Zig Series (#119) - BFS and DFS
- Learn Zig Series (#120) - Dijkstra and A*
- Learn Zig Series (#121) - Topological Sort
- Learn Zig Series (#122) - Union-Find
- Learn Zig Series (#123) - LRU Cache (this post)
Learn Zig Series (#123) - LRU Cache
Last episode closed on a cliff-hanger. Union-find, I said, only ever grows -- you can merge groups forever, but there is no split, no forget, no remove. And then I pointed at the exact opposite pressure: a cache sitting in front of a slow database or a network call, with a hard size limit, that must throw something away every time it fills up. The victim is the entry you have not touched in the longest while -- the least recently used one. That is today's structure, the LRU cache, and it is the single most common place where a hash map and a linked list are forced to get married and made to dance. But we owe a debt first: last episode left you three union-find exercises, and I promised full code, not hand-waving ;-)
Solutions to Episode 122 Exercises
Exercise 1 -- union by rank in stead of size. The claim from last episode was that either heuristic gives the near-constant bound, so let us prove it by swapping the rule. Rather than tracking a subtree's element count, rank tracks an upper bound on its height: attach the lower-rank root under the higher-rank one, and only bump a rank when two equal-rank trees merge (because that is the one case where the taller result genuinely grew a level). Everything else -- path compression in find, the count of remaining components, the "already connected" boolean -- stays exactly as it was:
const std = @import("std");
const DisjointSetRank = struct {
gpa: std.mem.Allocator,
parent: []usize,
rank: []u8,
count: usize,
fn init(gpa: std.mem.Allocator, n: usize) !DisjointSetRank {
const parent = try gpa.alloc(usize, n);
errdefer gpa.free(parent);
const rank = try gpa.alloc(u8, n);
for (parent, 0..) |*p, i| p.* = i;
@memset(rank, 0);
return .{ .gpa = gpa, .parent = parent, .rank = rank, .count = n };
}
fn deinit(self: *DisjointSetRank) void {
self.gpa.free(self.parent);
self.gpa.free(self.rank);
}
fn find(self: *DisjointSetRank, x: usize) usize {
var root = x;
while (self.parent[root] != root) root = self.parent[root];
var cur = x;
while (self.parent[cur] != root) {
const next = self.parent[cur];
self.parent[cur] = root; // path compression, unchanged
cur = next;
}
return root;
}
fn connected(self: *DisjointSetRank, a: usize, b: usize) bool {
return self.find(a) == self.find(b);
}
fn unite(self: *DisjointSetRank, a: usize, b: usize) bool {
const ra = self.find(a);
const rb = self.find(b);
if (ra == rb) return false;
if (self.rank[ra] < self.rank[rb]) {
self.parent[ra] = rb;
} else if (self.rank[ra] > self.rank[rb]) {
self.parent[rb] = ra;
} else {
self.parent[rb] = ra;
self.rank[ra] += 1; // equal ranks -> the survivor grew one level
}
self.count -= 1;
return true;
}
};
test "union by rank keeps trees shallow and tracks components" {
const gpa = std.testing.allocator;
var ds = try DisjointSetRank.init(gpa, 8);
defer ds.deinit();
try std.testing.expect(ds.unite(0, 1));
try std.testing.expect(ds.unite(2, 3));
try std.testing.expect(ds.unite(1, 3));
try std.testing.expect(!ds.unite(0, 2)); // already joined
try std.testing.expect(ds.connected(0, 3));
try std.testing.expectEqual(@as(usize, 5), ds.count);
}
Notice rank is a u8, not a usize: because rank only rises when two equal-rank trees meet, it is bounded by log2(n), so even a u8 (max 255) covers a set with more elements than any machine will ever hold. That is the kind of tight, honest sizing Zig invites you to think about. Drop this into last episode's property test in place of the size-based version and it agrees with QuickFind on all 200 random scenarios -- the two heuristics are interchangeable at the level of correctness, and differ only in constant factors.
Exercise 2 -- a rollback-able disjoint set. Some algorithms (offline dynamic connectivity, DSU-on-tree) unite and then need to undo the last union. Path compression makes that impossible to reverse cheaply -- it rewrites pointers all over the tree on every find, so there is no single change to walk back. The trick is to give up compression, lean entirely on union-by-rank to keep the trees shallow, and record just enough about each merge to reverse it: which root got a new parent, and whether the survivor's rank was bumped. Push that record on an undo stack; rollback pops it and restores both fields:
const DsuRollback = struct {
gpa: std.mem.Allocator,
parent: []usize,
rank: []u8,
history: std.ArrayList(Record),
const Record = struct { child_root: usize, parent_root: usize, rank_bumped: bool };
fn init(gpa: std.mem.Allocator, n: usize) !DsuRollback {
const parent = try gpa.alloc(usize, n);
errdefer gpa.free(parent);
const rank = try gpa.alloc(u8, n);
for (parent, 0..) |*p, i| p.* = i;
@memset(rank, 0);
return .{ .gpa = gpa, .parent = parent, .rank = rank, .history = .empty };
}
fn deinit(self: *DsuRollback) void {
self.gpa.free(self.parent);
self.gpa.free(self.rank);
self.history.deinit(self.gpa);
}
fn find(self: *const DsuRollback, x: usize) usize {
var root = x;
while (self.parent[root] != root) root = self.parent[root];
return root; // NO compression -> find is a pure read, safe to undo around
}
fn unite(self: *DsuRollback, a: usize, b: usize) !bool {
var ra = self.find(a);
var rb = self.find(b);
if (ra == rb) return false;
if (self.rank[ra] > self.rank[rb]) {
const tmp = ra;
ra = rb;
rb = tmp; // ensure ra is the lower-rank root; it hangs under rb
}
const bump = self.rank[ra] == self.rank[rb];
self.parent[ra] = rb;
if (bump) self.rank[rb] += 1;
try self.history.append(self.gpa, .{ .child_root = ra, .parent_root = rb, .rank_bumped = bump });
return true;
}
fn rollback(self: *DsuRollback) void {
const rec = self.history.pop() orelse return;
self.parent[rec.child_root] = rec.child_root; // re-orphan the child root
if (rec.rank_bumped) self.rank[rec.parent_root] -= 1;
}
};
test "rollback undoes exactly the last union" {
const gpa = std.testing.allocator;
var ds = try DsuRollback.init(gpa, 5);
defer ds.deinit();
_ = try ds.unite(0, 1);
_ = try ds.unite(2, 3);
ds.rollback(); // undo the (2,3) merge only
try std.testing.expect(ds.find(2) != ds.find(3)); // gone
try std.testing.expect(ds.find(0) == ds.find(1)); // first merge still standing
}
Because find here never mutates -- look, it takes *const DsuRollback, the exact opposite of the compressing version from last episode -- undoing is trivially just "put the two fields back". That is the deal you strike: you trade the inverse-Ackermann magic for O(log n) finds, and in return you get perfect reversibility. unite, unite, rollback leaves the structure byte-for-byte identical to having done just the first unite. Nota bene: this is why offline algorithms love it -- they can explore a branch, then unwind it cleanly.
Exercise 3 -- counting islands. Given a grid of booleans (true = land), how many separated blobs of land are there? Map each cell (r, c) to the id r * cols + c, start with a running island count equal to the number of land cells, then union each land cell with its land neighbours to the right and below -- one pass is enough, because every horizontal or vertical adjacency gets seen from its top-left member. Each merge that actually joins two different blobs drops the count by one:
const IslandDsu = struct {
gpa: std.mem.Allocator,
parent: []usize,
size: []usize,
fn init(gpa: std.mem.Allocator, n: usize) !IslandDsu {
const parent = try gpa.alloc(usize, n);
errdefer gpa.free(parent);
const size = try gpa.alloc(usize, n);
for (parent, 0..) |*p, i| p.* = i;
@memset(size, 1);
return .{ .gpa = gpa, .parent = parent, .size = size };
}
fn deinit(self: *IslandDsu) void {
self.gpa.free(self.parent);
self.gpa.free(self.size);
}
fn find(self: *IslandDsu, x: usize) usize {
var root = x;
while (self.parent[root] != root) root = self.parent[root];
var cur = x;
while (self.parent[cur] != root) {
const next = self.parent[cur];
self.parent[cur] = root;
cur = next;
}
return root;
}
fn unite(self: *IslandDsu, a: usize, b: usize) bool {
const ra = self.find(a);
const rb = self.find(b);
if (ra == rb) return false;
if (self.size[ra] < self.size[rb]) {
self.parent[ra] = rb;
self.size[rb] += self.size[ra];
} else {
self.parent[rb] = ra;
self.size[ra] += self.size[rb];
}
return true;
}
};
fn countIslands(gpa: std.mem.Allocator, grid: []const bool, rows: usize, cols: usize) !usize {
std.debug.assert(grid.len == rows * cols);
var ds = try IslandDsu.init(gpa, rows * cols);
defer ds.deinit();
var islands: usize = 0;
for (grid) |cell| {
if (cell) islands += 1;
}
for (0..rows) |r| {
for (0..cols) |c| {
const idx = r * cols + c;
if (!grid[idx]) continue;
if (c + 1 < cols and grid[idx + 1]) {
if (ds.unite(idx, idx + 1)) islands -= 1;
}
if (r + 1 < rows and grid[idx + cols]) {
if (ds.unite(idx, idx + cols)) islands -= 1;
}
}
}
return islands;
}
test "counting islands finds the separated land blobs" {
const gpa = std.testing.allocator;
const grid = [_]bool{
true, true, false, false,
true, false, false, true,
false, false, true, true,
false, true, false, false,
};
try std.testing.expectEqual(@as(usize, 3), try countIslands(gpa, &grid, 4, 4));
}
Three blobs on that little 4x4: the L-shape in the top-left corner, the three-cell hook on the right edge, and the lone cell down at the bottom -- exactly what your eye counts. This is what image processing calls blob labelling or connected-component labelling, and here it is in a dozen lines because union-find does all the grouping for us. Debt paid ;-) Now, the cache.
Why one data structure will not do
The interface we want is dead simple, and it is the same one behind an operating system's page cache, a browser's image cache, a database buffer pool, and half the @lru_cache-style decorators in every language:
- get(key): return the value if present, and mark that key as just used.
- put(key, value): insert or update, and if that pushes us over the capacity, evict the least recently used entry.
Both operations must be O(1). That "both" is the whole difficulty. A hash map gives you O(1) get and put on its own -- but it has no notion of order, so "which key is the least recently used?" would mean scanning every entry, which is O(n). You might reach for a timestamp on each entry and scan for the minimum, like this:
const std = @import("std");
// The tempting but WRONG shape: a flat array of slots, each stamped with a "tick".
const Slot = struct { key: u32, value: u32, used: u64 };
fn evictOldest(slots: []const Slot) usize {
var oldest: usize = 0;
for (slots, 1..) |s, i| {
if (s.used < slots[oldest].used) oldest = i;
}
return oldest; // O(n) scan on EVERY eviction -- the wall we refuse to hit
}
That works, and for a cache of eight entries nobody will notice. But a real cache holds thousands or millions of entries and evicts constantly under load, and an O(n) scan on every eviction turns your fast-path cache into a slow-path liability. We saw the same "obvious but quadratic" trap with quick-find last episode. A linked list, meanwhile, gives you O(1) reordering and O(1) removal from either end -- but it has no way to find a key without walking the whole thing. So: the hash map can find but not order, the list can order but not find. Marry them.
The marriage: a hash map of pointers into a linked list
Here is the design, and once it clicks it never leaves you. We keep a doubly linked list of nodes where each node holds a key and a value. The list is kept in recency order: the most-recently-used node sits at the head, the least-recently-used at the tail. Separately, we keep a hash map from key to a pointer to that key's node in the list. Now watch how both operations collapse to constant time:
- get(key): look the key up in the map -- O(1) -- to get the node pointer directly, no list walk. Then unlink that node and splice it back in at the head, because it was just used. Both splices are O(1) thanks to the
prev/nextpointers. - put(key, value): if the key exists, update its node's value and move it to the head. If it does not, allocate a node, push it at the head, and record it in the map. If we are now over capacity, the victim is always the tail -- one pointer away, O(1) -- so we unlink it, erase its key from the map, and free it.
We built doubly linked lists back in episode 106 for exactly this reason: cheap removal from the middle. And we need doubly linked, not singly, because to unlink a node given only a pointer to it, you must fix up its predecessor's next, which means you need the node's prev. Here is the node, and the whole generic cache:
fn LruCache(comptime K: type, comptime V: type) type {
return struct {
const Self = @This();
const Node = struct {
key: K,
value: V,
prev: ?*Node = null,
next: ?*Node = null,
};
gpa: std.mem.Allocator,
capacity: usize,
map: std.AutoHashMap(K, *Node),
head: ?*Node = null, // most recently used
tail: ?*Node = null, // least recently used
fn init(gpa: std.mem.Allocator, capacity: usize) Self {
std.debug.assert(capacity >= 1);
return .{
.gpa = gpa,
.capacity = capacity,
.map = std.AutoHashMap(K, *Node).init(gpa),
};
}
fn deinit(self: *Self) void {
var cur = self.head;
while (cur) |node| {
const next = node.next;
self.gpa.destroy(node);
cur = next;
}
self.map.deinit();
}
// unlink a node from the list (leaves the map alone)
fn detach(self: *Self, node: *Node) void {
if (node.prev) |p| p.next = node.next else self.head = node.next;
if (node.next) |n| n.prev = node.prev else self.tail = node.prev;
node.prev = null;
node.next = null;
}
// splice a detached node in at the head (the most-recently-used end)
fn pushFront(self: *Self, node: *Node) void {
node.prev = null;
node.next = self.head;
if (self.head) |h| h.prev = node;
self.head = node;
if (self.tail == null) self.tail = node;
}
fn get(self: *Self, key: K) ?V {
const node = self.map.get(key) orelse return null;
self.detach(node);
self.pushFront(node);
return node.value;
}
fn put(self: *Self, key: K, value: V) !void {
if (self.map.get(key)) |node| {
node.value = value;
self.detach(node);
self.pushFront(node);
return;
}
const node = try self.gpa.create(Node);
errdefer self.gpa.destroy(node);
node.* = .{ .key = key, .value = value };
try self.map.put(key, node);
self.pushFront(node);
if (self.map.count() > self.capacity) self.evictLru();
}
fn evictLru(self: *Self) void {
const victim = self.tail orelse return;
self.detach(victim);
_ = self.map.remove(victim.key);
self.gpa.destroy(victim);
}
fn len(self: *const Self) usize {
return self.map.count();
}
};
}
A few things worth pausing on. detach and pushFront are the two list primitives, and every public method is just a short composition of them -- the boring, mechanical pointer surgery lives in exactly one place each, which is how you keep this class of bug-prone code honest. In put, the errdefer self.gpa.destroy(node) guards the window between create and the map.put succeeding: if the map fails to grow, the freshly created node is destroyed and we do not leak it. And evictLru reads the victim's key before freeing the node -- because we need that key to erase the map entry, and touching freed memory is a sin Zig will happily let you commit if you get the order wrong. The capacity >= 1 assert in init rules out the degenerate zero-capacity cache that could never hold anything.
Watching it evict
Let us make the recency logic concrete. Capacity two, three keys, and one deliberate get in the middle to flip which key is "old":
test "lru evicts the least recently used entry" {
const gpa = std.testing.allocator;
var cache = LruCache(u32, u32).init(gpa, 2);
defer cache.deinit();
try cache.put(1, 10);
try cache.put(2, 20);
// touch key 1 -> now 1 is most-recent, 2 is the LRU
try std.testing.expectEqual(@as(?u32, 10), cache.get(1));
// inserting a third key overflows capacity 2 -> evict the LRU (key 2)
try cache.put(3, 30);
try std.testing.expectEqual(@as(?u32, null), cache.get(2)); // evicted
try std.testing.expectEqual(@as(?u32, 10), cache.get(1)); // survived
try std.testing.expectEqual(@as(?u32, 30), cache.get(3)); // just added
try std.testing.expectEqual(@as(usize, 2), cache.len());
}
The single get(1) in the middle is what decides key 2's fate. Without it, keys 1 and 2 would be equally stale and 1 (the older insertion) would be the one to go. That is the entire personality of an LRU cache: access is what keeps you alive. Comment out that get line and re-run, and you will watch key 1 get evicted in stead -- a great five-second experiment to convince yourself the ordering does what you think.
The Zig-honest trap: never store nodes in a growable array
Here is the load-bearing detail, and it is the exact cousin of last episode's "find is secretly a mutation" surprise. Our map stores *Node -- raw pointers into the list -- and we hand each node its own allocation with gpa.create. It is very tempting, coming from a language with a growable vector, to store the nodes inside a std.ArrayList(Node) and keep indices around in stead of pointers. Do not. The moment that ArrayList outgrows its capacity, it reallocates its backing buffer to a new address, and every pointer you were holding into the old buffer is now dangling. Zig will not stop you -- it trusts you -- and you will get a use-after-free that only triggers once the list happens to grow. Individually heap-allocated nodes have stable addresses; that stability is the whole reason the design works.
If per-node create/destroy churn worries you (and in a hot cache it legitimately might), the right answer is not to abandon stable addresses -- it is to hand out fixed-size nodes from a memory pool, which we built back in episode 114. std.heap.MemoryPool gives you exactly that: O(1) create and destroy of a single fixed type, with addresses that stay put:
const PoolNode = struct {
key: u32,
value: u32,
prev: ?*PoolNode = null,
next: ?*PoolNode = null,
};
test "a memory pool hands out fixed-size nodes with stable addresses" {
const gpa = std.testing.allocator;
var pool: std.heap.MemoryPool(PoolNode) = .empty;
defer pool.deinit(gpa);
const a = try pool.create(gpa);
const b = try pool.create(gpa);
a.* = .{ .key = 1, .value = 10 };
b.* = .{ .key = 2, .value = 20, .prev = a };
a.next = b;
// the pointer we stored in `a` still resolves after further allocations
try std.testing.expectEqual(@as(u32, 20), a.next.?.value);
pool.destroy(b);
pool.destroy(a);
}
Swapping self.gpa.create(Node)/self.gpa.destroy(node) for self.pool.create(self.gpa)/self.pool.destroy(node) is a two-line change to the cache and buys you far less allocator traffic under load, without touching a single line of the list logic. Same interface, faster engine -- the profiling-before-optimizing discipline from episode 34 will tell you whether your workload actually needs it.
Testing the clever version against the obvious one
The pointer surgery in detach/pushFront/evictLru is exactly the sort of code where an off-by-one in the prev/next fixups hides for months. So we do what we did for union-find: build a stupidly obvious reference implementation and let a property test hammer both with the same random operations, asserting they always agree. The reference keeps an ArrayList of entries in recency order -- front is LRU, back is MRU -- and pays O(n) for everything, which is fine because obviousness, not speed, is its whole job:
fn NaiveLru(comptime K: type, comptime V: type) type {
return struct {
const Self = @This();
const Entry = struct { key: K, value: V };
gpa: std.mem.Allocator,
capacity: usize,
items: std.ArrayList(Entry), // items[0] = LRU, last = MRU
fn init(gpa: std.mem.Allocator, capacity: usize) Self {
return .{ .gpa = gpa, .capacity = capacity, .items = .empty };
}
fn deinit(self: *Self) void {
self.items.deinit(self.gpa);
}
fn indexOf(self: *Self, key: K) ?usize {
for (self.items.items, 0..) |e, i| {
if (e.key == key) return i;
}
return null;
}
fn get(self: *Self, key: K) ?V {
const i = self.indexOf(key) orelse return null;
const e = self.items.orderedRemove(i);
self.items.appendAssumeCapacity(e); // move to MRU end (room is guaranteed)
return e.value;
}
fn put(self: *Self, key: K, value: V) !void {
if (self.indexOf(key)) |i| {
_ = self.items.orderedRemove(i);
}
try self.items.append(self.gpa, .{ .key = key, .value = value });
if (self.items.items.len > self.capacity) {
_ = self.items.orderedRemove(0); // evict the front = LRU
}
}
};
}
test "property: LRU cache agrees with the naive reference" {
const gpa = std.testing.allocator;
var prng = std.Random.DefaultPrng.init(0xCACE);
const rand = prng.random();
var round: usize = 0;
while (round < 200) : (round += 1) {
const cap = rand.intRangeAtMost(usize, 1, 8);
var fast = LruCache(u32, u32).init(gpa, cap);
defer fast.deinit();
var slow = NaiveLru(u32, u32).init(gpa, cap);
defer slow.deinit();
const ops = rand.intRangeAtMost(usize, 0, 60);
for (0..ops) |_| {
const key = rand.uintLessThan(u32, 12);
if (rand.boolean()) {
const val = rand.int(u32);
try fast.put(key, val);
try slow.put(key, val);
} else {
try std.testing.expectEqual(slow.get(key), fast.get(key));
}
}
// final sweep: both must agree about every key in the domain
for (0..12) |k| {
const key: u32 = @intCast(k);
try std.testing.expectEqual(slow.get(key), fast.get(key));
}
}
}
Two hundred rounds, capacities from one to eight, up to sixty interleaved puts and gets against a twelve-key domain -- and the pointer-juggling cache never once disagrees with the array that anyone can read at a glance. Note the subtle discipline in the final sweep: get mutates recency, so calling it on both structures in lock-step keeps their orderings identical; if the fast cache ever reordered differently, the very next comparison would catch it. Because it all runs on std.testing.allocator, a single leaked node -- one missing destroy in deinit or evictLru -- fails the round too. That is two classes of bug pinned by one test.
How C, Rust, and Go do it
In C, this is the textbook "intrusive list plus hash table" and it is genuinely pleasant: a struct Node with prev/next and a key/value, a hash table mapping key to Node*, and the same detach/push-front dance. No allocator threading, no deinit, but also nobody reminding you to free the evicted node or to erase its hash entry before you free it -- the exact ordering bug our evictLru is written to avoid. Every serious C codebase has its own hand-rolled version, usually with the list pointers embedded directly in the cached struct.
In Rust, the naive LRU is one of the most famous "why won't this compile?" rites of passage in the language. A doubly linked list means every node is pointed at by two others (its neighbours) plus the map, and Rust's ownership model hates shared mutable aliasing with a passion. The honest-but-heavy fix is Rc<RefCell<Node>> everywhere, paying reference-counting and runtime borrow-checking for the privilege; the fast fix that real crates like lru actually ship is to store nodes in a Vec (an arena) and use integer indices in stead of pointers -- sidestepping the borrow checker by never holding a raw reference across a mutation. Which, notice, is the precise pattern our Zig trap warned against, made safe there only because the arena never frees individual slots and never reallocates out from under you. Same idea, opposite verdict on whether the compiler lets you.
In Go, it is almost boring, in the good way: the standard library ships container/list, a ready-made doubly linked list, and a map[K]*list.Element alongside it -- the exact marriage, handed to you. The garbage collector means no destroy, no eviction-ordering hazard, no dangling anything. You pay for it with GC pressure and pointer-chasing that the collector must trace, but for most services that is a trade worth making. Four languages, one structure, and the same load-bearing truth in every one: an LRU cache is a hash map and a doubly linked list, kept in sync so that finding and ordering are both O(1). Bam, jonguh ;-)
Exercises
A time-to-live (TTL) cache. Extend
LruCacheso each node also stores an expiry tick, and add agetFresh(key, now)that returnsnull(and evicts the node) ifnowis past its expiry, even when the key is present. Decide deliberately: should a plaingetstill resurrect an expired-but-not-yet-swept entry, or not? Write a test that inserts a key, advancesnowpast its TTL, and confirmsgetFreshreports a miss and shrinkslen().Swap in the memory pool. Take the
LruCacheabove and replace thegpa.create/gpa.destroycalls with astd.heap.MemoryPool(Node)field, initialised ininitand torn down indeinit. Re-run the property test unchanged -- it must still pass, and still be leak-clean onstd.testing.allocator. The point is that the eviction policy and the node allocation strategy are independent axes you can vary without touching each other.Segmented LRU (a "2Q"-lite). A pure LRU is fooled by a one-off scan that floods the cache with keys you will never see again, evicting your genuinely hot entries. Build a two-list variant: new keys enter a small "probationary" list, and only get promoted into the "protected" list on their second access. Evict from the probationary tail first. Test that a single sweep of never-repeated keys leaves a previously-promoted hot key untouched, where a plain LRU would have thrown it out.
Where this is heading
We now have a cache that finds and orders in O(1), evicts the coldest entry the instant it runs out of room, and rests on a marriage of a hash map and a doubly linked list -- with the pointer-stability discipline that keeps that marriage from turning into a use-after-free. But notice the quiet assumption baked into every line: there is one cache, living in one process, on one machine. The moment your data outgrows a single box -- when you need a dozen cache servers, or a hundred, and every key has to land on a predictable one of them -- a fresh problem appears that no amount of clever list-splicing solves. A naive hash(key) % server_count looks fine until the day you add or lose a server, and suddenly almost every key maps somewhere new and your whole cache goes cold at once. Spreading keys across a changing set of nodes so that adding or removing one node reshuffles as few keys as possible is a genuinely elegant idea with a name, and it is where we go next ;-)
Bedankt voor het lezen -- en tot de volgende keer! ;-)
Leave Learn Zig Series (#123) - LRU Cache to:
Read more #stem posts
Best Posts From scipio
We have not curated any of scipio's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.
More Posts From scipio
- Learn Zig Series (#123) - LRU Cache
- Learn Rust Series (#12) - Smart Pointers: Box, Rc & RefCell
- Learn AI Series (#142) - AI Reasoning and Planning
- Learn Zig Series (#122) - Union-Find
- Learn Rust Series (#11) - Closures & the Iterator Trait
- Learn AI Series (#141) - Robotics and Embodied AI
- Learn Zig Series (#121) - Topological Sort
- Learn Rust Series (#10) - Lifetimes
- Learn AI Series (#140) - Scientific AI
- Learn Zig Series (#120) - Dijkstra and A*