Skip to content
gohacks.dev
Concurrency

Stop a Goroutine in Go Without Leaking the Whole Server

Go has no way to kill a goroutine, so ask it to stop: context, a done channel, closing its input, checks in CPU loops, unblocking I/O, and waiting.

7 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

Programmers new to Go look for the function that kills a goroutine and are surprised to learn there is none. go starts one, nothing stops one, and that is deliberate. A goroutine killed from outside could be in the middle of a map write, holding a lock, halfway through a protocol. The runtime offers no way to destroy it safely, so Go's answer is cooperation: the goroutine is asked to stop and it ends itself when it is at a point where that is safe.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article collects the ways to ask, the places where the asking fails, and how to wait for the answer. It follows goroutine leaks and context, and belongs to the concurrency material. Everything ran on Go 1.25.6.

Signal 1: a context

The default tool is a context. The goroutine takes a context.Context and selects on ctx.Done() wherever it waits:

main.goGo
package main

import (
	"context"
	"fmt"
	"sync"
	"time"
)

func worker(ctx context.Context, id int, wg *sync.WaitGroup) {
	defer wg.Done()
	for {
		select {
		case <-ctx.Done():
			fmt.Println("worker", id, "stopping:", ctx.Err())
			return
		case <-time.After(10 * time.Millisecond):
			// do a unit of work
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	var wg sync.WaitGroup
	wg.Add(1)
	go worker(ctx, 1, &wg)

	time.Sleep(35 * time.Millisecond)
	cancel()  // ask it to stop
	wg.Wait() // wait until it has
	fmt.Println("stopped and joined")
}
Text
worker 1 stopping: context canceled
stopped and joined

Three steps: cancel, the goroutine notices and returns, and Wait confirms it has gone. The last step matters. cancel() only asks; until the goroutine has returned, it may still be running, touching shared data or holding resources. Stopping means asking and then waiting.

Signal 2: a done channel

Before contexts there was the done channel, and it is still right for self-contained components. A chan struct{} that the owner closes; closing broadcasts to every goroutine that selects on it:

main.goGo
package main

import (
	"fmt"
	"sync"
	"time"
)

type Ticker struct {
	done chan struct{}
	wg   sync.WaitGroup
	once sync.Once
}

func Start(interval time.Duration, f func()) *Ticker {
	t := &Ticker{done: make(chan struct{})}
	t.wg.Go(func() {
		tick := time.NewTicker(interval)
		defer tick.Stop()
		for {
			select {
			case <-tick.C:
				f()
			case <-t.done:
				return
			}
		}
	})
	return t
}

// Stop ends the loop and waits for it to exit. It is safe to call twice.
func (t *Ticker) Stop() {
	t.once.Do(func() { close(t.done) })
	t.wg.Wait()
}

func main() {
	n := 0
	t := Start(10*time.Millisecond, func() { n++ })
	time.Sleep(55 * time.Millisecond)
	t.Stop()
	t.Stop() // idempotent
	fmt.Println("ticked at least 3 times before Stop:", n >= 3)
}
Text
ticked at least 3 times before Stop: true

The type bundles the goroutine with its stop and wait. Stop closes done once (guarded by sync.Once so a second call is harmless) and waits for the goroutine to finish. This is the shape of nearly every well-behaved background component: Start or New launches, Stop or Close ends and joins. If a type starts a goroutine and offers no Stop, it cannot be used in a test or shut down cleanly (starting and stopping in order).

Signal 3: close the input

A goroutine that consumes from a channel ends naturally when the channel is closed and drained:

main.goGo
package main

import (
	"fmt"
	"sync"
)

func main() {
	jobs := make(chan int)
	var wg sync.WaitGroup
	var sum int

	wg.Go(func() {
		for j := range jobs { // ends when jobs is closed
			sum += j
		}
	})

	for i := 1; i <= 5; i++ {
		jobs <- i
	}
	close(jobs) // the stop signal is "no more work"
	wg.Wait()
	fmt.Println("sum:", sum)
}
Text
sum: 15

No separate stop mechanism is needed: closing the input is the stop signal. It ends the consumer after it has processed everything already queued, which is often what you want for a graceful finish. It does not interrupt a consumer in the middle of a long job, so combine it with a context when you need both "finish the queue" and "abort now".

Where stopping fails

CPU-bound loops that never check

A goroutine in a tight computation never reaches a select, so no signal reaches it. The fix is to check periodically:

main.goGo
package main

import (
	"context"
	"fmt"
	"time"
)

func countPrimes(ctx context.Context, limit int) (count int, err error) {
	for n := 2; n < limit; n++ {
		if n%1000 == 0 { // check every so often, not every iteration
			if err := ctx.Err(); err != nil {
				return count, err
			}
		}
		prime := true
		for d := 2; d*d <= n; d++ {
			if n%d == 0 {
				prime = false
				break
			}
		}
		if prime {
			count++
		}
	}
	return count, nil
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
	defer cancel()

	start := time.Now()
	_, err := countPrimes(ctx, 1_000_000_000)
	fmt.Println("stopped:", err, "| within about", time.Since(start).Round(30*time.Millisecond))
}
Text
stopped: context deadline exceeded | within about 30ms

The function would run for minutes, and returns within a few milliseconds of the deadline because it asks ctx.Err() every thousand iterations. Err() is cheap but not free, so checking at an interval bounds the delay without paying on each iteration. The Go scheduler preempts the goroutine so other goroutines still run (the scheduler), but preemption does not end it; only the check does.

Goroutines blocked in I/O

A read on a socket or a pipe does not select on a context. The goroutine is stuck in the system call until data arrives or the file is closed. The two ways to wake it are to close the resource from another goroutine, which makes the pending read return an error, or to set a deadline:

main.goGo
package main

import (
	"context"
	"fmt"
	"net"
	"sync"
	"time"
)

func main() {
	client, server := net.Pipe()
	defer server.Close()

	ctx, cancel := context.WithCancel(context.Background())
	stop := context.AfterFunc(ctx, func() { client.Close() }) // wake the reader on cancel
	defer stop()

	var wg sync.WaitGroup
	wg.Go(func() {
		_, err := client.Read(make([]byte, 16)) // blocks: nobody writes
		fmt.Println("reader woke up with an error:", err != nil)
	})

	time.Sleep(20 * time.Millisecond)
	cancel()
	wg.Wait()
	fmt.Println("reader goroutine has exited")
}
Text
reader woke up with an error: true
reader goroutine has exited

context.AfterFunc closes the connection when the context ends, and the blocked Read returns an error, so the goroutine can return (AfterFunc). The standard library's net/http does the same for requests with contexts. For a goroutine blocked in a plain system call with no handle to close, such as a cgo call, you cannot interrupt it at all; the thread stays blocked until the call returns (cgo and blocked threads).

Goroutines blocked on a send

A producer blocked sending to a channel nobody reads ignores ctx.Done() unless the send is in a select. Always write sends as select { case ch <- v: case <-ctx.Done(): return } in goroutines that must be stoppable (the select guide has helpers).

Stopping many goroutines

Components rarely have one goroutine. Cancel one context shared by all of them, and wait on one WaitGroup for all; errgroup bundles both, and returns the first error (errgroup). Stop in dependency order: stop accepting new work first, then let in-flight work finish within a deadline, then cancel what remains, then close resources. Reversing that order drops work or panics on closed channels (send on closed channel and graceful shutdown).

Testing that it stops

A stop path that is never exercised is not a stop path. For every component with a Stop, write a test that starts it, stops it and checks that it stopped: the stop call returns within a short bound (a stuck stop is a bug), the work function stops being called afterwards, and no goroutines remain. The bound is the valuable part, because a stop that waits forever shows up as a hanging test, and a select with time.After(time.Second) around the call turns the hang into a failure with a message. To check that nothing is called afterwards, record a counter, stop, note the value, wait a few intervals and assert it did not change. With Go 1.25's testing/synctest the waiting costs no real time, since the fake clock advances when every goroutine in the bubble is blocked (leak tests with synctest). These three checks together are a cheap way to keep a component's lifecycle honest through refactors.

What not to do

Don't use a global flag variable without synchronisation; use an atomic or a channel. Don't call runtime.Goexit to "stop" another goroutine; it ends only the caller. Don't rely on os.Exit as the shutdown strategy for components that hold resources, since deferred functions do not run. Don't stop without waiting, which leaves a window in which the goroutine still runs. Don't write a goroutine you cannot stop unless it is meant to live for the whole process, and then say so in a comment.

A goroutine that can be told to stop, and a caller that waits for it, are the two halves of a clean lifecycle. The Go blog's Pipelines and cancellation is the classic treatment, and the language specification's section on go statements confirms that a program ends when main returns without waiting for other goroutines.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.