AnyLearn
All lessons
Programmingadvanced

Goroutine Leaks: Who Stops This Thing

Starting a goroutine is one keyword; stopping one is a design decision nothing forces you to make. A goroutine blocked forever is never collected and holds everything it references, which is the most common Go-specific bug. This lesson covers how leaks happen, the cancellation mechanism that prevents them, and how to detect the ones already there.

Updated · AI-authored, review-gated · how lessons are made

Not signed in: your progress and quiz score won't be saved.
Progress1 / 8

The asymmetry

Starting a goroutine takes two characters. Stopping one takes a plan, and the language provides no default.

There is no kill, no interrupt, and no timeout on a goroutine. A goroutine ends exactly one way: its function returns. Everything else is a mechanism for persuading it to return.

That asymmetry is deliberate. Killing a thread from outside is unsafe in every language that has tried it, because the victim may hold a lock, be mid-write, or own a resource nobody else can release. Go declined to offer it.

The consequence is a question with no default answer, and it must be answered for every goroutine you start: what makes this return?

If the answer is that it finishes its work, fine. If the answer is that it loops until something tells it to stop, then something must be able to tell it, and building that channel is your job.

A goroutine with no answer is a leak waiting for the right input.

Full lesson text

All 8 steps on one page, for reading, reference, and search.

Show

1. The asymmetry

Starting a goroutine takes two characters. Stopping one takes a plan, and the language provides no default.

There is no kill, no interrupt, and no timeout on a goroutine. A goroutine ends exactly one way: its function returns. Everything else is a mechanism for persuading it to return.

That asymmetry is deliberate. Killing a thread from outside is unsafe in every language that has tried it, because the victim may hold a lock, be mid-write, or own a resource nobody else can release. Go declined to offer it.

The consequence is a question with no default answer, and it must be answered for every goroutine you start: what makes this return?

If the answer is that it finishes its work, fine. If the answer is that it loops until something tells it to stop, then something must be able to tell it, and building that channel is your job.

A goroutine with no answer is a leak waiting for the right input.

2. What a leak actually costs

A leaked goroutine is not merely idle. It is blocked forever on a channel operation, and three things follow.

It is never garbage collected. A blocked goroutine is a live root, so its stack and everything reachable from it stay allocated. Leak one holding a large buffer per request and memory grows with traffic.

It holds resources. Open connections, file handles, and mutex guards it happens to be carrying are never released.

It is silent. Nothing errors, nothing logs, and the program keeps serving. The symptom is memory growth and eventual failure hours later, with a stack trace pointing at whatever allocated last rather than at the cause.

That third property is what makes it the characteristic Go bug. A deadlock announces itself when the whole program stops; a leak announces itself as a gradual, unexplained resource climb that looks like a memory leak in the ordinary sense and is not.

The scale multiplies it. Leak one goroutine per request at a thousand requests a second and there are 3.6 million by the end of the hour.

3. The classic leak

The canonical case appears in code that looks entirely reasonable.

func first(urls []string) string {
    ch := make(chan string)          // UNBUFFERED
    for _, u := range urls {
        go func(u string) {
            ch <- fetch(u)           // blocks until someone receives
        }(u)
    }
    return <-ch                      // take the first, return
}

The intent is a race: start several fetches and return whichever finishes first. It works, and it leaks every goroutine but one.

The channel is unbuffered, so each send blocks until a receiver takes it. Exactly one receive happens, then the function returns. The remaining goroutines are blocked on their sends forever, and nothing will ever receive because the only receiver has gone.

Call this with five URLs a thousand times and four thousand goroutines are stuck, each holding its response.

The one-character fix is to buffer the channel to the number of senders:

ch := make(chan string, len(urls))   // every send succeeds immediately

Now each goroutine sends into the buffer and returns. The unread values become garbage once the channel is unreachable, which is fine: the goroutines finished, and finishing is what matters.

4. Where goroutines get stuck

Four shapes account for essentially every leak, and each has a standard remedy.

The recurring theme is a missing partner. A channel operation needs a counterpart, and the leak is the case where the counterpart went away, was never created, or exited by an error path nobody considered.

That last one deserves emphasis. Most leaks are on error paths. The happy path was tested, drains the channel, and closes it properly. The path where a request fails halfway returns early, and the goroutine started at the top is still waiting to hand over its result.

The practical consequence is that leaks are found by asking, for every early return, whether anything was left waiting.

The general defence is to make every blocking operation cancellable, so that no goroutine is ever waiting on a single thing with no alternative. That is what the next step is about.

flowchart TD
A["Goroutine blocked forever"] --> B["Sending, nobody will receive"]
A --> C["Receiving, nobody will send"]
A --> D["Waiting on a channel never closed"]
A --> E["Ranging over a channel never closed"]
B --> F["Buffer the channel, or use select with a done case"]
C --> G["Ensure the sender exists and cannot exit early"]
D --> H["The owner must close it on every path"]
E --> H

5. Context: cancellation that propagates

The standard mechanism is context.Context, and its core is one channel.

A context carries a Done() channel that is closed when the work should stop. Closing is the ideal signal here: it never blocks, it can be observed by any number of goroutines at once, and it cannot be un-signalled.

func worker(ctx context.Context, in <-chan Job) error {
    for {
        select {
        case job, ok := <-in:
            if !ok { return nil }
            process(job)
        case <-ctx.Done():
            return ctx.Err()          // cancelled or timed out
        }
    }
}

Every blocking operation now has an escape. The goroutine waits for work or for cancellation, so it can always return.

Contexts form a tree. Deriving a child context with a timeout or a cancel function means cancelling a parent cancels every descendant, so one cancellation at the top of a request unwinds every goroutine it spawned, however deep.

That is the mechanism behind the convention of passing ctx as the first parameter of anything that blocks. It looks like ceremony and it is the cancellation path: a function without a context cannot be told to stop, and neither can anything it starts.

6. The discipline that prevents them

Four rules eliminate most leaks, and they are about ownership rather than cleverness.

Whoever starts a goroutine is responsible for it stopping. Not the goroutine, not the caller: the code containing the go statement. If you cannot state what makes it return, do not start it.

Every blocking operation gets a cancellation case. A bare <-ch in long-lived code is a latent leak; a select with ctx.Done() is not.

Always call cancel. Deriving a cancellable context returns a cancel function, and defer cancel() releases the resources associated with it even on the happy path. Failing to is itself a leak, and go vet checks for it.

Close from the sender, on every path. Including error paths, which is what defer close(ch) at the top of the sending goroutine achieves.

A fifth is worth adding for libraries: a function that starts a goroutine should say so, and either return something that waits for it or take a context. A library quietly starting a background goroutine the caller cannot stop is a leak the caller cannot fix.

7. Finding the ones you already have

Leaks are easier to detect than most bugs, because the evidence is a number that only goes up.

Watch the count. runtime.NumGoroutine() returns how many exist. Exporting it as a metric turns leaks into a visible trend: a healthy service returns to a baseline between bursts, and a leaking one climbs monotonically.

Take a goroutine profile. The runtime can dump every goroutine's stack, showing exactly where each is blocked. A leak appears unmistakably: ten thousand goroutines stopped at the same line. This is the fastest route from symptom to cause in production.

Test for it. Record the count at the start of a test, run the code, allow a moment for shutdown, and assert the count returned to its baseline. Leak-checking libraries automate this, and it catches leaks at the commit that introduces them rather than in production.

Read every early return. In review, the question is whether anything was left waiting when this path fires, which is where the leaks that survive testing live.

The first two are the significant ones. A goroutine count on a dashboard converts a class of silent, delayed failures into an obvious upward line.

8. The question to ask every time

PatternLeak riskFix
go f() with no way to stop itHighState what makes it return, or do not start it
Unbuffered channel, fewer receives than sendsHighBuffer to the sender count, or select on done
for range ch where nothing closes chHighSender closes, with defer
Blocking receive with no timeoutMediumAdd ctx.Done() to a select
Cancellable context without defer cancel()MediumAlways defer it; go vet catches this
Library starting a hidden background goroutineHighAccept a context or return a stopper

Every row is the same question in a different costume: what makes this goroutine return, on every path including the ones that fail?

That question is the whole lesson. Go makes starting concurrent work so cheap that the starting is invisible, and the stopping is the part that needs deliberate design. Nothing in the language forces you to think about it, and the failure is silent, delayed, and far from its cause.

The compensating strength is that the fix is mechanical once the discipline is in place: contexts everywhere, cancellation cases in every select, closes owned by senders, and a goroutine count on a dashboard.

What remains is why the language looks the way it does at all, which is the last lesson.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. How does a goroutine end?
    • Its function returns; there is no kill or interrupt
    • The runtime collects it when it becomes unreachable
    • It is preempted after exceeding its time slice
    • The parent goroutine terminates it on exit
  2. Why does `ch := make(chan string)` with five senders and one receive leak?
    • The channel is garbage collected before the sends complete
    • The receiver closes the channel, panicking the senders
    • Four senders block forever on an unbuffered send with no receiver left
    • The goroutines are preempted and never rescheduled
  3. Why is a leaked goroutine worse than an idle one?
    • It consumes a CPU core while blocked
    • It prevents the program from exiting cleanly
    • It triggers the runtime's deadlock detector
    • It is a live root, so its stack and everything reachable from it stay allocated, silently
  4. What is the core mechanism inside `context.Context`?
    • A Done channel that is closed to signal cancellation
    • A mutex guarding a cancellation flag
    • A timer goroutine that kills its children
    • An atomic counter of outstanding operations
  5. Where do most goroutine leaks actually occur?
    • In tight loops that never yield
    • On error paths with early returns that leave something waiting
    • In the main goroutine at shutdown
    • In buffered channels that fill up

Related lessons

Programming
advanced

Undo: Reset, Revert, Restore, and Getting Work Back

Git's undo commands are notoriously confusing because they are usually memorised as recipes. Read against the object model they separate cleanly: each acts on a different one of the three places content lives. This lesson maps them, covers recovery from the accidents that feel unrecoverable, and states what genuinely cannot be undone.

8 steps·~12 min
Programming
advanced

The Failure Modes the Design Creates

Reconciliation buys robustness and charges for it in a specific currency: nothing is ever definitely finished, commands succeed without meaning anything worked, and manual fixes are silently undone. This lesson covers the failures that come from the model rather than from bugs, how to debug them, and when the whole thing is the wrong tool.

8 steps·~12 min
Programming
advanced

Merge and Rebase: Two Answers to Divergence

Every operation so far moved a pointer. Merging is the first that has to decide something: two branches changed the same project and one result must come out. This lesson covers the three-way merge and the merge base it depends on, what a conflict actually is, and why rebase produces different commits rather than moving them.

8 steps·~12 min
Programming
advanced

Refs: A Branch Is a File Containing a Hash

Hashes are unusable by hand, so Git names them. A branch is not a copy of anything, not a directory, and not a container for commits: it is a forty-character file. Once that is clear, checkout, reset, detached HEAD and the reflog stop being separate concepts and become one operation on one kind of file.

8 steps·~12 min