Skip to Content
Course content

218: Reducing Garbage Collector Pressure

Click on the "Edit" button in the top corner of the screen to edit your slide content.

Go's garbage collector is fantastic—it's the reason we don't spend half our lives hunting for memory leaks like we did in C++. But "automatic" doesn't mean "free." When you're building high-throughput systems, the GC can become your primary bottleneck. Not because it's slow, but because you're giving it too much work to do. This is what we call GC pressure.

The goal isn't to avoid the heap entirely, but to stop the constant cycle of allocate-use-discard. I've found that the most common culprit is the temporary buffer—those little []byte or []int slices we create inside a loop or a request handler that live for a millisecond and then vanish.

The Cost of Tiny Allocations

Let's look at a common scenario: a telemetry processor. Imagine we're receiving thousands of raw byte packets per second and we need to format them into a standardized string for a database. Here is how I'd initially write this without thinking about the GC:

type Event struct {
    ID    int
    Value float64
}

func formatEvent(e Event) string {
    // Every time this is called, we allocate a new byte slice
    buf := make([]byte, 0, 64) 
    buf = append(buf, []byte("ID:")...)
    buf = append(buf, []byte(strconv.Itoa(e.ID))...)
    buf = append(buf, []byte(" Val:")...)
    buf = append(buf, []byte(fmt.Sprintf("%.2f", e.Value))...)
    return string(buf)
}

On the surface, this is clean. But if you're processing 100,000 events per second, you're creating 100,000 short-lived slices. The GC has to track every single one of those, mark them as unreachable, and sweep them. You'll see your CPU usage spike, not because of your business logic, but because the runtime is frantically cleaning up after you.

The Dangerous Shortcut

When I first encountered this in a production app, my first instinct was to just move the buffer outside the function. I thought, "Why allocate every time? I'll just keep one buffer and reuse it."

var globalBuf = make([]byte, 0, 64)

func formatEventFast(e Event) string {
    globalBuf = globalBuf[:0] // Reset length, keep capacity
    globalBuf = append(globalBuf, []byte("ID:")...)
    // ... append the rest ...
    return string(globalBuf)
}

I pushed this to a staging environment and it crashed almost immediately. Why? Because I forgot that Go services are concurrent. Two goroutines tried to write to globalBuf at the same time, leading to a classic data race and corrupted strings. Never use a global slice for temporary work unless you have a very good reason and a very strong Mutex (which would then kill your performance anyway).

Implementing a Recyclable Buffer

The professional way to handle this is via sync.Pool. It's essentially a cache of objects that the GC can reclaim if the system is under memory pressure, but which you can reuse to avoid new allocations.

Here is how I'd actually implement the telemetry formatter to keep the GC happy:

var bufPool = sync.Pool{
    New: func() any {
        // We allocate a slice with a reasonable capacity
        b := make([]byte, 0, 64)
        return &b
    },
}

func formatEventOptimized(e Event) string {
    // Grab a buffer from the pool
    ptr := bufPool.Get().(*[]byte)
    buf := *ptr

    // Reset the slice but keep the underlying array
    buf = buf[:0]

    buf = append(buf, "ID:"...)
    buf = append(buf, strconv.Itoa(e.ID)...)
    buf = append(buf, " Val:"...)
    buf = append(buf, fmt.Sprintf("%.2f", e.Value)...)

    res := string(buf)

    // Put the buffer back for the next goroutine to use
    *ptr = buf
    bufPool.Put(ptr)

    return res
}

Notice a few key things here. First, I'm storing a pointer to the slice (*[]byte) in the pool. This is because any (interface{}) takes up space, and passing the slice header by value can sometimes cause an accidental allocation itself. Second, I reset the length to zero using buf[:0], which keeps the capacity intact. Now, instead of 100,000 allocations per second, we might only have a few dozen—one for each active goroutine.

One final tip: when you're debugging this, run your tests with go test -bench . -benchmem. If you see "allocs/op" dropping toward zero, you know you've won.




📋 Practical Task

Exercise: Optimizing the Log Header Generator

You have a high-frequency logging function that attaches a metadata header to every log message. Currently, it allocates a new byte slice for every single call, causing significant GC pauses in a high-traffic environment.

Your Task: Refactor the generateHeader function below. Implement a sync.Pool to reuse the byte slices, ensuring that the implementation is thread-safe and minimizes heap allocations per call.

package main

import (
    "fmt"
    "sync"
    "time"
)

// Current inefficient implementation
func generateHeader(requestID string, userID int) string {
    buf := make([]byte, 0, 128)
    buf = append(buf, "[Req:"...)
    buf = append(buf, requestID...)
    buf = append(buf, "] [User:"...)
    buf = append(buf, []byte(fmt.Sprintf("%d", userID))...)
    buf = append(buf, "] ")
    return string(buf)
}

func main() {
    // Test the function
    fmt.Println(generateHeader("abc-123", 42))
}

Requirements:

  • Create a sync.Pool that allocates a []byte with a capacity of 128.
  • Modify generateHeader to retrieve a buffer from the pool, reset it, and return it to the pool after the string is created.
  • Ensure you are handling the pool types correctly (using type assertion).
Rating
0 0

There are no comments for now.

to be the first to leave a comment.