Memory leaks are among the toughest problems a software engineer may need to deal with. A program consumes huge amounts of memory, possibly crashes as a result, and it’s not immediately apparent why. Different languages have different tools to deal with resource problems. In Go, we can use the built-in profiler called pprof.
To see it in action, we’ll consider a simple program that allocates some memory. It’s not the best example, but real situations with memory leaks are both hard to troubleshoot and to make up. So instead of trying to come up with a really good example, I’ll show you how to use the tooling on a simple one, and then you can apply the same steps when you encounter a real problem situation.
package main
import (
"fmt"
)
func main() {
const size = 1000000
waste := make([]int, size)
for i := 0; i < size; i++ {
waste[i] = i
}
fmt.Println("Done.")
}
The above code is nothing special, right? It’s clear where we’re allocating memory. However, real situations aren’t always so simple, and so we’ll use pprof to help us understand what’s going on under the hood. We do this by adding a couple of imports and setting up an endpoint as follows:
package main
import (
"fmt"
"net/http"
_ "net/http/pprof"
)
func main() {
const size = 1000000
waste := make([]int, size)
for i := 0; i < size; i++ {
waste[i] = i
}
http.ListenAndServe("localhost:8090", nil)
fmt.Println("Done.")
}
Before we go on, there are a few things to note at this point:
- The port is arbitrary. You can use any you want, as long as it doesn’t conflict with something else.
- If you want to access the endpoint from another machine, use
0.0.0.0
instead oflocalhost
. - It’s generally not a good idea to include profiling code with a program by default, because its operation can cause nontrivial resource consumption, and can also expose internal details about the code that can have security and intellectual property implications. So, do this only in case of necessity and in a controlled environment.
- The
ListenAndServe()
is blocking, so in this case you won’t see “Done.” printed afterwards. If you need code to resume afterwards (e.g. you’re using a web framework such as Gin), runListenAndServe()
at the beginning in a goroutine like:
go func() {
err := http.ListenAndServe("localhost:8090", nil)
if err != nil {
fmt.Println(err)
}
}()
So, once you’ve got pprof set up, you can run the program and, from a separate terminal, access /debug/pprof
relative to the endpoint you specified:
From here, if we click on “heap”, we get a dump of heap (memory) information:
Because this output is rather cryptic, our problem now shifts from obtaining memory data to interpreting it. Grab a copy of this heap dump either by saving directly from the browser, or using the following command. If it’s on a server, copy it over to your local system using scp
or similar. We’ll inspect the memory using this dump, but hang onto it so that you can compare the state before and after a possible fix later.
curl http://localhost:8090/debug/pprof/heap > heap.dump
Once you have the heap dump on your local system, there are different ways to inspect it. The easiest way I found is to use pprof’s -http
switch to run a local web server that gives you a few different views. Let’s try that:
go tool pprof -http=localhost:8091 heap.dump
Again, the port here is arbitrary but it needs to be different from the one you specified in the code. That was for the pprof endpoint, whereas this one is for pprof’s analysis tool. Once this is running, we can open localhost:8091 to understand a little more:
This tool has a few different ways you can use to inspect memory allocation in the program’s heap. I find the graph, flame graph, top and source views most useful, but there are others. Because a program’s memory structures can be very complex, it often helps to use several of these views to look at it from different angles. For instance, the graph view gives us an idea of the extent of memory allocation as functions call each other, but the source view actually tells us the exact line that is allocating memory.
Keep in mind that the fact that memory is being allocated does not necessarily imply that there is a problem. So, in practice you’ll need to diligently spend time inspecting several heap dumps of the same program at different times or with different changes. Every program is different, so there’s no general-purpose advice that can be offered to solve memory leaks. pprof can help you gather and view data about the program’s memory, but it’s up to you to understand the nature of how your program behaves and be able to spot bad behaviour in the heap dumps.
While I prefer the above approach, some people like inspecting heap dumps directly in the terminal using pprof. For instance, you can run pprof without the -http
switch to enter interactive mode and then use the svg command to generate the graph view as an image:
$ go tool pprof heap.dump
File: __debug_bin2191375990
Type: inuse_space
Time: Jun 16, 2024 at 12:36pm (CEST)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) svg
Generating report in profile001.svg
(pprof)
Personally, I think this is a little more cumbersome because you have to learn pprof’s specific commands, it requires graphviz to be installed in some situations (for the image generation), and it takes more effort to see the different views that the web-based approach offers trivially. But, if you prefer it this way, the option is there.
So, hopefully the steps above should make pprof really easy for you to use. I’d like to express my gratitude to the following sources that helped me figure out how to use it originally, and contain some more detail if you need it:
- pprof GitHub repo
- pprof docs – particularly the “Interpreting the Callgraph” and “Web Interface” sections
- Profiling Go Programs (Go Blog)
- “Investigating Golang Memory Leak with Pprof” (Aviv Zohari, 21st November 2023)
- “How I investigated memory leaks in Go using pprof on a large codebase” (Jonathan Levison, 11th April 2019)
- “SRE: Debugging: Simple Memory Leaks in Go” (dm03514, 3rd August 2018)