Patterns for Bounded Concurrency with Context in Go

Limit concurrent goroutines in Go using a buffered channel semaphore and context for cancellation.

Bounded concurrency saves your server

You are processing a batch of 10,000 database records. Each record needs an API call to an external service. You write a loop, spawn a goroutine for each record, and hit run. The CPU spikes. Memory climbs. The external service rate-limits you. Your database connection pool exhausts. The server crashes.

The problem is not concurrency. The problem is unbounded concurrency. You spawned 10,000 goroutines to do work that only needs 20 running at once. You need a ceiling. You need bounded concurrency.

Bounded concurrency limits the number of active goroutines while still allowing parallelism. It protects downstream services from overload and keeps memory usage predictable. Go provides the tools to build this pattern cleanly using a buffered channel as a semaphore and context.Context for cancellation.

The semaphore pattern

A semaphore is a synchronization primitive that controls access to a resource by maintaining a count of available slots. In Go, a buffered channel implements a semaphore naturally. A channel with capacity N can hold N values. Sending to the channel blocks if it is full. Receiving from the channel frees a slot.

Think of a parking garage with 50 spots. Cars arrive and take a spot. When all 50 spots are filled, new cars wait in line. When a car leaves, a spot opens, and the next car enters. The garage never holds more than 50 cars. The channel is the garage. The values in the channel are the spots. The goroutines are the cars.

The pattern uses a buffered channel of struct{}. The empty struct takes zero bytes of memory, so the channel overhead is minimal. The capacity of the channel sets the concurrency limit. Before spawning a goroutine, the code sends a value to the channel to acquire a slot. If the channel is full, the send blocks. The goroutine does not start until a slot is available. When the goroutine finishes, it receives from the channel to release the slot.

This design has a subtle advantage. The blocking happens in the spawning loop, not inside the goroutine. You never create more goroutines than the limit. Some patterns spawn all goroutines upfront and have them block on a channel. That approach creates thousands of goroutines in memory, each waiting for a slot. The semaphore pattern creates at most N goroutines. This saves memory and reduces scheduler pressure.

Minimal example

Here is the core pattern. A function takes a context, a slice of jobs, and a maximum worker count. It spawns goroutines up to the limit and waits for them to finish.

package main

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

// processBatch runs jobs with a limit on concurrent workers.
func processBatch(ctx context.Context, jobs []string, maxWorkers int) {
	var wg sync.WaitGroup
	// buffered channel acts as a semaphore to limit active goroutines.
	sem := make(chan struct{}, maxWorkers)

	for _, job := range jobs {
		// check context before spawning to avoid starting cancelled work.
		select {
		case <-ctx.Done():
			return
		default:
		}

		// acquire a semaphore slot. blocks if maxWorkers are busy.
		sem <- struct{}{}
		wg.Add(1)
		go func(j string) {
			defer wg.Done()
			// release semaphore slot when goroutine exits.
			defer func() { <-sem }()

			fmt.Println("processing", j)
		}(job)
	}
	// wait for all spawned goroutines to complete.
	wg.Wait()
}

The code uses sync.WaitGroup to track active goroutines. The wg.Add(1) call happens before the go statement to ensure the counter increments before the goroutine might finish. The defer wg.Done() decrements the counter when the goroutine returns. The defer func() { <-sem }() releases the semaphore slot. Using defer for the release is critical. If the goroutine panics, the defer still runs, and the slot is freed. Without the defer, a panic would leak the slot, eventually filling the semaphore and causing a deadlock.

The loop checks the context with a select statement before spawning. If the context is cancelled, the loop returns immediately. This prevents spawning new work after cancellation. The default case ensures the loop continues if the context is still active.

How the flow works

The execution flow follows a strict rhythm. The loop iterates over the jobs. For each job, it checks the context. If the context is valid, it attempts to send to the semaphore channel. If the channel has space, the send succeeds, and the goroutine starts. If the channel is full, the send blocks. The loop pauses until a goroutine finishes and releases a slot.

Inside the goroutine, the work runs. When the work completes, the deferred function receives from the semaphore channel, freeing a slot. The deferred wg.Done() signals the wait group. Back in the loop, the blocked send unblocks, and the next goroutine starts.

This flow ensures that at any moment, at most maxWorkers goroutines are running. The number of goroutines never exceeds the limit. The loop acts as a gatekeeper. It only spawns a goroutine when a slot is available. This keeps the system stable under load.

Context is plumbing. Run it through every long-lived call site.

Realistic worker with context

Real work often involves I/O, errors, and cancellation. A realistic worker passes the context to the work function and checks for errors. The work function should respect cancellation to avoid wasting resources.

package main

import (
	"context"
	"log"
	"sync"
	"time"
)

// Task represents a unit of work.
type Task struct {
	ID   string
	Data string
}

// execute performs the actual work and respects context cancellation.
func execute(ctx context.Context, t Task) error {
	// check context immediately to avoid starting expensive operations.
	if err := ctx.Err(); err != nil {
		return err
	}

	// simulate work that can be interrupted.
	select {
	case <-ctx.Done():
		return ctx.Err()
	case <-time.After(50 * time.Millisecond):
		// work completed successfully.
		return nil
	}
}

// runBounded executes tasks with concurrency limits and cancellation support.
func runBounded(ctx context.Context, tasks []Task, limit int) error {
	var wg sync.WaitGroup
	// semaphore channel controls the number of active workers.
	sem := make(chan struct{}, limit)

	for _, t := range tasks {
		// stop spawning if the parent context is cancelled.
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
		}

		// block until a worker slot is available.
		sem <- struct{}{}
		wg.Add(1)
		go func(task Task) {
			defer wg.Done()
			// ensure semaphore is released even if panic occurs.
			defer func() { <-sem }()

			if err := execute(ctx, task); err != nil {
				// log error but continue processing other tasks.
				log.Printf("task %s failed: %v", task.ID, err)
			}
		}(t)
	}
	// wait for all spawned goroutines to finish.
	wg.Wait()
	return ctx.Err()
}

The execute function checks the context before doing work. If the context is cancelled, it returns early. This prevents starting operations that will be discarded. The select statement inside execute allows the work to be interrupted. If the context is cancelled during the work, the function returns ctx.Err().

The runBounded function passes the context to execute. If execute returns an error, the goroutine logs the error and continues. The function does not stop on individual errors. It processes all tasks and returns the context error at the end. This is useful for batch processing where you want to complete as much work as possible before a timeout or cancellation.

The receiver name is usually one or two letters matching the type. If you add methods to Task, name the receiver (t Task), not (this Task) or (self Task).

Pitfalls and compiler traps

Bounded concurrency introduces specific failure modes. Understanding these pitfalls prevents subtle bugs.

Goroutine leaks. If a worker blocks on a channel or I/O operation that never completes, and the worker does not check the context, the goroutine hangs. The semaphore slot remains held. If all slots hang, the loop blocks forever. The program deadlocks. Always pass the context to blocking operations. Use context.WithTimeout or context.WithCancel to ensure workers can be stopped.

The worst goroutine bug is the one that never logs.

Loop variable capture. In older versions of Go, using the loop variable directly in a closure caused a bug where all goroutines shared the same variable. Go 1.22 fixed this by creating a new variable per iteration. If you use an older version, capture the variable by passing it as an argument to the closure. The compiler rejects the program with loop variable t captured by func literal if you try to use the loop variable directly in a closure without capturing it.

Semaphore deadlock. If maxWorkers is zero, the semaphore channel has capacity zero. The first send to the channel blocks forever. The program deadlocks. Validate the limit before creating the semaphore.

Unused imports. If you add log or time to the imports but do not use them in the code, the compiler rejects the build with imported and not used. Go enforces clean imports. Remove unused packages or use them in the code.

Forgetting wg.Add. If you forget to call wg.Add(1) before spawning a goroutine, the wait group counter stays at zero. wg.Wait() returns immediately. The main function exits while goroutines are still running. The goroutines may panic or produce incomplete results. Always pair wg.Add with go.

When to use this pattern

Choose the right concurrency tool for the job. The semaphore pattern is powerful, but it is not the only option.

Use a buffered channel semaphore when you need to limit concurrent goroutines and want fine-grained control over spawning and cancellation.

Use errgroup.WithContext when you want to stop all workers on the first error and collect that error automatically.

Use a worker pool with a job channel when the number of tasks is large or dynamic, and you prefer a fixed set of long-lived goroutines pulling work from a queue.

Use sequential code when the tasks are CPU-bound and the number of tasks is small, or when the overhead of concurrency outweighs the benefit.

Goroutines are cheap. Channels are not magic.

Where to go next