← Back to Blog

Stability by Design: The Segmented Pool Pattern

In systems programming, the choice of container often dictates the complexity of memory management. The dynamic array (like C++ std::vector or Go slice) is a staple for its cache friendliness and simplicity. However, it harbors a hidden complexity: pointer invalidation during reallocation.

When the underlying capacity is exhausted, the system allocates a larger block, copies the data, and frees the old memory. In an instant, every pointer, reference, or iterator pointing to that data becomes dangling. For complex graph structures, ASTs (Abstract Syntax Trees), or long-lived object pools, this behavior is catastrophic.

Enter the Segmented Pool. It trades absolute contiguity for absolute stability.

Core Concept: Divide and Conquer

The core philosophy of a segmented pool is straightforward: when the current memory chunk is full, do not move the old data. Instead, allocate a new chunk (Segment) and link it.

Think of it like a notebook. When you fill the last page, you don't buy a thicker notebook and copy everything over. You simply buy another notebook and continue writing. The contents of the first notebook remain exactly where they were written.

Memory Layout

A typical segmented pool consists of a spine (an array of pointers) and multiple fixed-size memory blocks:

  1. The Spine: Keeps track of the allocated segments.
  2. The Segments: Fixed-size arrays where data actually lives.
  3. The Cursor: Tracks usage within the current segment.

This structure maintains locality within each segment while guaranteeing that the physical address of an element never changes once it is created.

Implementation Demo

To understand the mechanics, let's look at a stripped-down implementation in Go. This demo restates the fundamental append logic of a segmented pool.

package main

import (
	"fmt"
)

// SegmentedPool represents a memory pool providing stable pointer references.
// It consists of multiple fixed-size segments.
type SegmentedPool[T any] struct {
	segments    [][]T
	segmentSize int
	currentIdx  int // Index within the current segment
}

// NewSegmentedPool creates a new pool.
func NewSegmentedPool[T any](segmentSize int) *SegmentedPool[T] {
	return &SegmentedPool[T]{
		segments:    make([][]T, 0),
		segmentSize: segmentSize,
		currentIdx:  0,
	}
}

// Append adds an element and returns a pointer to it.
// The pointer remains valid even if new segments are allocated.
func (p *SegmentedPool[T]) Append(val T) *T {
	// If no segments exist or the current segment is full
	if len(p.segments) == 0 || p.currentIdx >= p.segmentSize {
		// Allocate a new segment
		newSeg := make([]T, p.segmentSize)
		p.segments = append(p.segments, newSeg)
		p.currentIdx = 0 // Reset segment index
	}
	
	// Get the current segment
	seg := p.segments[len(p.segments)-1]
	// Write the value
	seg[p.currentIdx] = val
	// Get the stable pointer
	ptr := &seg[p.currentIdx]
	
	p.currentIdx++
	return ptr
}

func main() {
	// Create a pool with a tiny segment size for demonstration
	pool := NewSegmentedPool[int](2)
	
	// Append data
	p1 := pool.Append(10)
	p2 := pool.Append(20)
	// Trigger new segment allocation
	p3 := pool.Append(30) 
	
	fmt.Printf("p1: %p, val: %d\n", p1, *p1)
	fmt.Printf("p2: %p, val: %d\n", p2, *p2)
	fmt.Printf("p3: %p, val: %d\n", p3, *p3)
}

Deconstructing the Code

In the Append operation, we check capacity. If full, we append a fresh newSeg to our list of segments. We never touch the old ones.

The critical line is ptr := &seg[p.currentIdx]. Because the underlying array of the old seg is never reallocated or moved, the pointers p1 and p2 returned previously remain valid even after p3 forces a new allocation. This is memory stability in action.

Deep Dive: The Trade-offs

The segmented pool is not a silver bullet; it is a calculated compromise between continuity and stability.

1. Locality vs. Fragmentation

Standard dynamic arrays offer perfect cache locality, beloved by CPU prefetchers. Segmented pools maintain locality within a segment, but accessing data across segments involves a "jump." If segments are too small, these jumps occur frequently, hurting cache efficiency. If segments are too large for the dataset, memory is wasted (unused capacity at the end).

2. The Cost of Access

In a dynamic array, vec[i] is simple pointer arithmetic: $O(1)$. In a segmented pool, finding the $i$-th element usually involves two steps:

  1. Find the segment: segmentIndex = i / segmentSize
  2. Find the offset: offset = i % segmentSize

This introduces division and modulo operations (unless optimized with power-of-two sizing) or requires double indirection. Thus, segmented pools are better suited for "append-only, pointer-referenced" workloads rather than random-access heavy ones.

3. Lifecycle Management

For languages with manual memory management (like C++), implementing this pattern is complex. Memory is often pre-allocated as raw bytes, requiring explicit placement new for construction and manual destructor calls. Mishandling this leads to resource leaks or undefined behavior.

Conclusion

The segmented pool sacrifices some "compactness" for "stability." It plays an irreplaceable role in compiler implementations, order management in high-frequency trading, and any scenario requiring long-lived object pointers. It serves as a reminder that memory management isn't just about allocation and freeing—it's about planning the physical layout of data to match the access patterns of the system.