返回资讯中心
外部精选
后端
#Go

Goroutine Leak Profiles

Go 1.27 includes new goroutine leak profiles.

Go BlogVlad Saioc30 分钟阅读

以下正文同步自 Go Blog,版权归原站所有,已转换为易读排版。

The Go Blog

Goroutine Leak Profiles

Vlad Saioc

2 September 2026

Go’s concurrency features are powerful and easy to use, but

that same ease can sometimes lead even seasoned developers to make

mistakes.

Fortunately, the Go ecosystem comes equipped with useful tools for

debugging, e.g., the race detector,

but even existing tools may miss some concurrency bugs,

such as the topic of this article, the goroutine leak.

Goroutines synchronize or exchange information

via shared concurrency primitives, e.g., channels, locks, and wait groups.

While communicating, goroutines often block on these primitives,

as in, wait until some condition is met;

ubiquitous examples include waiting to acquire a held mutex,

or receive a message over a channel.

Goroutines can also block on operating system operations, like reading from a network socket or a file.

We may consider a goroutine leaked if it is blocked,

but the conditions needed to unblock it can never be met.

Over time, an accumulation of leaked goroutines degrades

performance through excessive memory usage (by the leaked

goroutines themselves or the memory they reference), as well as

CPU usage from the garbage collector, especially

if GOMEMLIMIT is in use.

Goroutine leaks can be notoriously difficult to detect.

In unit testing, the most significant breakthroughs include

the open-source library goleak,

which can instrument individual tests to signal any

un-terminated goroutines after the test wraps up as suspicious.

Similarly, Go 1.25 introduced the synctest package to

the standard library; it can significantly improve

the quality of unit tests in concurrent code by giving

Go developers more control over the ordering of concurrent events

in order to reliably test hard-to-reproduce scenarios.

Unfortunately, neither approach can check for goroutine leaks

in production systems, especially at larger scales,

which may behave in ways unaccounted for by tests.

Goroutine profiles are a rudimentary way to check for operations

that block too many goroutines, or analyze growth trends.

However, goroutine profiles cannot distinguish between

goroutines which are leaked, and those which are temporarily blocked

in high numbers by design, e.g., as caused by increased

traffic in a microservice.

Likewise, leaks which are low in number may slip by undetected for many years.

Go 1.27 introduces the goroutine leak profiler,

a flexible and lightweight mechanism for finding

goroutine leaks in running Go programs, including production systems.

Unlike previous approaches, which require human analysis,

this mechanism is precise and generates little-to-no false positives.

The trade-off is that it is limited to a subset of goroutine leaks:

goroutines permanently blocked on channels or primitives

in the sync package.

Luckily for us, this already covers a very large subset of goroutine leaks,

as we’ll see in our examples.

In the following sections, we showcase how to use the feature, followed by

some additional examples of detectable leaks, and a description of the

underlying implementation and trade-offs.

Example: concurrent workers

Consider a function that processes work items concurrently:

type result struct {

res workResult

err error

}

func processWorkItems(ws []workItem) ([]workResult, error) {

// Process work items in parallel, aggregating results in ch.

ch := make(chan result)

for _, w := range ws {

go func() {

res, err := processWorkItem(w)

ch

Because ch is an unbuffered channel, each worker goroutine blocks when sending

its result until the main goroutine receives from the channel.

If processWorkItems returns early due to an error, the receiving loop terminates,

and all remaining sender goroutines block forever.

This example is emblematic of a common mistake discovered in real Go programs,

including Uber production services.

Let’s see how we can find these leaks by using the

new goroutine leak profiler.

Debugging with the goroutine leak profiler

The profile is available through the

runtime/pprof package, as the

goroutineleak profile type, or by installing the profile handlers defined

by the net/http/pprof package.

If you already have net/http/pprof set up in your service,

then you don’t need to do anything else! The profile will be

automatically made available for collection at the /debug/pprof/goroutineleak

endpoint on whatever host and port the handlers are installed.

Let’s put our concurrency bug in context and set up the net/http/pprof package.

This way, you can try it yourself!

package main

import (

"errors"

"log"

"net/http"

_ "net/http/pprof"

"time"

)

type workItem int

type workResult int

func processWorkItem(w workItem) (workResult, error) {

time.Sleep(10 * time.Millisecond)

if w == 5 {

return 0, errors.New("simulated error")

}

return workResult(w * 2), nil

}

type result struct {

res workResult

err error

}

func processWorkItems(ws []workItem) ([]workResult, error) {

ch := make(chan result)

for _, w := range ws {

go func() {

res, err := processWorkItem(w)

ch

Build the program above, then run it:

$ go build -o leaky

$ ./leaky

Collecting the profile

It won’t take long for the program to start accumulating

leaks, which you can then view by using the web UI

at http://localhost:6060/debug/pprof.

Alternatively, you can collect the goroutine

leak profile using curl, and then examine it with go tool pprof:

$ curl http://localhost:6060/debug/pprof/goroutineleak > leak.prof

$ go tool pprof leak.prof

Type: goroutineleak

Time: 2026-03-01 13:19:49 UTC

Entering interactive mode (type "help" for commands, "o" for options)

(pprof) list processWorkItems

Total: 116

ROUTINE ======================== main.processWorkItems.func1 in .../main.go

0 116 (flat, cum) 100% of Total

. . 31: go func() {

. . 32: res, err := processWorkItem(w)

. 116 33: ch

The profile reveals the goroutines leaked at

ch (line 33), pinpointing the culprit operation.

Notably, the longer the program is running, the larger the number of leaked

goroutines.

Addressing the leak

This leak can be simply fixed by giving ch a buffer:

ch := make(chan result, len(ws))

This allows all the work item goroutines to send a message without blocking

in the event of a premature return of processWorkItems.

We list more real-world examples in this section.

Implementation

This section is for those interested how leak detection

works under the hood of the goroutine leak profiler.

For details strictly pertaining to performance overhead and limitations,

skip ahead to this section.

Core concept

Let’s start with an initial observation: if a goroutine

is blocked over some concurrency primitive that no other goroutine has access to

(in this case, via a reference in memory), then it is obviously leaked.

This is already a strong lead, we can generalize it further into a definition

for when a goroutine is not leaked, a property we term as liveness.

We formally define liveness, an inductive property

as follows:

A goroutine is live if:

- it is not blocked by a concurrency primitive, or

- at least one concurrency primitive that blocks it is referenced

by another live goroutine.

In the trivial case, goroutines which are not blocked are obviously

not leaked.

In the inductive case, the underlying assumption is that

any goroutine which is not leaked may eventually use

concurrency primitives it references to unblock any

other goroutines blocked by those primitives.

To find all live goroutines, we start from the obviously live

unblocked goroutines and trace any references

they hold, i.e., through their local variables, to find

the concurrency primitives they have access to.

We then incrementally include any goroutines blocked over those

primitives as live, and repeat the process until no

additional live goroutines are discovered.

Fortunately for us, the Go runtime already computes memory reachability

through the garbage collector (GC),

so the next step is to adapt the GC to suit our purposes.

You can quickly compare the two GCs with the following diagrams:

A complete overhaul of the GC is not necessary.

The Go runtime uses a concurrent tri-color mark-and-sweep garbage collector,

(now with the Green Tea variant!),

so its MO already neatly aligns with our goals.

Only a few key changes are needed:

- In the initial phases, the regular GC marks all goroutines (and global variables)

as reachable, such that they would never be considered garbage,

i.e., they are mark roots.

We change it to instead only include unblocked goroutines,

since these are guaranteed to be live.

- This is followed by the marking phase, where the GC traces objects referenced

(transitively) by the mark roots, and marks them as usable memory.

Even though we do not modify this phase directly, the changes in step 1.

implicitly ensure that the GC only marks memory referenced by live goroutines.

- The marking phase is finalized by inspecting all the blocked

goroutines not included as mark roots in step 1.

If a goroutine is blocked by at least one concurrency

primitive that has been marked in step 2., it is added as a mark root,

and the GC resumes the marking phase from step 2.

This coincides with the inductive step in the definition

of liveness.

- Once all live goroutines have been discovered, any goroutine

which has not been added as a mark root has its status set to leaked.

- The marking phase then resumes one last time with all the leaked goroutines

added as mark roots, allowing the GC to mark all the memory it would have

marked during a regular run.

Once the GC cycle is complete, the goroutine leak profiler picks up

like in a regular goroutine profile, and filters for strictly

leaked goroutines.

Limitations

The examples above demonstrate the usefulness of goroutine leak profiles.

Nevertheless, the garbage collector has some limitations that may lead

it to miss leaks:

-

Memory overreach: if a concurrency primitive is

consistently reachable through global variables or runnable goroutines,

then goroutines blocking on it are never reported as leaked, even if

that concurrency primitive is never used in the future.

This can be alleviated by better regimenting access to

concurrency primitive references, and more clearly

delineating their lifecycle.

-

Non-standard blocking:

For the sake of correctness, goroutine leak detection is strictly limited

to Go first-class concurrency primitives, which includes:

channel send and receive operations (including over nil channels),

blocking select statements, i.e., with no default case, up to, and including

select statements with no cases, and members of the

sync package, specifically Mutex,

RWMutex, WaitGroup and Cond.

Goroutines blocked for any other reason, e.g.,

file and network IO, or direct system calls

are never considered as leaked.

This likewise applies for custom, user-defined concurrency,

e.g., spin locks, unless they rely on the primitives outlined above

for their underlying implementation.

-

Non-determinism: leaks can be detected only after

they have occurred, but cannot be otherwise predicted,

so reproducing and diagnosing leaks in flaky programs

continues to be a challenge.

For the best results, we encourage mixing approaches, by using

goroutine leak profiles at various layers, up to, and including production,

as well as comprehensive test suites instrumented with goleak and synctest.

Performance impact

Goroutine leak detection is carefully designed to minimize

performance impact, but there are, nevertheless, some costs.

While memory overhead is negligible, only limited to small additions

required for bookkeeping, goroutine leak detection can be slower

than the regular GC.

This is best illustrated through a pathological case we

call the “daisy-chain”:

In this leak-free example, runnable goroutine G₀ has a

reference to primitive P₁ which blocks G₁, and so on.

This implies that proving liveness for some Pᵢ₊₁,

requires proving liveness for Pᵢ, which introduces

two costs:

- The GC marking phase is effectively serialized relative to the

order in which goroutines can be scanned, as all the memory reachable

from some Pᵢ must be marked before Pᵢ

正文由 FLUX 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。