You cannot kill a goroutine. You have to ask it to stop.
You spin up a goroutine to poll a remote API every second. The user clicks "Cancel" in the dashboard, and your handler calls a stop function. The goroutine keeps running. You cannot reach into the runtime and yank the thread. Go does not give you a kill switch. You have to signal the goroutine to exit, and the goroutine has to listen.
This design is intentional. Go uses a cooperative scheduler. It does not freeze goroutines mid-instruction like an operating system preempts a thread. A goroutine controls its own lifecycle. It runs until it returns, panics, or the process exits. This cooperation prevents data races caused by halting a thread while it writes shared state. It also forces you to think about cancellation points. Every long-running operation needs a way to bail out.
Think of a goroutine like a barista making a complicated latte. You cannot walk up and freeze their hands in place. You have to signal them that the order is cancelled. The barista has to notice the signal, stop steaming the milk, and clear the counter. If the barista is busy shaking a shaker and never looks up, the order keeps getting made. Your code must check for the signal at safe points.
Cooperation is not a limitation. It is a guarantee that your goroutine cleans up after itself.
Context is the standard signal
The context package is the idiomatic way to pass cancellation signals through your code. A context carries deadlines, cancellation requests, and request-scoped values. When you create a context with context.WithCancel, you get a cancellation function. Calling that function closes an internal channel. Any code holding a reference to that context can detect the closure and stop.
This pattern scales. A top-level HTTP handler can cancel a database query, which cancels a network call, which cancels a background goroutine, all with one function call. The context flows down the call tree. Each layer checks the context and exits when it sees the signal.
Here is the canonical pattern. A context flows into the goroutine, the loop selects on ctx.Done(), and the goroutine returns when the signal arrives.
package main
import (
"context"
"fmt"
"time"
)
// pollService runs a loop until the context is cancelled.
func pollService(ctx context.Context, done chan struct{}) {
defer close(done) // Signal the caller when cleanup finishes.
for {
select {
case <-ctx.Done():
// Exit immediately when the context is cancelled.
// ctx.Err() holds the reason, usually context.Canceled.
return
case <-time.After(500 * time.Millisecond):
// Simulate periodic work without blocking the loop.
// time.After returns a channel that fires once after the duration.
fmt.Println("Polling...")
}
// The select statement unblocks as soon as one case is ready.
// If multiple are ready, Go picks one at random.
}
}
func main() {
// Create a cancellable context derived from the background root.
ctx, cancel := context.WithCancel(context.Background())
// Channel to signal when the goroutine has finished.
done := make(chan struct{})
// Start the background task.
go pollService(ctx, done)
// Let it run briefly.
time.Sleep(1200 * time.Millisecond)
// Signal the goroutine to stop.
cancel()
// Wait for the goroutine to exit before main returns.
// This prevents the program from exiting while the goroutine is cleaning up.
<-done
fmt.Println("Goroutine stopped cleanly")
}
The select statement is the engine here. It waits for any of the cases to be ready. When cancel() closes the internal channel, <-ctx.Done() becomes ready. The select picks that case, the function returns, and the goroutine ends. The defer close(done) ensures the caller can wait for cleanup to finish.
context.WithCancel creates a new context node linked to the parent. It allocates a channel and a done flag. When you call cancel, it closes the channel and marks the context as done. Closing a channel is a broadcast. All receivers blocked on <-ctx.Done() unblock simultaneously.
The goroutine does not know you called cancel. It only knows that the channel it is reading from is ready. This decoupling is key. The caller does not need to know how the goroutine is structured. It just needs the context. The goroutine does not need to know who cancelled it. It just stops.
Context is plumbing. Run it through every long-lived call site.
Realistic worker pattern
In production, goroutines often process work from a channel. The worker needs to stop when the server shuts down, but it should finish the current job before exiting. This is a graceful shutdown. The worker checks the context before starting new work. If the context is cancelled, it stops pulling from the channel.
Here is a worker that processes jobs and respects cancellation. It uses a select to balance work and shutdown signals.
package main
import (
"context"
"fmt"
"time"
)
// processJobs pulls jobs from a channel until the context is cancelled.
func processJobs(ctx context.Context, jobs <-chan string) {
for {
select {
case <-ctx.Done():
// Stop accepting new work when cancelled.
fmt.Println("Worker shutting down")
return
case job, ok := <-jobs:
if !ok {
// Channel closed. No more jobs will arrive.
fmt.Println("Job channel closed")
return
}
// Process the job.
// In real code, pass ctx to downstream calls.
fmt.Println("Processing:", job)
time.Sleep(200 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
jobs := make(chan string, 10)
// Start the worker.
go processJobs(ctx, jobs)
// Send some jobs.
for i := 0; i < 5; i++ {
jobs <- fmt.Sprintf("job-%d", i)
}
// Simulate server shutdown.
time.Sleep(800 * time.Millisecond)
cancel()
// Close the job channel to unblock the worker if it is waiting.
close(jobs)
// Wait for worker to finish.
time.Sleep(100 * time.Millisecond)
}
The worker checks ctx.Done() in the same select as the job channel. This ensures it can exit even if jobs are piling up. If the context is cancelled, the worker returns immediately. It will not start the next job. The ok check on the channel handles the case where the sender closes the channel. A closed channel returns the zero value and false. The worker detects this and exits.
Don't fight the scheduler. Cooperate or leak.
Pitfalls and common mistakes
The biggest trap is the blocking call without an escape hatch. If your goroutine calls a function that blocks indefinitely, and that function does not accept a context, the goroutine is stuck. It will not check cancellation. It leaks.
A goroutine leak happens when a goroutine waits forever. It holds memory, keeps references alive, and blocks program shutdown. The worst goroutine bug is the one that never logs. It just sits there, invisible, until the process runs out of memory.
Common leak scenarios include a goroutine waiting on a channel that never closes, calling time.Sleep for a long duration without checking context, making a network request without a timeout, or spawning a goroutine in a loop where the cancellation signal never reaches the inner task. Fix these by adding context checks. Use select with ctx.Done() alongside any blocking operation. If a library function does not support context, wrap it in a goroutine that can be cancelled, or use a timeout.
Compiler errors catch some mistakes early. If you forget to import the context package, you get undefined: context. If you try to use a context variable that is not in scope, you get undefined: ctx. If you pass a context to a function that does not accept it, the type mismatch error tells you exactly what is wrong.
Runtime panics are rarer with context, but they happen. Sending on a closed channel panics. Never send on ctx.Done(). It is a receive-only channel from the goroutine perspective. The context package manages the channel. You only read from it.
Another pitfall is ignoring the context in fast functions. Fast functions today become slow functions tomorrow. A database call might hang. A network call might stall. Accept the context, pass it down, and check it. It is defensive programming that pays off.
Convention matters. The context.Context parameter always goes first in a function signature. Name it ctx. This makes the pattern consistent across the codebase. Tools and readers recognize it instantly. Functions that take a context should respect cancellation and deadlines. If you can support cancellation, implement it. If you cannot, document it. Do not ignore the context just because your current implementation does not need it. The caller might rely on cancellation to stop the work.
If you need to discard a return value you do not use, assign it to _. The compiler knows you considered it and chose to drop it. Use it sparingly with errors, but freely for unused channel values or loop indices.
Trust the cancellation path. If it is not in the signature, it does not exist.
Alternatives to context
Context is the standard, but it is not the only tool. Sometimes a context is too heavy. You might only need a simple stop signal. Or you might need to wait for a goroutine to finish.
Use context.Context when the goroutine is part of a larger request or operation that might be cancelled by a user, a timeout, or a parent function. Use context.Context when you need to pass deadlines or values alongside the cancellation signal. Use context.Context when the goroutine calls other functions that accept context.
Use a chan struct{} when you have a simple stop signal and do not need the overhead of a full context tree. A channel of empty structs uses zero bytes per element. Close the channel to broadcast the stop signal. The goroutine selects on the channel and returns. This is idiomatic for simple background tasks.
Use a sync.WaitGroup when you need to wait for a goroutine to finish, not to stop it. A WaitGroup tracks active goroutines. The caller calls Wait to block until all goroutines call Done. This complements cancellation. You cancel the context, then wait on the WaitGroup.
Use a atomic.Bool when you need to toggle state frequently without the allocation overhead of channels. Channels are preferred for synchronization, but atomic flags are lighter for simple on/off switches.
The decision matrix guides your choice. Pick the tool that matches the complexity of your coordination.