The most surprising thing about memory profiling is that the worst offenders are often the smallest, most innocent-looking objects.
Let’s see it in action. Imagine a simple Go service that reads a configuration file, parses it into a struct, and then spins up a few goroutines that periodically access that config.
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"runtime"
"sync"
"time"
)
type Config struct {
ServerName string `json:"server_name"`
Port int `json:"port"`
Users []string `json:"users"`
}
var globalConfig Config
var mu sync.RWMutex
func loadConfig() {
data, err := ioutil.ReadFile("config.json")
if err != nil {
fmt.Printf("Error reading config: %v\n", err)
return
}
var cfg Config
err = json.Unmarshal(data, &cfg)
if err != nil {
fmt.Printf("Error unmarshalling config: %v\n", err)
return
}
mu.Lock()
globalConfig = cfg
mu.Unlock()
fmt.Println("Config loaded successfully")
}
func configHandler(w http.ResponseWriter, r *http.Request) {
mu.RLock()
cfg := globalConfig // This is where the problem can start
mu.RUnlock()
fmt.Fprintf(w, "Server: %s, Port: %d, Users: %v", cfg.ServerName, cfg.Port, cfg.Users)
}
func main() {
// Initial config load
loadConfig()
// Start a background task to reload config periodically
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
loadConfig()
}
}()
http.HandleFunc("/config", configHandler)
fmt.Println("Starting server on :8080")
http.ListenAndServe(":8080", nil)
}
And a sample config.json:
{
"server_name": "MyAwesomeServer",
"port": 8080,
"users": ["alice", "bob", "charlie", "david", "eve", "frank", "grace", "heidi", "ivan", "judy"]
}
Now, let’s imagine this service is running, and we notice its memory usage creeping up over time. It’s not crashing, but it’s definitely growing.
The first instinct might be to look for large data structures being allocated and never freed. But often, the culprit is subtler: a slow leak caused by a constantly growing collection, or a large number of small, short-lived objects that aren’t garbage collected efficiently enough.
To diagnose this, we’ll use Go’s built-in profiling tools. We need to enable the pprof HTTP endpoint. We can add this to our main function:
import _ "net/http/pprof" // Import this package
// ... in main() ...
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// ...
Now, when our service runs, we can access profiling data at http://localhost:6060/debug/pprof/.
The most common tool for analyzing memory usage is the heap profile. We can grab a snapshot of the heap after the service has been running for a while:
curl http://localhost:6060/debug/pprof/heap > heap.out
Then, we use the go tool pprof command to analyze this snapshot:
go tool pprof heap.out
Inside the pprof interactive shell, we can ask questions. A good starting point is to see the "top" memory consumers:
(pprof) top
This might show us something like:
flat% flat cumulative% cumulative size:type heap
50.00% 10.00MB 50.00% 10.00MB 10MB:[]string main.globalConfig
30.00% 6.00MB 80.00% 16.00MB 6MB:sync.PoolEntry runtime.sync.Pool
10.00% 2.00MB 90.00% 18.00MB 2MB:string encoding/json.Unmarshal
...
In our example, the main.globalConfig is taking up a significant chunk. This is the Config struct. If we were to repeatedly call loadConfig with larger Users slices, this globalConfig would grow. The problem isn’t necessarily that globalConfig itself is a leak, but if the Users slice inside it is always growing on each reload and never shrinking, that’s a leak within the globalConfig.
The cumulative column shows the total memory allocated by that function and all functions it calls. The flat column shows memory allocated directly by that function.
If we see a lot of memory associated with encoding/json.Unmarshal, it might indicate that we’re repeatedly parsing large JSON documents.
Another crucial command is list <function_name> to see the source code and memory allocations within a specific function. For example, list main.loadConfig.
The key to detecting leaks is observing trends over time. You’d want to take multiple heap snapshots at different points in time and compare them. The go tool pprof command can do this directly:
go tool pprof http://localhost:6060/debug/pprof/heap
This connects directly to the running application. Then, after some time, you can run:
(pprof) top
again. If the memory usage for specific allocations has increased significantly between snapshots, you’ve likely found your leak.
To fix the globalConfig issue, if the Users slice was growing uncontrollably, the solution would be to ensure that the new Config struct created during loadConfig actually replaces the old one entirely, and that the old one, if it held a larger Users slice, becomes eligible for garbage collection. In our current code, this is handled correctly by the assignment globalConfig = cfg. The GC will reclaim the memory of the old globalConfig when it’s no longer referenced.
The real problem often lies in how data is being managed. If, for instance, a goroutine was holding onto a reference to an old version of globalConfig for too long, that memory wouldn’t be reclaimed. In our configHandler, cfg := globalConfig copies the value of the Config struct. If Config contained pointers or slices, these would be copied by value, but the underlying data they point to would remain the same. The Users slice itself is a header that contains a pointer to an array on the heap. When globalConfig = cfg happens, the Config struct is copied, including the header for the Users slice. The garbage collector will only reclaim the old Users slice’s underlying array if no references to it exist.
This is why understanding the garbage collector’s behavior is paramount. It tracks reachability. If an object is reachable from a root (like a global variable or a goroutine’s stack), it’s kept alive. Memory bloat can occur when objects are unintentionally kept alive, perhaps because a long-running goroutine still has a reference to them, or because a data structure is designed to grow indefinitely without a mechanism to prune old data.
The next step after understanding heap profiles is to look at goroutine profiles to see if you have an excessive number of goroutines, or goroutines that are stuck and not terminating, which can indirectly lead to memory issues by holding onto resources.