o makes concurrency feel approachable because starting concurrent work is syntactically small. You can put go before a function call and the function runs independently. That is useful, but it can also be dangerous. A production service does not become reliable because it starts many goroutines. It becomes reliable when every goroutine has a purpose, a lifetime, a way to stop, and a way to report failure.
Concurrency means structuring a program so multiple tasks can make progress independently. Parallelism means those tasks actually execute at the same time on multiple CPU cores. Go helps with both, but the main design benefit is concurrency: a service can wait for a database, listen to a channel, handle a timeout, and keep serving other requests without freezing the whole process.
This post uses a courier dispatch service as the working example. The same ideas apply to APIs, message consumers, background workers, payment processors, and telemetry pipelines.
The Problem
Imagine an API that assigns a courier to a delivery request. To respond, the service may need to:
- Load the courier profile.
- Load the parcel details.
- Calculate or retrieve a route plan.
- Watch for request cancellation from the client.
- Stop waiting when a dependency is too slow.
- Avoid launching unlimited work during traffic spikes.
- Report errors instead of hiding them inside background goroutines.
- Expose enough runtime data to detect leaks before users notice.
The inputs are request IDs, a context.Context, repository calls, external service calls, and work queues. The output is either a completed assignment or a clear error. The main constraints are latency, CPU limits, memory pressure, slow dependencies, cancellation, and operational visibility.
A healthy concurrent design has this shape:
HTTP request or background trigger
|
v
context with deadline or cancellation
|
v
bounded goroutines do independent work
|
v
channels, locks, or atomics coordinate safely
|
v
select handles result, error, timeout, or shutdown
|
v
response, retry decision, or clean termination
|
v
runtime metrics and pprof reveal behavior in production
The goal is not to use channels everywhere. The goal is to choose the simplest safe coordination tool for each kind of work.
How Go Runs Concurrent Work
A goroutine is a lightweight unit of execution managed by the Go runtime. It is not the same as an operating system thread. Operating system threads are heavier and scheduled by the OS. Goroutines are scheduled by Go, which maps many goroutines onto a smaller set of OS threads.
The scheduler uses an M:N model: many goroutines are multiplexed onto fewer operating system threads. Logical processors hold queues of runnable goroutines. When a goroutine waits on I/O, a channel, a mutex, or a timer, the runtime can park it and run another goroutine. If one processor has no work while another has too much, the scheduler can steal work to keep CPUs busy.
Go goroutines start with small stacks that can grow. This makes it practical to have many goroutines, but they are not free. Each goroutine still has a stack, scheduler metadata, captured variables, and cleanup cost. When you create many short-lived goroutines under load, you also create work for the garbage collector.
In containerized environments, the number of CPU threads matters. GOMAXPROCS controls how many OS threads can execute Go code at the same time. Go 1.25 improves container-aware scheduling by respecting cgroup CPU limits more accurately, which helps services running inside Docker or Kubernetes avoid using a CPU setting that does not match the actual quota.
You can inspect the current value without changing it:
current := runtime.GOMAXPROCS(0)
fmt.Println("current parallelism limit:", current)
Do not confuse high concurrency with unlimited parallelism. If a machine has a small CPU quota, starting thousands of CPU-bound goroutines does not make the CPU larger. It creates scheduling pressure. For I/O-heavy work, goroutines are often excellent. For CPU-heavy work, concurrency must be bounded carefully.
Start Goroutines with a Clear Lifetime
A goroutine should not be treated as a fire-and-forget escape hatch. If a goroutine can fail, it needs an error path. If it can block, it needs cancellation. If it belongs to a request, it should stop when the request stops.
context.Context is the usual way to carry deadlines and cancellation across goroutines. It gives child work a shared signal: the caller is no longer waiting, so stop as soon as possible.
The following example fetches courier, parcel, and route data concurrently. Each task writes to a separate variable, and the function reads those variables only after all tasks complete.
package dispatch
import (
"context"
"fmt"
"golang.org/x/sync/errgroup"
)
type Courier struct {
ID string
}
type Parcel struct {
ID string
Address string
}
type RoutePlan struct {
CourierID string
ParcelID string
}
type Assignment struct {
Courier Courier
Parcel Parcel
Route RoutePlan
}
type LookupStore interface {
FindCourier(ctx context.Context, id string) (Courier, error)
FindParcel(ctx context.Context, id string) (Parcel, error)
PlanRoute(ctx context.Context, courierID string, address string) (RoutePlan, error)
}
func BuildAssignment(ctx context.Context, store LookupStore, courierID, parcelID string) (Assignment, error) {
g, ctx := errgroup.WithContext(ctx)
var courier Courier
var parcel Parcel
g.Go(func() error {
var err error
courier, err = store.FindCourier(ctx, courierID)
return err
})
g.Go(func() error {
var err error
parcel, err = store.FindParcel(ctx, parcelID)
return err
})
if err := g.Wait(); err != nil {
return Assignment{}, fmt.Errorf("load assignment inputs: %w", err)
}
route, err := store.PlanRoute(ctx, courier.ID, parcel.Address)
if err != nil {
return Assignment{}, fmt.Errorf("plan route: %w", err)
}
return Assignment{
Courier: courier,
Parcel: parcel,
Route: route,
}, nil
}
The important part is not only that two operations run concurrently. The important part is that they are coordinated as one unit:
- The same context is passed into both operations.
- If one operation fails,
errgroupcancels the shared context. - The function waits for all started work before returning.
- Errors are returned to the caller instead of disappearing in a background goroutine.
This is structured concurrency: related goroutines are started, canceled, waited for, and reported as one logical operation.
Channels Transfer Ownership
A channel is a typed communication pipe between goroutines. A send operation passes a value into the channel. A receive operation takes a value out. Channels help you design around ownership: one goroutine produces values, another consumes them.
An unbuffered channel has no queue. A sender waits until a receiver is ready. This is useful when the exchange itself is a synchronization point.
A buffered channel has a small queue. A sender can continue until the buffer is full. This is useful when producers arrive in short bursts and consumers process at a steadier pace.
Use buffers intentionally. A buffer is not only a performance setting. It is a backpressure decision. Backpressure means the system slows producers when consumers cannot keep up. Without backpressure, a busy service can create unbounded memory growth or too many goroutines.
Here is a single-owner producer that streams scan events. The producer owns the output channel, so it is also responsible for closing it.
package dispatch
import "context"
type Scan struct {
PackageID string
Location string
}
func StreamScans(
ctx context.Context,
packageIDs []string,
load func(context.Context, string) (Scan, error),
) <-chan Scan {
out := make(chan Scan)
go func() {
defer close(out)
for _, id := range packageIDs {
scan, err := load(ctx, id)
if err != nil {
continue
}
select {
case out <- scan:
case <-ctx.Done():
return
}
}
}()
return out
}
The receiver can safely range over the channel:
for scan := range StreamScans(ctx, ids, loadScan) {
process(scan)
}
The range loop ends when the producer closes the channel. The receiver does not close it. This rule prevents a common panic: closing a channel while another goroutine may still send to it.
Closing Channels with Multiple Producers
When several goroutines send to the same channel, no individual worker should close it. A separate goroutine should wait until all senders are finished, then close the channel exactly once.
package dispatch
import (
"context"
"sync"
)
func MergeScans(
ctx context.Context,
groups [][]string,
load func(context.Context, string) (Scan, error),
) <-chan Scan {
out := make(chan Scan)
var wg sync.WaitGroup
for _, group := range groups {
group := group
wg.Add(1)
go func() {
defer wg.Done()
for _, id := range group {
scan, err := load(ctx, id)
if err != nil {
continue
}
select {
case out <- scan:
case <-ctx.Done():
return
}
}
}()
}
go func() {
wg.Wait()
close(out)
}()
return out
}
This pattern is safe because there is one closer. The workers only send. The closer only closes after every worker has called Done.
Directional channel types make intent clearer:
func scoreWorker(ctx context.Context, jobs <-chan Scan, results chan<- int) {
for {
select {
case <-ctx.Done():
return
case scan, ok := <-jobs:
if !ok {
return
}
results <- score(scan)
}
}
}
jobs <-chan Scan means the worker can only receive jobs. results chan<- int means the worker can only send results. The compiler now protects part of your concurrency design.
Use select to Stay Responsive
select waits on several channel operations and runs the branch that becomes ready first. It is the main tool for combining data, errors, timeouts, and cancellation.
func WaitForDecision(
ctx context.Context,
decisions <-chan Assignment,
errs <-chan error,
timeout time.Duration,
) (Assignment, error) {
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case a := <-decisions:
return a, nil
case err := <-errs:
return Assignment{}, err
case <-timer.C:
return Assignment{}, fmt.Errorf("assignment decision timed out")
case <-ctx.Done():
return Assignment{}, ctx.Err()
}
}
This function will not wait forever. It can complete because a decision arrived, an error arrived, a timeout fired, or the request was canceled.
A default case makes a select non-blocking. Use it carefully. In a loop, a default case can create a busy loop that consumes CPU while doing no useful work.
Prefer this:
select {
case item := <-input:
process(item)
case <-ctx.Done():
return
}
Be cautious with this inside a loop:
select {
case item := <-input:
process(item)
default:
// This may spin repeatedly and burn CPU.
}
For repeated timeouts, avoid creating a new time.After every iteration. Use a reusable timer and reset it carefully.
func resetTimer(t *time.Timer, d time.Duration) {
if !t.Stop() {
select {
case <-t.C:
default:
}
}
t.Reset(d)
}
This avoids old timer signals leaking into the next wait and keeps timer behavior predictable.
Fan-Out, Fan-In, and Bounded Work
Fan-out means splitting work across multiple goroutines. Fan-in means collecting the results back into one place. This is useful when independent work can overlap, such as checking many couriers, loading several parcels, or validating a batch of events.
The mistake is unbounded fan-out: starting one goroutine per item without a limit. That may work for 50 items and fail for 500,000 items. Go makes goroutines cheap, not free.
A worker pool is a practical way to bound concurrency. The job channel supplies work, a fixed number of workers process jobs, and the error channel reports failures.
package dispatch
import (
"context"
"sync"
)
type Job struct {
ID string
}
func RunWorkers(
ctx context.Context,
workerCount int,
jobs <-chan Job,
handle func(context.Context, Job) error,
) <-chan error {
errs := make(chan error, workerCount)
var wg sync.WaitGroup
for i := 0; i < workerCount; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
if err := handle(ctx, job); err != nil {
select {
case errs <- err:
case <-ctx.Done():
return
}
}
}
}
}()
}
go func() {
wg.Wait()
close(errs)
}()
return errs
}
The worker count becomes an explicit capacity decision. You can size it based on the bottleneck:
- CPU-heavy work should usually stay close to available CPU capacity.
- Database work should respect connection pool limits.
- API calls should respect downstream rate limits and timeout budgets.
- Memory-heavy work should stay low enough to avoid garbage collector pressure.
For request-level fan-out, a semaphore channel can limit the expensive section while still keeping the code small:
sem := make(chan struct{}, 8)
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
return ctx.Err()
}
return callSlowDependency(ctx)
The buffered channel has eight slots. When all slots are full, new callers wait or exit through cancellation. That is backpressure in a few lines.
Choose Channels, Locks, Atomics, or Once
Channels are excellent when work or ownership moves between goroutines. They are not the best tool for every shared state problem. Go also provides synchronization primitives in the sync and sync/atomic packages.
| Need | Prefer | Why |
|---|---|---|
| Send work or results between goroutines | Channel | The value changes owner through communication. |
| Protect a shared map or cache | sync.Mutex or sync.RWMutex |
The data stays shared, so locking is simpler than message passing. |
| Allow many readers and few writers | sync.RWMutex |
Readers can proceed together while writes remain exclusive. |
| Wait for a shared condition to change | sync.Cond |
Goroutines sleep until signaled instead of polling. |
| Count events or store an independent flag | sync/atomic |
A single value can be updated safely without a lock. |
| Initialize a shared resource once | sync.Once |
The setup code runs exactly one time across goroutines. |
A shared cache is usually clearer with a lock than with a goroutine acting as a cache owner.
package dispatch
import "sync"
type ETAStore struct {
mu sync.RWMutex
values map[string]int
}
func NewETAStore() *ETAStore {
return &ETAStore{values: make(map[string]int)}
}
func (s *ETAStore) Get(routeID string) (int, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
minutes, ok := s.values[routeID]
return minutes, ok
}
func (s *ETAStore) Set(routeID string, minutes int) {
s.mu.Lock()
defer s.mu.Unlock()
s.values[routeID] = minutes
}
For a simple independent counter, atomics are smaller and faster than a mutex.
package dispatch
import "sync/atomic"
var activeLookups int64
func BeginLookup() {
atomic.AddInt64(&activeLookups, 1)
}
func EndLookup() {
atomic.AddInt64(&activeLookups, -1)
}
func CurrentLookups() int64 {
return atomic.LoadInt64(&activeLookups)
}
Use atomics only when the value is independent. If several fields must change together, use a mutex so the combined state stays consistent.
For one-time setup, sync.Once protects initialization from races:
package dispatch
import "sync"
var zoneOnce sync.Once
var zoneIndex map[string]int
func Zones(load func() map[string]int) map[string]int {
zoneOnce.Do(func() {
zoneIndex = load()
})
return zoneIndex
}
If the function passed to Do panics, do not assume it will be retried automatically. Treat initialization errors carefully and design a clear recovery path where needed.
Waiting for Shared State with sync.Cond
sync.Cond is useful when goroutines share state and some of them must sleep until that state changes. It is different from a channel pipeline. The data remains in shared memory, and the condition variable only wakes waiting goroutines when there may be work to do.
A queue is a simple example. Workers should sleep when the queue is empty instead of repeatedly checking it in a loop.
package dispatch
import "sync"
type ReadyQueue struct {
mu sync.Mutex
cond *sync.Cond
items []Job
}
func NewReadyQueue() *ReadyQueue {
q := &ReadyQueue{}
q.cond = sync.NewCond(&q.mu)
return q
}
func (q *ReadyQueue) Add(job Job) {
q.mu.Lock()
q.items = append(q.items, job)
q.cond.Signal()
q.mu.Unlock()
}
func (q *ReadyQueue) Take() Job {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) == 0 {
q.cond.Wait()
}
job := q.items[0]
q.items = q.items[1:]
return job
}
The loop around Wait is required. A goroutine should always recheck the condition after waking. Signal wakes one waiter, which fits one new job. Broadcast wakes all waiters, which fits state changes that affect everyone.
Common Mistakes to Watch For
Starting goroutines with no stop condition
A loop that reads forever from a channel, waits forever on a dependency, or ignores ctx.Done() can leak. A leaked goroutine may look harmless at first, but thousands of them consume memory and scheduler attention.
Always ask: what makes this goroutine return?
Closing a channel from the receiver
The sender owns closing. A receiver should not close a channel it did not create, because another sender may still be active. With multiple senders, use a WaitGroup and close once after all senders finish.
Fixing deadlocks by adding random buffers
A deadlock happens when goroutines wait forever and no progress is possible. A buffer can sometimes be correct, but it can also hide a broken design. First check that every send has a receiver, every receiver has a send or close path, and cancellation can interrupt waiting.
Creating livelocks
A livelock is work without progress. Goroutines keep waking, retrying, or passing signals around, but the system never reaches a useful state. Add timeouts, ownership rules, and backoff where repeated coordination can spin.
Using select with default in a hot loop
A default branch can turn waiting code into CPU-burning code. When nothing is ready, the loop immediately runs again. Use timers, blocking receives, or cancellation-aware waits instead.
Ignoring errors from goroutines
A goroutine that fails silently is a production debugging problem. Use errgroup, an error channel, or another explicit reporting path. Every concurrent task that can fail must have an owner that receives the failure.
Treating goroutines as free
Goroutines are lightweight compared with OS threads, but they still allocate memory, participate in scheduling, and add garbage collector work. Use worker pools, semaphores, and bounded queues when load can grow.
Testing Concurrent Code
Concurrent tests often become flaky when they rely on real sleeps. A test that says time.Sleep(100 * time.Millisecond) is guessing. It might pass on your laptop and fail under CI load.
Prefer deterministic signals:
- Use channels to tell the test when a goroutine reached a point.
- Use contexts to stop workers.
- Use small interfaces so slow dependencies can be replaced in tests.
- Run the race detector regularly.
- For time-based concurrent behavior in Go 1.25, use
testing/synctestso virtual time can advance without real waiting.
Useful commands:
go test -race ./...
go test -run TestAssignmentTimeout ./...
A good timeout test verifies behavior without sleeping longer than necessary. In Go 1.25, testing/synctest runs code in a controlled bubble where timers, tickers, channels, and synchronization primitives can be coordinated by virtual time. synctest.Wait() waits until goroutines are durably blocked and advances the virtual clock to the next event. This makes timeout and retry tests faster and more predictable.
Here is a small timeout test written with testing/synctest. The function waits for an acknowledgement from another goroutine. The test verifies the timeout path without sleeping in real time.
package dispatch
import (
"context"
"errors"
"testing"
"testing/synctest"
"time"
)
var errAckTimeout = errors.New("ack timeout")
func waitForParcelAck(ctx context.Context, ack <-chan struct{}, d time.Duration) error {
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ack:
return nil
case <-timer.C:
return errAckTimeout
case <-ctx.Done():
return ctx.Err()
}
}
func TestWaitForParcelAckTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ack := make(chan struct{})
done := make(chan error, 1)
go func() {
done <- waitForParcelAck(context.Background(), ack, 750*time.Millisecond)
}()
synctest.Wait()
if err := <-done; !errors.Is(err, errAckTimeout) {
t.Fatalf("expected ack timeout, got %v", err)
}
})
}
The important part is that the test has a deterministic event to wait for. The virtual clock can advance to the timer event, and the goroutine reports its result through a channel. If a goroutine inside the controlled test never becomes able to finish, the test exposes the cleanup problem instead of hiding it behind a guessed sleep.
A safe testing pattern for concurrent work is:
1. Start the test with a context.
2. Start the goroutine under test.
3. Wait for a deterministic signal, not a guessed sleep.
4. Trigger cancellation, a channel send, or a virtual-time event.
5. Assert the result.
6. Confirm all goroutines can exit before the test ends.
The last step matters. A test that leaves a goroutine blocked may pass while hiding a leak. Deterministic concurrency tests should prove both the expected result and the cleanup path.
Monitoring Goroutines in Production
Concurrency problems usually leave clues before they become incidents. A service may show a slow rise in goroutine count, memory growth, or unstable latency. Go gives you practical runtime tools to watch those signals.
The simplest signal is runtime.NumGoroutine():
package main
import (
"fmt"
"net/http"
_ "net/http/pprof"
"runtime"
)
func main() {
http.HandleFunc("/health/goroutines", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "goroutines=%d\n", runtime.NumGoroutine())
})
http.HandleFunc("/work", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "worker endpoint is alive")
})
_ = http.ListenAndServe(":8080", nil)
}
The blank import registers the built-in pprof handlers. The service will expose profiling endpoints under /debug/pprof/.
You can inspect goroutines directly:
go tool pprof -http=:6060 http://localhost:8080/debug/pprof/goroutine
For a raw dump with stack traces, open:
http://localhost:8080/debug/pprof/goroutine?debug=2
pprof groups goroutines by shared call stack. That is useful at scale because you often care about patterns, not individual goroutine IDs. A large and growing group blocked on the same receive, sleep, or external call is a strong signal that work is not finishing.
When reading goroutine dumps, look for states such as:
IO wait: often normal for server listeners or network calls.chan receive: healthy when workers are waiting for jobs, suspicious when the count grows forever.chan send: often means the receiver is missing, too slow, or has stopped.sleep: normal for timers, suspicious when thousands of goroutines sleep from the same call path.
Do not use goroutine count as a universal hard limit. Different services have different baselines. Watch the trend. A stable number under steady load is usually healthy. A slow climb after every request is usually a leak. A sudden spike often means unbounded concurrency.
Practical Workflow for Designing Go Concurrency
Use this workflow before adding go to a function call:
- Name the work unit. Decide what the goroutine is responsible for: one request, one queue item, one worker, one timer, or one background loop.
- Define ownership. Decide who creates the goroutine, who waits for it, who receives its error, and who can cancel it.
- Set a lifetime. Pass
context.Contextinto blocking calls and long-running loops. Add deadlines where external systems are involved. - Pick the coordination primitive. Use channels for handoff, locks for shared memory, atomics for independent counters, and
sync.Oncefor one-time setup. - Add backpressure. Use bounded channels, worker pools, semaphore channels, or external limits so traffic spikes do not create unlimited work.
- Collect results and errors. Do not let goroutines fail silently. Use
errgroup, result channels, or explicit callbacks owned by the caller. - Close channels from the producer side. With multiple producers, wait for all senders, then close once.
- Test cancellation and timeout paths. The unhappy path is where most concurrency leaks are found.
- Expose runtime signals. Track goroutine count and enable pprof in controlled environments so blocked call stacks are visible.
- Review under load. Use profiling to check whether goroutines are blocked, runnable, sleeping, or growing unexpectedly.
Checklist
Before merging concurrent Go code, verify these points:
- Every goroutine has a clear reason to exist.
- Every goroutine has a clear way to stop.
- Request-scoped goroutines receive the request context or a derived context.
- Blocking sends and receives can exit through cancellation where needed.
- Channels are closed only by the owner that finishes sending.
- Multi-producer channels are closed after all producers complete.
- Buffers have a reason, not a random size.
- Fan-out is bounded when input size can grow.
- Shared maps and caches are protected by a mutex or owned by one goroutine.
- Atomic operations are used only for independent values.
- Errors from goroutines are collected and returned or logged at the correct boundary.
- Tests do not depend on guessed sleeps.
- Race detection is part of the regular test workflow.
- Goroutine count and pprof are available for investigation.
Conclusion
Go concurrency is powerful because the language gives you small primitives with clear behavior: goroutines for independent work, channels for communication, select for coordination, contexts for cancellation, locks for shared state, atomics for simple counters, and pprof for runtime visibility.
The professional skill is designing the lifecycle around those primitives. Start only the work you can bound. Pass cancellation into anything that may wait. Close channels from the producer side. Prefer worker pools when load can grow. Use locks when protecting memory is simpler than passing messages. Test timeout and cleanup paths. Watch goroutine trends in production.
A concurrent Go service should not feel mysterious. When ownership, backpressure, cancellation, and observability are part of the design, goroutines become a reliable building block instead of a hidden source of leaks and outages.