JavaRush /Courses /Go SELF /Goroutine leaks: common causes and the “termination rule”...

Goroutine leaks: common causes and the “termination rule”

Go SELF
Level 67, Lesson 4
Available

1. Common causes of goroutine leaks

When you first hear “goroutine leak,” your brain pictures something like a “memory leak,” just with little green gophers escaping from your program. It’s almost like that: a goroutine keeps living because it is waiting for an event that will never happen, and nobody “cleans it up.” Go is a polite language: if you start a goroutine, it won’t forcibly kill it as long as the process is alive.

Strictly speaking, a goroutine leak is a situation where a goroutine is stuck waiting (receive/send/range over a channel/waiting for a timer/waiting for something else) and has no guaranteed termination condition. This is dangerous even if “nothing crashes”: such goroutines consume memory (stack, runtime structures), sometimes hold resources (for example, waiting on a channel can keep references to data alive), and sometimes create the effect of “the program seems to work, but it keeps getting heavier.”

The most unpleasant part: goroutine leaks often don’t show up immediately. In “single run” tests everything is fine, but in a long-lived application the goroutine counter grows, latencies increase, and you start suspecting the computer is simply tired of your architecture. It’s not tired—it’s just honestly doing what you wrote.

Blocking send to a channel without a reader

A very human story: you made a channel, started a goroutine, sent a value—and that’s it. But with an unbuffered channel, a send (ch <- v) blocks until someone does a receive (<-ch). If there is no reader (or it “left” due to a timeout), the sender hangs forever.

Mini example “how to hurt yourself”:

package main

import "fmt"

func main() {
	ch := make(chan int)

	go func() {
		ch <- 1 // will hang: nobody reads
		fmt.Println("unreachable") // unreachable
	}()

	fmt.Println("main ends") // main ends
}

There’s another nuance here: main will finish—the process will exit—and you might not even notice that a goroutine “leaked.” But if this is not main but a request handler or a background part of a service, the stuck goroutine will accumulate.

There are three basic remedies, and each has its own contract.

  • The first remedy: guarantee a reader (that is, design the protocol so that a receive definitely happens). This sounds trivial, but in practice it means: “the one who creates the channel is responsible for ensuring someone reads from it.”
  • The second remedy: a buffer. A buffer allows the sender to “drop it in and leave” as long as the buffer isn’t full.
  • The third remedy: a non-blocking send via select { case ch <- v: default: }, if losing the value is acceptable by contract (this is important!). The pattern with default turns sending into an attempt: “if it can send right now—send; otherwise—don’t wait.”

Blocking receive from a channel where nobody writes and nobody closes

The second classic: you write a consumer that reads from a channel. And the producer somewhere “changed its mind” and stopped writing. If the producer does not close the channel, the consumer can wait forever.

This code looks logical, but it’s very fragile:

package main

func reader(in <-chan int) {
	v := <-in // if nobody sends anything — we hang
	_ = v
}

func main() {}

The correct thought here is: if you expect an “end of stream,” you must be able to recognize it. And in channels, “end of stream” is expressed by closing the channel and reading in the form v, ok := <-ch.

A mini snippet that can terminate:

package main

func reader(in <-chan int) {
	v, ok := <-in
	if !ok {
		return // channel is closed — exit cleanly
	}
	_ = v
}

func main() {}

for range ch, but nobody closes the channel

for range ch is a very convenient pattern: “read until the channel is closed.” But this convenience comes with a built-in agreement: someone must close the channel. If nobody closes it, the loop is infinite.

A bad scenario usually looks like: “I read range somewhere, and I write somewhere else, and it will end someday.” No, it won’t. Channels cannot guess your intentions.

A good scenario looks like “the producer closes the channel”:

package main

import "fmt"

func main() {
	ch := make(chan int)

	go func() {
		for i := 0; i < 3; i++ {
			ch <- i
		}
		close(ch) // important contract!
	}()

	for v := range ch {
		fmt.Println(v) // 0, then 1, then 2
	}
}

This tiny close(ch) is like saying “that’s it, I’m done, you can go.” Without it, the consumer will wait for the “banquet to continue.”

The timeout fired, but the rest of the pipeline did not stop

A timeout in select is a way to stop waiting at the current point. But a timeout does not automatically kill other goroutines. If you started a goroutine that tries to send you a result, and you leave due to a timeout and stop reading, you can easily get a leak via a blocking send.

This follows directly from the basic “timeouts + select” pattern: we “abandon the read attempt” on timeout, but for the sender it makes no difference.

The program’s mental flow often looks like this:

flowchart TD
    A[main waits for result] --> B{select}
    B -->|case v := <-results| C[success: got v]
    B -->|case <-time.After| D[timeout: moved on]
    E[worker goroutine] --> F[results <- v]
    F -->|no reader| G[worker hangs forever]

If after a timeout you do not cancel the workers’ work (via ctx.Done() or another signal) and/or you don’t ensure non-blocking sends for them, workers can “get stuck” sending the result.

select { default: ... } in a loop and a busy loop

Sometimes people learn about default and think: “Oh! I can make a loop that constantly checks a channel but doesn’t block.” You can. It will work. And it will burn CPU like a tiny jet engine.

A busy loop is not always a “goroutine leak” in the sense that “it waits forever.” But it’s a very close relative: the goroutine will not terminate and will also keep spinning, consuming an entire core. In a long-lived application the effect is similar: a resource leaks, just not memory but CPU time.

Mini anti-example:

package main

func main() {
	ch := make(chan int)

	for {
		select {
		case <-ch:
			// received something
		default:
			// nothing ready — but we don’t wait; we spin
		}
	}
}

If you need to “wait for an event,” use blocking cases without default. If you need an “infrequent check,” you typically add a blocking case (for example, a timer) or rethink the design. For today, it’s enough to remember: default inside a fast for loop is a red flag.

3. The goroutine termination rule

Here we pin down the main mental tool for all subsequent concurrent Go code. This is not a magic technique but a discipline: before starting a goroutine, you must be able to answer in words the question “when will it end?”. If there is no answer, you are already on the path to a leak.

The “termination rule” is conveniently phrased like this:

Every started goroutine must have at least one guaranteed exit condition:
closing the input channel, cancelation via ctx.Done(), or finishing its work without a risk of blocking forever.

To make it easier to apply this in code rather than in theory, keep a small “protocol table” in mind:

What the goroutine does What it can block on How it learns “time to exit” Who provides the signal
Consumer reads from in receive <-in in is closed (ok == false) Producer (closes in)
Worker waits for commands receive <-jobs jobs is closed or ctx.Done() Coordinator/owner of jobs or whoever cancels ctx
Producer sends to out send out <- v the reader reads, or the send is non-blocking, or ctx.Done() Consumer/channel buffer/context
Forwarder in a fan-in reads+writes receive and send input closed or context canceled Input producer or cancel

And yes, this is “contract-driven” discipline. Concurrent code is generally 80% contracts and only 20% syntax.

4. Mini case: “first answer wins” without leaks

So the examples don’t hang in midair, let’s assume we have a simple training app in a “task manager” style: we have several task sources, and we want to find a task by id as fast as possible. We start several goroutines, and we need the first result found.

Bad version: return the first result, while the others may hang

Let’s do a simplified “search” via a delay:

package main

import (
	"time"
)

func findSlow(id int) int {
	time.Sleep(50 * time.Millisecond)
	return id
}

Now the bad “first answer wins” scheme (note out := make(chan int) without a buffer):

package main

import "time"

func firstResult(id int) int {
	out := make(chan int) // unbuffered: risk of hangs

	go func() { out <- findSlow(id) }()
	go func() { out <- findSlow(id) }()

	select {
	case v := <-out:
		return v
	case <-time.After(10 * time.Millisecond):
		return -1
	}
}

What’s wrong: we left due to a timeout, stopped reading out, but the senders will still try to do out <- ... and may hang. This is exactly the class of problems discussed in classic notes about timeouts and “move on”: if you stop waiting, you must think about the fate of those who are still trying to send you something.

Fix: buffer of 1 and non-blocking send

The most compact and practical version for “first answer wins” is out := make(chan int, 1) and sending via select with default. This approach (non-blocking send) is often presented as the canonical one.

package main

func firstResultSafe(id int) int {
	out := make(chan int, 1) // room for exactly the “winner”

	go func() {
		v := findSlow(id)
		select {
		case out <- v:
		default:
			// someone already won
		}
	}()

	go func() {
		v := findSlow(id)
		select {
		case out <- v:
		default:
		}
	}()

	return <-out
}

An important contract here: we consciously “lose” the losers’ results. That’s fine because the business idea of the function is to get the first.

Fix: cancelation via context

Sometimes the “losing” workers cannot just “silently finish the computation.” Suppose they do heavy work or hit an external resource. Then after the winner is found, we want to tell the others: “stop, don’t bother.”

That’s the point of cancelation via ctx.Done(): one signal for all participants. Documentation and articles about context emphasize that context is important so operations respect cancelation and deadlines—otherwise the system accumulates work and resources.

Let’s simplify: the worker will “wait” via a timer, but will be able to exit on cancelation.

package main

import (
	"context"
	"time"
)

func findWithCancel(ctx context.Context, id int) (int, bool) {
	select {
	case <-time.After(50 * time.Millisecond):
		return id, true
	case <-ctx.Done():
		return 0, false
	}
}

Now “first answer wins” with cancelation:

package main

import "context"

func firstResultWithCancel(id int) int {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	out := make(chan int, 1)

	go func() {
		if v, ok := findWithCancel(ctx, id); ok {
			select { case out <- v: default: }
		}
	}()

	go func() {
		if v, ok := findWithCancel(ctx, id); ok {
			select { case out <- v: default: }
		}
	}()

	v := <-out
	cancel() // tell the others “wrap it up”
	return v
}

Here the goroutine has two exit paths: “success” and ctx.Done(). This is the “termination rule” implemented in practice.

5. How to suspect a goroutine leak

When you’re just learning, you don’t always have convenient diagnostics, and sometimes you’re just writing a small utility. So it’s useful to have a “sanity-check detector.”

If a program “hangs” on shutdown, and you see that WaitGroup.Wait() never returns, it almost always means: some goroutine never reached Done(). The reason is usually that it got stuck on send/receive, or it’s waiting on range over a channel that never gets closed.

If a program starts to “slow down” or “bloat” over time, especially in a loop (for example, you run the same operation many times), and you suspect that something background remains after each iteration, your first thought should be: “am I really letting all goroutines finish?”. Yes, it sounds boring. Concurrency is discipline: the fun starts later, when it actually works.

6. Common mistakes when working with goroutines and channels

Error No. 1: “I’ll send to the channel and someone will read it somehow.”
This is the most common reason for stuck goroutines: an unbuffered send requires a reader right now. If the reader left due to a timeout, by mistake, via return, or due to context cancelation, the sender hangs. The fix is not “let’s add a buffer of 100500,” but an explicit contract: either a guaranteed reader, or a sufficiently sized buffer, or a non-blocking send (if loss is acceptable), or cancelation via ctx.Done().

Error No. 2: for range ch without a clear “who closes the channel.”
range over a channel is the agreement “I read until it’s closed.” If you cannot quickly answer who and where calls close(ch), then you don’t have an agreement—you have hope. Hope is a bad synchronization strategy. Typically the producer closes, and closes exactly once, when it is definitely done writing.

Error No. 3: You added a timeout, but not cancelation.
A timeout via time.After or timer.C stops waiting in one place, but the rest of the participants keep living. If after that they try to send a result into a channel that nobody reads anymore, you get a leak. A good pattern is: “timed out → canceled the context → everyone listening to ctx.Done() exits cleanly.”

Error No. 4: default in select inside a fast loop, then surprise “why is it pegging the CPU.”
default is not “I’ll wait a little.” It’s “don’t wait at all.” In a loop, this turns into constant spinning. If you truly want to wait for an event, remove default and let select block. If you need to wake up periodically, add a blocking case (for example, a timer) and clearly define why.

Error No. 5: Closing the channel by the “wrong” participant, or closing it multiple times.
Closing a channel is the responsibility of the data stream owner (usually the producer). If a consumer closes a channel “so everything finishes,” it will almost certainly someday panic with “close of closed channel” or “send on closed channel” (when the producer is still writing). In a fan-in scheme, the coordinator closes the out channel after all forwarders have finished, not the forwarders themselves—otherwise closing becomes a race.

1
Task
Go SELF, level 67, lesson 4
Locked
Sum without deadlock
Sum without deadlock
1
Task
Go SELF, level 67, lesson 4
Locked
Timeout without leak
Timeout without leak
1
Task
Go SELF, level 67, lesson 4
Locked
Send without waiting
Send without waiting
1
Task
Go SELF, level 67, lesson 4
Locked
Winner and cleanup
Winner and cleanup
1
Survey/quiz
Concurrency 3: select, 67 level, 4 lesson
Unavailable
Concurrency 3: select
Concurrency 3: select
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION