Golang DSA Chapter 10 Part 2: Cache Management and Space Allocation
Welcome back. In Part 1 we went through garbage collection algorithms, reference counting, mark-and-sweep, and generational collection. Now let’s finish up Chapter 10 of “Learn Data Structures and Algorithms with Golang” by Bhagvan Kommadi. This second half covers cache management, space allocation on the stack, pointers, memory management tips, and profiling.
Let’s wrap it up.
Cache Management
Caching is one of those things you use every day without thinking about it. Your browser caches web pages, your CPU caches memory lookups, your phone caches app data. The idea is simple: store frequently used stuff somewhere fast so you don’t have to fetch it from the slow place every time.
The book builds a basic cache system in Go with time-to-live (TTL) support. It starts with a CacheObject struct that holds a string value and an expiration timestamp:
// CacheObject class
type CacheObject struct {
Value string
TimeToLive int64
}
Nothing fancy. Each cached item knows its value and when it should expire.
Checking Expiration
The IfExpired method checks whether a cache entry has gone stale:
// IfExpired method
func (cacheObject CacheObject) IfExpired() bool {
if cacheObject.TimeToLive == 0 {
return false
}
return time.Now().UnixNano() > cacheObject.TimeToLive
}
If TimeToLive is zero, the object never expires. Otherwise, it compares the current time against the expiration timestamp. Simple and effective.
The Cache Struct
The Cache itself is a map of string keys to CacheObject values, protected by a read-write mutex for thread safety:
// Cache class
type Cache struct {
objects map[string]CacheObject
mutex *sync.RWMutex
}
Using sync.RWMutex here is a good call. Multiple goroutines can read from the cache at the same time, but writes get exclusive access. That’s exactly what you want for a cache, since reads happen way more often than writes.
Creating a New Cache
The constructor is straightforward:
// NewCache method
func NewCache() *Cache {
return &Cache{
objects: make(map[string]CacheObject),
mutex: &sync.RWMutex{},
}
}
It returns a pointer to a new Cache with an empty map and a fresh mutex.
Getting and Setting Values
Reading from the cache grabs a read lock and checks if the entry has expired:
// GetObject method
func (cache Cache) GetObject(cacheKey string) string {
cache.mutex.RLock()
defer cache.mutex.RUnlock()
var object CacheObject
object = cache.objects[cacheKey]
if object.IfExpired() {
delete(cache.objects, cacheKey)
return ""
}
return object.Value
}
If the item has expired, it gets deleted from the map and you get an empty string back. Otherwise you get the stored value.
Writing to the cache uses a full lock:
// SetValue method
func (cache Cache) SetValue(cacheKey string, cacheValue string, timeToLive time.Duration) {
cache.mutex.Lock()
defer cache.mutex.Unlock()
cache.objects[cacheKey] = CacheObject{
Value: cacheValue,
TimeToLive: time.Now().Add(timeToLive).UnixNano(),
}
}
The TTL is a time.Duration, so you can pass in something like 200 * time.Millisecond or 5 * time.Second.
Putting It All Together
The main function shows the whole thing in action:
func main() {
var cache *Cache
cache = NewCache()
cache.SetValue("name", "john smith", 200000000)
var name string
name = cache.GetObject("name")
fmt.Println(name)
}
This creates a cache, stores the name “john smith” with a TTL, then retrieves it. Run it and you get john smith printed out. If you waited long enough before calling GetObject, you’d get an empty string instead because the entry would have expired.
It’s a minimal cache implementation, but it covers the core concepts: TTL, thread safety with mutexes, and lazy expiration (items get cleaned up when you try to read them, not on a background timer).
Space Allocation
Now the book switches to how Go handles memory on the stack. Every function call gets its own stack frame with its own memory space. When you call a function, data gets copied from one frame to another.
Here’s the example. The addOne function takes a number and increments it:
func addOne(num int) {
num++
fmt.Println("added to num", num, "Address of num", &num)
}
func main() {
var number int
number = 17
fmt.Println("value of number", number, "Address of number", &number)
addOne(number)
fmt.Println("value of number after adding One", number, "Address of", &number)
}
Here’s the key thing: number in main and num in addOne live at different memory addresses. Go passes by value, so addOne gets its own copy. After calling addOne, number in main is still 17. The increment only happened to the copy inside addOne’s stack frame.
This is one of those concepts that trips up beginners coming from languages where everything is a reference.
Pointers to the Rescue
If you actually want addOne to modify the original variable, you pass a pointer:
func addOne(num *int) {
*num++
fmt.Println("added to num", num, "Address of num", &num, "Value Points To", *num)
}
func main() {
var number int
number = 17
fmt.Println("value of number", number, "Address of number", &number)
addOne(&number)
fmt.Println("value of number after adding One", number, "Address of", &number)
}
Now num inside addOne holds the address of number from main. When you do *num++, you’re incrementing the value at that address, which is the original variable. After calling addOne, number in main is 18.
The pointer itself (num) has its own address on the stack (it’s a variable too), but it points to number’s address. Pointers on a 64-bit system are 8 bytes, on 32-bit they’re 4 bytes.
This is how you get indirect memory access outside a function’s stack frame. Pointers let functions reach back and modify data that belongs to their caller.
Go Memory Management Tips
The book wraps up with some practical advice about memory management in Go.
Go programmers don’t need to manually allocate or free memory. The garbage collector handles that through the GOGC environment variable, which controls when GC kicks in. The default value is 100, meaning GC runs when new allocations equal the amount of live data from the last collection. You can turn GC off entirely by setting GOGC=off, but that’s rarely a good idea.
The current GC implementation uses mark-and-sweep, which we covered in Part 1.
Some tips the book gives for better memory performance:
- Combine small objects into larger ones to reduce allocation overhead
- Watch for escaped variables, local variables that get promoted to the heap when they outlive their function
- Pre-allocate slices when you know the size upfront
- Use smaller data types like
int8instead ofintwhen the range allows it - Avoid pointers in objects that don’t need them, since the GC won’t scan pointer-free objects
- Use FreeLists to reuse temporary objects and cut down on allocations
These are solid tips. The slice pre-allocation one is especially practical. If you know you’re going to have 1000 items, make([]int, 0, 1000) is way better than letting the slice grow dynamically through multiple reallocations.
Profiling
Last topic: profiling. Go has built-in support for CPU and memory profiling using the pprof package.
You can profile tests with flags:
go test -run=none -bench=ClientServerParallel4 -cpuprofile=cprofile net/http
Then analyze the results:
go tool pprof --text http.test cprof
For custom programs, you wire up profiling like this:
var profile = flag.String("cpuprofile", "", "cpu profile to output file")
func main() {
flag.Parse()
if *profile != "" {
var file *os.File
var err error
file, err = os.Create(*profile)
if err != nil {
log.Fatal(err)
}
pprof.StartCPUProfile(file)
defer pprof.StopCPUProfile()
}
// ... rest of your program
}
You parse command-line flags, create an output file, start CPU profiling, and defer the stop call so it flushes everything when the program exits. Then you run your program with -cpuprofile=output.prof and analyze the results with go tool pprof.
Profiling is one of those things every Go developer should know but many skip. If you’re trying to optimize performance, guessing is not as good as measuring.
Chapter Summary
Chapter 10 covered a lot of ground across both parts. In Part 1 we went through garbage collection algorithms: simple reference counting, deferred reference counting, one-bit reference counting, weighted reference counting, mark-and-sweep, and generational collection.
In this Part 2, we covered cache management with TTL and mutex-based concurrency, stack-based space allocation, how pointers let you share memory across stack frames, Go memory management best practices, and profiling with pprof.
The chapter’s review questions are a good sanity check:
- What factors matter when choosing a GC algorithm?
- Which reference counting approach ignores program-variable references? (Deferred)
- Which one uses a single-bit flag? (One-bit)
- Which assigns weight to references? (Weighted)
- Who invented weighted reference counting?
- Which GC algorithm did Dijkstra propose? (Mark-and-sweep, tri-color variant)
- What class handles concurrency during mark-and-sweep? (The mutex-based synchronization)
- What promotes objects to older generations? (Surviving enough collection cycles)
- Can you draw a flowchart for cache management?
- How do you get indirect memory access outside a stack frame? (Pointers)
If you can answer most of those, you’ve got the gist of the chapter.
Final Thoughts on Chapter 10
This was a mixed chapter. The cache management section is actually useful, even if the implementation is basic. Understanding stack allocation and pointers is fundamental Go knowledge. The memory tips are practical and actionable.
The weaker parts? The reference counting algorithms from Part 1 felt more academic than practical for most Go developers, since you don’t implement your own GC. And the profiling section could have been much deeper. But as an introduction to these concepts, it does the job.
One more post to go in this series, where I’ll share my overall thoughts on whether this book was worth the read. Stay tuned.
| Previous | Chapter 10 Part 1: Memory Management and GC |
| Next | Final Thoughts: Was This Book Worth It? |