Go Module 15 – Concurrency patterns in Go
Goroutine leaks and the goroutineleak profile
Find leaked goroutines with the goroutineleak profile of Go 1.27 and with goleak and synctest in tests, see what the profile misses, and fix each kind of leak.
What you will learn
- Recognise the common causes of goroutine leaks: early returns, channels nobody closes and loops nobody stops
- Detect leaks with the goroutineleak profile of Go 1.27, and say which leaks it cannot see
- Catch leaks in tests with goleak and synctest
- Fix leaks with buffered channels, contexts and one owner who closes
Before you start
What you’ll learn · Before you start
What you will learn
- Recognise the common causes of goroutine leaks: early returns, channels nobody closes and loops nobody stops
- Detect leaks with the goroutineleak profile of Go 1.27, and say which leaks it cannot see
- Catch leaks in tests with goleak and synctest
- Fix leaks with buffered channels, contexts and one owner who closes
Before you start
On this page
A function that checks five items at once and returns at the first error can leave the goroutines of the later checks waiting for ever: in leak.go below, one call left 3 goroutines behind, and the runtime said nothing about them. At 2,000 failing calls an hour, that is 6,000 goroutines an hour that never end, each with its stack and whatever it refers to.
Goroutine leaks like this one are invisible until memory runs out. You will see how they happen, find them with the goroutineleak profile: A profile of runtime/pprof, generally available since Go 1.27, that reports goroutines blocked on a channel or lock that no goroutine able to run can reach, so that nothing can ever wake them. that Go 1.27 made generally available, and name the fix for each kind. The output shown was recorded with Go 1.27.1.
A leaked goroutine waits for ever on something nobody will touch
A goroutine leak: A goroutine that stays blocked for ever, such as on a send that nobody will receive, so that its stack and everything it refers to stay in memory. is a goroutine that will never run again and never return. The Go 1.26 release notes show a common shape of it: goroutines send their results on an unbuffered channel, and the receiver returns early, at the first error. leak.go does the same, with checks that take 20 ms per item and an error at item 2, once with a channel buffered for all five results and once unbuffered:
// leak.go: checkOrder checks the five items of an order at the same time and returns at the first error, the shape of
// the example in the Go 1.26 release notes. Run with a result channel buffered for every item, nothing is left behind.
// Run with an unbuffered one, the goroutines whose checks finish after the error wait for ever to send a result that
// nobody will receive, and Go 1.27's goroutineleak profile reports them.
package main
import (
"bufio"
"bytes"
"errors"
"fmt"
"path/filepath"
"runtime"
"runtime/pprof"
"strings"
"time"
)
// check stands for one check of an item: item i takes i × 20 ms, and item 2 fails.
func check(item int) error {
time.Sleep(time.Duration(item) * 20 * time.Millisecond)
if item == 2 {
return errors.New("item 2: out of stock")
}
return nil
}
func checkOrder(items []int, buffer int) error {
results := make(chan error, buffer)
for _, item := range items {
go func() { results <- check(item) }()
}
for range items {
if err := <-results; err != nil {
return err // the early return: nobody receives the remaining results
}
}
return nil
}
// leakReport prints the goroutineleak profile's total, and where its leaked goroutines of package main wait.
func leakReport() {
var out bytes.Buffer
pprof.Lookup("goroutineleak").WriteTo(&out, 1)
sc := bufio.NewScanner(&out)
count := ""
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "goroutineleak profile:"):
fmt.Println(" ", line)
case strings.Contains(line, " @ "):
count = strings.Fields(line)[0] // "3 @ 0x… 0x…": how many goroutines share the stack below
case strings.HasPrefix(line, "#") && strings.Contains(line, "main.") && count != "":
f := strings.Fields(line) // "# 0x… main.checkOrder.func1+0x77 /…/leak.go:31"
fn := f[2][:strings.LastIndex(f[2], "+")]
fmt.Printf(" %s waiting in %s at %s\n", count, fn, filepath.Base(f[3]))
count = ""
}
}
}
// run checks one order, waits until every check has finished, and reports what is left.
func run(name string, buffer int) {
before := runtime.NumGoroutine()
fmt.Printf("%s: %v\n", name, checkOrder([]int{1, 2, 3, 4, 5}, buffer))
time.Sleep(150 * time.Millisecond)
fmt.Println(" goroutines left:", runtime.NumGoroutine()-before)
leakReport()
}
func main() {
run("buffered for 5 results", 5)
run("unbuffered", 0)
} Output
buffered for 5 results: item 2: out of stock goroutines left: 0 goroutineleak profile: total 0 unbuffered: item 2: out of stock goroutines left: 3 goroutineleak profile: total 3 3 waiting in main.checkOrder.func1 at leak.go:31
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run leak.go
With the buffer, each check could send its result and return, so nothing was left. Unbuffered, the checks of items 3,
4 and 5 finished after checkOrder had returned and waited to send to a receiver that no longer existed: 3 goroutines
left, counted by runtime.NumGoroutine. The runtime’s deadlock check never fires for them, because it needs every
goroutine of the program to be blocked, and main kept running.
Most goroutine leaks are one of three shapes: a send or a receive on a channel that its other side has abandoned, a channel that its owner never closes, and a loop, often on a ticker, that nothing tells to stop.
Checkpoint
Check: What a goroutine leak is
The goroutineleak profile reports goroutines that can never wake
Go 1.27 made the goroutineleak profile generally available. It is a profile of runtime/pprof, read with
pprof.Lookup("goroutineleak"), and a program that serves net/http/pprof offers it at /debug/pprof/goroutineleak.
The release notes explain how it decides: the runtime runs a garbage collection that marks only from goroutines that
can run, and a goroutine blocked on a channel or lock that none of them can reach can never wake. leak.go’s last lines
came from that profile: total 3, all waiting in checkOrder.func1 at the send of line 31. Step through how it found
them:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Five checks start. checkOrder starts one goroutine per item. Each will send its result on results, a channel with no buffer, so each send waits until checkOrder receives.
- The early return. Item 1 reports at 20 ms and item 2's error at 40 ms. checkOrder returns that error at once, and nobody receives from results any more.
- Three senders wait. Items 3, 4 and 5 finish at 60, 80 and 100 ms and try to send. No receiver will ever come, so each waits in its send for good.
- Nothing else reaches results. Once checkOrder has returned, the only references to results are the three senders' own. main runs on and refers to it nowhere.
- The leak profile looks. Asked for the goroutineleak profile, the runtime runs a garbage collection that marks only from goroutines that can run, here main. It never reaches results.
- Three leaks reported. A goroutine blocked on something no runnable goroutine can reach can never wake, so all three are leaked. The profile says total 3, at the send line.
- The buffered run. With room in results for all 5 errors, every send completes at once and its goroutine returns. There is nothing to report, as the first run showed.
Because it works by reachability, the release notes add, the profile can miss leaks whose channel or lock a package-level variable or a running goroutine can still reach. Four tools between them see what any one misses:
| Detector | Where it runs | What it reports |
|---|---|---|
| the goroutineleak profile | a running program, production too | goroutines blocked on something unreachable |
runtime.NumGoroutine |
anywhere, as a metric | how many goroutines exist, leaked or not |
goleak.VerifyNone |
a test | goroutines still running when the test ends |
synctest.Test |
a test | goroutines of its bubble still blocked when it returns |
Each kind of leak has its fix: a buffer with room for every sender’s result, a select with ctx.Done() beside
every send and receive, and one owner who closes each channel.
Key idea
The goroutineleak profile reports a goroutine when nothing that can still run could ever wake it, which catches early returns and unclosed channels, and misses what a global or a running goroutine can still reach.
Checkpoint
Check: The goroutineleak profile and the fixes
Four kinds of leak, reproduced, and the two the profile misses Pro: opens with a Pro, Premium or Ultimate pass
Four goroutines that never end, one of each common kind, show where each detector helps. This part runs them and
their fixes, compares what runtime.NumGoroutine counts with what the goroutineleak profile reports, and explains
the two leaks the profile cannot see.
In this part:
- Four leaks of four kinds, and what each detector saw
- Why the ticker loop and the global channel escape the profile
- Each fix, and why it is the right one
- What a leak costs, worked out
1 runnable example (Go) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Leak checks in tests and in production Pro: opens with a Pro, Premium or Ultimate pass
A leak found by a test costs a minute; one found by an out-of-memory alert costs a night. This part runs the two test checks on leak.go’s function, reproduces the trap that makes one leaky test fail its neighbours, and serves the profile safely in a running service.
In this part:
- goleak fails the test that leaks, and the next test too
- synctest wants every goroutine of its bubble finished
- The profile in production, on an internal port
3 runnable examples (Go, Go (order.go), Go (order_test.go), MOD, Python) · about 5 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise: three functions that leak
practice/leaks.go holds three functions that each leave goroutines behind in their own way. Fix each so that it leaves none, without changing what it returns. practice/leaks_test.go asks the goroutineleak profile, counting only each test’s own function, since a leak lives on for the whole test binary:
Go (leaks.go) · practice/leaks.go
// Package practice is the practice of the lesson on goroutine leaks: three functions that each leave goroutines
// behind in their own way. Fix each one so that it leaves none, without changing what it returns.
package practice
import "time"
// FirstError runs check for every item at the same time and returns the first error, or nil.
func FirstError(items []int, check func(item int) error) error {
errs := make(chan error)
for _, item := range items {
go func() { errs <- check(item) }()
}
for range items {
if err := <-errs; err != nil {
return err
}
}
return nil
}
// WithTimeout runs work and returns its result, or "", false if work takes longer than d.
func WithTimeout(work func() string, d time.Duration) (string, bool) {
result := make(chan string)
go func() { result <- work() }()
select {
case r := <-result:
return r, true
case <-time.After(d):
return "", false
}
}
// PlaceOrders hands every order to a notifier goroutine, which calls notify for each, and returns how many it placed.
func PlaceOrders(ids []string, notify func(id string)) int {
orders := make(chan string)
go func() {
for id := range orders {
notify(id)
}
}()
for _, id := range ids {
orders <- id
}
return len(ids)
} Go (leaks_test.go) · practice/leaks_test.go
package practice
import (
"bufio"
"bytes"
"errors"
"runtime/pprof"
"strconv"
"strings"
"testing"
"time"
)
// leaked counts the goroutines that the goroutineleak profile reports with fn in their stack. Leaks of one test stay
// alive for the whole test binary, so each test counts only those of its own function.
func leaked(t *testing.T, fn string) int {
t.Helper()
time.Sleep(100 * time.Millisecond) // let every goroutine the function started finish or block
var out bytes.Buffer
if err := pprof.Lookup("goroutineleak").WriteTo(&out, 1); err != nil {
t.Fatal(err)
}
total, count := 0, 0
sc := bufio.NewScanner(&out)
for sc.Scan() {
line := sc.Text()
if n, _, ok := strings.Cut(line, " @ "); ok {
count, _ = strconv.Atoi(n)
} else if strings.HasPrefix(line, "#") && strings.Contains(line, "practice."+fn) {
total += count
count = 0 // count each stack once
}
}
return total
}
func TestFirstError(t *testing.T) {
broken := errors.New("item 2: out of stock")
err := FirstError([]int{1, 2, 3, 4, 5}, func(item int) error {
time.Sleep(time.Duration(item) * 5 * time.Millisecond)
if item == 2 {
return broken
}
return nil
})
if !errors.Is(err, broken) {
t.Fatalf("FirstError returned %v; want item 2's error", err)
}
if n := leaked(t, "FirstError"); n != 0 {
t.Fatalf("leaked goroutines of FirstError: %d; want 0", n)
}
}
func TestWithTimeout(t *testing.T) {
r, ok := WithTimeout(func() string {
time.Sleep(50 * time.Millisecond)
return "report"
}, 10*time.Millisecond)
if r != "" || ok {
t.Fatalf("WithTimeout returned %q, %v; want \"\", false", r, ok)
}
if n := leaked(t, "WithTimeout"); n != 0 {
t.Fatalf("leaked goroutines of WithTimeout: %d; want 0", n)
}
}
func TestPlaceOrders(t *testing.T) {
var notified []string
n := PlaceOrders([]string{"A-1", "A-2", "A-3"}, func(id string) { notified = append(notified, id) })
if n != 3 {
t.Fatalf("PlaceOrders returned %d; want 3", n)
}
if n := leaked(t, "PlaceOrders"); n != 0 {
t.Fatalf("leaked goroutines of PlaceOrders: %d; want 0", n)
}
if len(notified) != 3 {
t.Fatalf("notified %v; want all 3 orders", notified)
}
} The starter code compiles, and every test fails:
"""The three functions of practice/leaks.go against their tests, which ask the goroutineleak profile."""
import session
session.run(
"""
cd practice && go test .
""",
expect_failure=["cd practice && go test ."],
) Output
$ cd practice && go test .
--- FAIL: TestFirstError (…s)
leaks_test.go:50: leaked goroutines of FirstError: 3; want 0
--- FAIL: TestWithTimeout (…s)
leaks_test.go:63: leaked goroutines of WithTimeout: 1; want 0
--- FAIL: TestPlaceOrders (…s)
leaks_test.go:74: leaked goroutines of PlaceOrders: 1; want 0
FAIL
FAIL example.com/practice …s
FAIL
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py
Make go test . pass, then go test -race . as well. A first hint: FirstError and WithTimeout each need a channel
with room for every sender, and PlaceOrders needs one owner who closes the channel and waits for the reader to finish.
The self-check below covers the two sections outside the locked parts.
Practice that adapts to you
Practise this lesson
7 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
The interview set for leaks: a warm-up with a full answer first, and three deeper questions from the module bank:
-
Fresher to mid · Warm-up
What is a goroutine leak, and why does the Go runtime not report it as a deadlock?
A goroutine leak is a goroutine that will never run again and never return, such as one waiting for ever to send a result that its caller stopped waiting for. Goroutines are not garbage collected, so it keeps its stack and everything it refers to until the program ends. The runtime reports a deadlock only when every goroutine of the program is blocked; in a server, other goroutines keep running, so a leak stays silent. It shows up as a goroutine count and memory that grow over hours. Go 1.27's goroutineleak profile reports goroutines blocked on something no runnable goroutine can reach, and tests can catch leaks with goleak or synctest.
-
Mid to senior
A Go service's memory climbs steadily between deploys and drops after each restart, and runtime.NumGoroutine climbs the same way. How do you find the leak, and what do you look for in the code once you know where the goroutines wait?
-
Senior to staff
runtime.NumGoroutine rises by thousands a day, but the goroutineleak profile reports total 0. Which kinds of leak can hide from the profile, and how do you find this one?
-
Mid to senior
After goleak.VerifyNone was added to every test of a package, its tests fail in different places depending on which tests run, and some tests of correct code fail. Explain what happens, and set up leak checks whose failures point at the test that leaked.
Key takeaways
- Goroutine leaks are goroutines that never return: a send or receive whose other side left, a channel nobody closes, or a loop nobody stops.
- Go 1.27’s goroutineleak profile reports goroutines blocked on something no runnable goroutine can reach, and can miss what a global or a running goroutine still reaches.
- The fixes are a buffer for every sender, a
selectwithctx.Done(), and one owner who closes each channel.
Terms in this lesson
- goroutineleak profile
- A profile of runtime/pprof, generally available since Go 1.27, that reports goroutines blocked on a channel or lock that no goroutine able to run can reach, so that nothing can ever wake them.
References
- Go 1.27 Release Notes: Goroutine leak profile (The Go Authors)
- Go 1.26 Release Notes: Goroutine leak profile (the experiment and its example) (The Go Authors)
- Goroutine Leak Profiles (The Go Blog) (The Go Authors)
- Package runtime/pprof: Profile (The Go Authors)
- Package net/http/pprof (The Go Authors)
- Package go.uber.org/goleak (Uber Technologies (goleak authors))
- Package testing/synctest (The Go Authors)
- Go 1.23 Release Notes: Timer changes (The Go Authors)
- Go Concurrency Patterns: Pipelines and cancellation (The Go Blog) (The Go Authors)
Report a problem with this lesson
Kept only in this browser. Your Learn progress