Ask me anything
Ithy Logo
Sign in to save

Unlock Peak Performance: Building a Lock-Optimized Sharded Slab Allocator in Go

Dive into high-concurrency memory management with sharding, sync.Map, colored slabs, and cache locality for your Go applications.

go-sharded-slab-allocator-syncmap-u1hgy2ei

Efficient memory management is crucial for high-performance Go applications, especially those dealing with numerous short-lived objects or requiring predictable memory usage patterns, like caching systems. A standard garbage collector (GC) can introduce latency spikes. A slab allocator mitigates this by pre-allocating memory in large blocks (slabs) and dividing them into fixed-size chunks, reducing GC pressure and fragmentation. This guide explores building a sophisticated slab allocator in Go, incorporating sharding, sync.Map for concurrency, colored slabs for state tracking, and optimizing for cache locality, specifically targeting structures like redixNode.

Core Concepts & Highlights

  • Slab Allocation Fundamentals: Pre-allocate large memory blocks (slabs) divided into fixed-size chunks to serve allocation requests efficiently, bypassing frequent GC cycles.
  • Sharding for Concurrency: Divide the allocator into multiple independent shards, distributing the allocation/deallocation load and drastically reducing lock contention across goroutines.
  • Optimized Concurrency with sync.Map: Each shard utilizes Go's sync.Map to manage its collection of slabs, offering optimized concurrent access, especially for read-heavy workloads or when keys are stable.
  • Colored Slabs for State Management: Implement a "coloring" system (state flags) to quickly identify slab status (e.g., Open for allocation, Full, Abandoned for potential reclamation) without expensive checks.
  • Cache Locality for redixNode: Design the allocator to store data structures like redixNode within contiguous memory chunks, improving CPU cache hit rates during traversal or access.

Why a Sharded Slab Allocator?

Addressing Concurrency and Performance Bottlenecks

In highly concurrent Go applications, a single, globally locked memory allocator can become a significant bottleneck. Multiple goroutines attempting to allocate or free memory simultaneously contend for the same lock, leading to performance degradation.

Sharding Explained

Sharding partitions the allocator's responsibilities. Instead of one global pool of slabs, we create multiple smaller, independent allocators (shards). Allocation requests are directed to a specific shard, typically based on a hash of the object's key or the calling goroutine's ID. This distribution means that contention is limited to within a single shard, significantly improving overall throughput.

Leveraging sync.Map

Within each shard, we need a concurrent-safe way to manage the slabs. Go's sync.Map is designed for scenarios where map entries are mostly stable, and read operations significantly outnumber write operations. It achieves better concurrency than a standard map protected by a sync.RWMutex under certain conditions by using internal mechanisms involving atomic operations and separate read/write structures. While writes to sync.Map might still involve locking internally, it often performs better in highly contested read scenarios and simplifies the implementation compared to fully custom lock-free maps.

Diagram illustrating Slab Allocator components

Conceptual view of Slab Allocator structures (Source: GeeksforGeeks)


Designing the Allocator

Components and Structure

Our design involves several key components working together:

  1. SlabAllocator:** The main entry point, holding the array of shards and configuration.
  2. Shard:** An independent allocator unit containing a sync.Map to manage its slabs.
  3. Slab:** A large, pre-allocated block of memory ([]byte) containing metadata like its current state ("color") and tracking for free chunks (e.g., an atomic index or a lock-free list).
  4. SlabColor:** An enum or constant set representing slab states (e.g., Open, Full, Abandoned).
  5. Hashing Function:** Determines which shard handles an allocation request.
  6. Atomic Operations:** Used within slabs for lock-free chunk allocation/deallocation and state transitions where possible.

Slab State Management: Colored Slabs

"Coloring" slabs is a way to efficiently manage their lifecycle and availability. We use atomic flags or values to represent the state:

  • ColorOpen:** The slab has free chunks and can be used for new allocations.
  • ColorFull:** The slab has no free chunks currently.
  • ColorAbandoned:** The slab might be full, or marked for potential reclamation (e.g., if it's highly fragmented or contains objects past a certain lifetime). This state helps the allocator quickly skip over unusable slabs during the search for free space.

Transitions between these states must be handled atomically to ensure consistency in a concurrent environment.

Allocator Structure Mindmap

This mindmap illustrates the hierarchical structure and key concepts of the sharded slab allocator:

mindmap root["Sharded Slab Allocator"] id1["Configuration"] id1_1["NumShards"] id1_2["SlabSize"] id1_3["ChunkSize (for redixNode)"] id2["Shards (Array)"] id2_1["Shard Instance"] id2_1_1["sync.Map (Manages Slabs)"] id2_1_1_1["Key: Slab ID/Pointer"] id2_1_1_2["Value: Slab Pointer"] id2_1_2["Slab Management Logic"] id2_1_2_1["Find Open Slab"] id2_1_2_2["Create New Slab"] id3["Slab Structure"] id3_1["Memory Buffer ([]byte)"] id3_2["Metadata"] id3_2_1["Atomic Color State (Open/Full/Abandoned)"] id3_2_2["Atomic Allocation Index/FreeList"] id3_3["Chunks (Fixed Size)"] id4["Core Operations"] id4_1["Allocate"] id4_1_1["Hashing to Shard"] id4_1_2["Find/Create Slab in Shard"] id4_1_3["Atomic Chunk Allocation"] id4_2["Deallocate (Conceptual)"] id4_2_1["Identify Slab"] id4_2_2["Atomic Chunk Return"] id4_2_3["Update Slab Color"] id5["Key Goals"] id5_1["Reduced Lock Contention (Sharding + sync.Map)"] id5_2["Minimized GC Pressure"] id5_3["Cache Locality (Contiguous Allocation)"] id5_4["Optimized Slab Search (Coloring)"]

Go Implementation Example

Bringing the Concepts Together

Below is a conceptual Go implementation demonstrating the core ideas. Note that a production-ready version would require more robust error handling, slab reclamation logic, and potentially more sophisticated free-list management for deallocation.

package slaballoc

import (
	"fmt"
	"hash/fnv"
	"sync"
	"sync/atomic"
	"unsafe" // Used here conceptually for unique slab IDs
)

// SlabColor represents the state of a slab
type SlabColor int32

const (
	ColorOpen      SlabColor = iota // Has free chunks
	ColorFull                       // No free chunks
	ColorAbandoned                  // Marked for potential cleanup/ignore
)

// Number of shards (tune based on CPU cores/contention)
const NumShards = 16
const DefaultSlabSizeBytes = 1 * 1024 * 1024 // 1MB slab size
// Assume redixNode size is fixed for this example chunk size
const DefaultChunkSizeBytes = 128

// Slab represents a memory slab managing fixed-size chunks
type Slab struct {
	id        uintptr    // Unique ID for the slab
	memory    []byte     // The actual memory block
	chunkSize int        // Size of each chunk
	numChunks int        // Total number of chunks
	allocIdx  int64      // Atomically incremented index for next allocation
	color     atomic.Int32 // Atomic state: Open, Full, Abandoned
}

// newSlab creates a new slab instance
func newSlab(slabSize int, chunkSize int) *Slab {
	if chunkSize == 0 {
		chunkSize = DefaultChunkSizeBytes // Avoid division by zero
	}
	numChunks := slabSize / chunkSize
	if numChunks == 0 {
		panic(fmt.Sprintf("Slab size %d too small for chunk size %d", slabSize, chunkSize))
	}
	s := &Slab{
		id:        uintptr(unsafe.Pointer(&struct{}{})), // Generate a unique-ish ID
		memory:    make([]byte, slabSize),
		chunkSize: chunkSize,
		numChunks: numChunks,
		allocIdx:  0,
	}
	s.color.Store(int32(ColorOpen)) // Start as open
	return s
}

// Allocate attempts to get a chunk from this slab atomically
func (s *Slab) Allocate() ([]byte, bool) {
	if s.color.Load() != int32(ColorOpen) {
		return nil, false // Don't allocate from full or abandoned slabs
	}

	// Atomically get the next index
	idx := atomic.AddInt64(&s.allocIdx, 1) - 1

	if idx >= int64(s.numChunks) {
		// We potentially went past the end, revert counter if needed (though unlikely with proper checks)
		atomic.AddInt64(&s.allocIdx, -1)
		// Mark as full - use CompareAndSwap for safety
		s.color.CompareAndSwap(int32(ColorOpen), int32(ColorFull))
		return nil, false // Slab is full
	}

	// Calculate chunk offset
	offset := int(idx) * s.chunkSize
	// Return the slice representing the chunk
	chunk := s.memory[offset : offset+s.chunkSize]

	// Check if this allocation made the slab full
	if idx == int64(s.numChunks)-1 {
		s.color.CompareAndSwap(int32(ColorOpen), int32(ColorFull))
	}

	return chunk, true
}

// Deallocate (Conceptual): Returns a chunk to the slab.
// A robust lock-free deallocation typically requires a lock-free stack/queue
// for the free list, which adds complexity. This is a placeholder.
func (s *Slab) Deallocate(chunk []byte) {
	// 1. Identify the chunk's index based on its pointer/offset.
	// 2. Add the index back to a free list (atomically).
	// 3. If the slab was ColorFull, attempt to change state back to ColorOpen atomically.
	// fmt.Printf("Deallocating chunk in slab %d (Conceptual)\n", s.id)

	// Example state transition (simplified):
	if s.color.Load() == int32(ColorFull) {
		// Assuming free list management makes a slot available...
		s.color.CompareAndSwap(int32(ColorFull), int32(ColorOpen))
	}
}


// Shard manages slabs using a sync.Map
type Shard struct {
	slabs         sync.Map // Stores *Slab, keyed by slab.id (uintptr)
	slabSize      int
	chunkSize     int
	nextSlabID    uintptr // Could use atomic counter for more robust IDs
	newSlabMutex  sync.Mutex // Mutex specifically for creating new slabs to avoid thundering herd
}

// SlabAllocator manages multiple shards
type SlabAllocator struct {
	shards    [NumShards]*Shard
	chunkSize int // Target chunk size (e.g., for redixNode)
}

// NewSlabAllocator creates the sharded allocator
func NewSlabAllocator(chunkSize int, slabSize int) *SlabAllocator {
	if chunkSize == 0 { chunkSize = DefaultChunkSizeBytes }
	if slabSize == 0 { slabSize = DefaultSlabSizeBytes }

	sa := &SlabAllocator{
		chunkSize: chunkSize,
	}
	for i := 0; i < NumShards; i++ {
		sa.shards[i] = &Shard{
			slabSize:  slabSize,
			chunkSize: chunkSize,
		}
	}
	return sa
}

// getShard determines the shard for a given key (e.g., redixNode key)
func (sa *SlabAllocator) getShard(key string) *Shard {
	hasher := fnv.New32a()
	hasher.Write([]byte(key))
	shardIndex := hasher.Sum32() % uint32(NumShards)
	return sa.shards[shardIndex]
}

// Allocate finds or creates a slab in the appropriate shard and allocates a chunk
func (sa *SlabAllocator) Allocate(key string) ([]byte, error) {
	shard := sa.getShard(key)

	// Fast path: Iterate existing slabs using sync.Map's Range
	var allocatedChunk []byte
	var success bool

	shard.slabs.Range(func(slabIDKey, slabVal interface{}) bool {
		slab := slabVal.(*Slab)
		chunk, ok := slab.Allocate()
		if ok {
			allocatedChunk = chunk
			success = true
			return false // Stop iteration on success
		}
		// Continue checking other slabs
		return true
	})

	if success {
		return allocatedChunk, nil
	}

	// Slow path: No suitable slab found, need to create one.
	// Use a mutex here to prevent multiple goroutines creating slabs simultaneously in the same shard.
	shard.newSlabMutex.Lock()
	// Double-check if another goroutine created a slab while waiting for the lock
	shard.slabs.Range(func(slabIDKey, slabVal interface{}) bool {
		slab := slabVal.(*Slab)
		chunk, ok := slab.Allocate()
		if ok {
			allocatedChunk = chunk
			success = true
			return false // Stop iteration
		}
		return true
	})

	if success {
		shard.newSlabMutex.Unlock()
		return allocatedChunk, nil
	}

	// Still no luck, proceed to create a new slab
	newSlab := newSlab(shard.slabSize, shard.chunkSize)
	shard.slabs.Store(newSlab.id, newSlab) // Store the new slab
	shard.newSlabMutex.Unlock()

	// Attempt allocation from the newly created slab
	// This allocation should ideally succeed unless the slab is incredibly small
	chunk, ok := newSlab.Allocate()
	if !ok {
		// This indicates a potential issue, e.g., slabSize too small for even one chunk
		return nil, fmt.Errorf("failed to allocate from newly created slab in shard for key %s", key)
	}

	return chunk, nil
}

// Deallocate finds the shard and conceptually deallocates the chunk
// NOTE: Finding the correct slab for deallocation requires additional tracking
// (e.g., storing slab ID with the chunk, or checking address ranges)
func (sa *SlabAllocator) Deallocate(key string, chunk []byte) error {
	shard := sa.getShard(key)

	// Find the slab this chunk belongs to (Complex part omitted for brevity)
	var targetSlab *Slab = nil
	chunkAddr := uintptr(unsafe.Pointer(&chunk[0]))

	shard.slabs.Range(func(slabIDKey, slabVal interface{}) bool {
		slab := slabVal.(*Slab)
		slabStart := uintptr(unsafe.Pointer(&slab.memory[0]))
		slabEnd := slabStart + uintptr(len(slab.memory))

		if chunkAddr >= slabStart && chunkAddr < slabEnd {
			targetSlab = slab
			return false // Found the slab
		}
		return true
	})


	if targetSlab != nil {
		targetSlab.Deallocate(chunk) // Call conceptual deallocation
		return nil
	}

	return fmt.Errorf("could not find slab for chunk associated with key %s", key)
}

// --- Placeholder for redixNode ---
type redixNode struct {
	// Define fields relevant to your radix node
	prefix []byte
	value  interface{}
	// etc.
}

// Example Usage:
// func main() {
// 	alloc := NewSlabAllocator(128, 1024*1024) // 128 byte chunks, 1MB slabs
//
// 	// Simulate concurrent allocations
// 	var wg sync.WaitGroup
// 	for i := 0; i < 1000; i++ {
// 		wg.Add(1)
// 		go func(id int) {
// 			defer wg.Done()
// 			key := fmt.Sprintf("node-%d", id)
// 			chunk, err := alloc.Allocate(key)
// 			if err != nil {
// 				fmt.Printf("Error allocating for %s: %v\n", key, err)
// 				return
// 			}
// 			// Simulate using the chunk for a redixNode
// 			// node := (*redixNode)(unsafe.Pointer(&chunk[0])) // Unsafe, needs proper handling
// 			// node.value = id
// 			fmt.Printf("Goroutine %d allocated chunk for key %s\n", id, key)
//
// 			// Conceptual deallocation
// 			// time.Sleep(10 * time.Millisecond)
// 			// alloc.Deallocate(key, chunk)
// 		}(i)
// 	}
// 	wg.Wait()
// 	fmt.Println("Finished allocations.")
// }

Implementation Notes:

  • Lock-Freedom Tradeoffs: While chunk allocation within a slab uses atomics, creating *new* slabs introduces a mutex (newSlabMutex) per shard to prevent race conditions during slab creation under high contention. sync.Map itself has internal synchronization for writes. True end-to-end lock-freedom is significantly more complex.
  • Deallocation Complexity: The Deallocate function is conceptual. A production system needs a robust way to map a returned chunk back to its original slab and manage the free list using lock-free data structures (like a Treiber stack or Michael-Scott queue) or careful atomic operations.
  • Slab ID/Tracking: The example uses unsafe.Pointer for a quick unique ID. A safer approach might involve an atomic counter per shard or globally. Tracking which slab owns a chunk for deallocation is essential.
  • Cache Locality: By allocating redixNode (or its data) into the []byte chunks obtained from the allocator, related nodes hashed to the same shard *might* end up in the same slab, improving spatial locality compared to scattered heap allocations.

Performance Considerations & Trade-offs

Evaluating the Approach

This sharded slab allocator design offers significant advantages in high-concurrency scenarios compared to a single locked allocator or relying solely on the Go GC for small, frequent allocations. However, it's essential to understand the trade-offs.

Performance Aspects Comparison

The radar chart below provides a qualitative comparison between different memory management approaches in Go, focusing on aspects relevant to our design goals. Scores are relative (higher is generally better, except for Complexity).

This chart highlights that our sharded sync.Map approach aims for a balance, offering excellent concurrency and GC pressure reduction with moderate implementation complexity compared to a truly lock-free custom allocator, which would maximize performance but also complexity.


Go Memory Allocation Insights Video

Understanding Go's Allocator Basics

To better appreciate the benefits of a slab allocator, understanding Go's default memory management is helpful. This talk provides insights into how Go handles memory allocation and garbage collection, setting the context for why custom allocators can be beneficial in specific high-performance scenarios.

GopherCon UK 2018: Andre Carvalho - Understanding Go's Memory Allocator


Frequently Asked Questions (FAQ)

Is sync.Map truly lock-free?

sync.Map is optimized for concurrency but isn't strictly "lock-free" for all operations in the academic sense. Reads can often proceed without locking if the key is present in the read-only part of the map. However, writes (Store, Delete) and operations that promote data from the write part to the read part typically involve internal locking mechanisms. It provides significantly better concurrency than a simple mutex-protected map for certain access patterns (many reads, stable keys) but doesn't eliminate locks entirely.

How does this allocator improve cache locality for `redixNode`?

Cache locality, specifically spatial locality, is improved because the allocator reserves large contiguous blocks of memory (slabs). When `redixNode` objects (or their data) are allocated into chunks within the same slab, they reside close to each other in memory. If the radix tree traversal frequently accesses nodes allocated sequentially or nearby within a slab, the CPU cache is more likely to already contain the required data, reducing cache misses and speeding up access compared to nodes scattered randomly across the heap by the standard allocator.

What does "Colored Slabs" mean in this context?

"Colored Slabs" refers to using a state flag (the "color") associated with each slab to quickly determine its usability. Instead of inspecting a slab's internal free list or counters every time, the allocator can first check its color. If a slab is marked `ColorFull` or `ColorAbandoned`, it can be skipped immediately during the search for an available chunk, optimizing the allocation path. The colors are updated atomically as the slab's state changes (e.g., from `Open` to `Full` when the last chunk is allocated, or potentially back to `Open` if chunks are deallocated).

How is deallocation handled?

The provided example code simplifies deallocation. A complete implementation requires two main steps: 1) Identifying which slab the memory chunk being freed belongs to (e.g., by storing metadata with the chunk, or by checking memory address ranges against known slabs managed by the shard). 2) Returning the chunk to the slab's pool of available chunks. To maintain low contention, this return process should ideally use lock-free techniques, such as pushing the chunk's index onto a lock-free stack (like a Treiber stack) associated with the slab. The slab's color state might also need atomic updates if it transitions from `Full` back to `Open`.


Recommended Next Steps


References

chromium.googlesource.com
go-slab - slab allocator in go
chromium.googlesource.com
slab allocator in go - github.com

Last updated April 27, 2025

AI-assisted research. Sources, methodology and corrections · Source-checked guides and tools · Explore published answers

Ask Ithy AI