Goroutines are cheap — but not free. A single goroutine costs ~8KB of stack memory that grows as needed. Spawn 100,000 of them simultaneously and you’ve committed ~800MB before doing any real work. Add contention, scheduling overhead, and connection storms to downstream services, and you have a reliability incident. Limiting concurrency is a first-class design decision.
Why Unlimited Goroutines Are Dangerous
// BAD: Unbounded goroutine spawn — a classic mistake
func processAllItems(items []Item) {
for _, item := range items {
go processItem(item) // Could spawn 100,000 goroutines for large inputs
}
}
Problems this causes:
- Memory exhaustion — 100k goroutines × ~8KB stack = ~800MB minimum
- Scheduler thrashing — Go’s runtime scheduler has overhead managing thousands of goroutines
- Connection storms — each goroutine might open a DB connection or HTTP request, overwhelming downstream
- Cascading failures — when the target system slows down, the backlog grows, making it worse
Pattern 1: Buffered Channel Semaphore
The simplest, idiomatic Go approach. A buffered channel of capacity N acts as a counting semaphore:
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
func processConcurrently(items []string, maxConcurrent int) {
sem := make(chan struct{}, maxConcurrent)
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
sem <- struct{}{} // Acquire: blocks when maxConcurrent goroutines are running
go func(item string) {
defer wg.Done()
defer func() { <-sem }() // Release: always runs even on panic
processItem(item)
}(item)
}
wg.Wait()
}
func processItem(item string) {
delay := time.Duration(rand.Intn(200)+50) * time.Millisecond
time.Sleep(delay)
fmt.Printf("processed: %s\n", item)
}
func main() {
items := make([]string, 20)
for i := range items {
items[i] = fmt.Sprintf("item-%d", i+1)
}
processConcurrently(items, 4) // At most 4 goroutines running simultaneously
}
Key detail: defer func() { <-sem }() releases the semaphore even if the goroutine panics. Never write <-sem without defer in a goroutine — a panic would leak the slot permanently.
With Context Cancellation
Production code needs cancellation. Add context so the whole operation can be stopped:
func processConcurrentlyWithContext(ctx context.Context, items []string, maxConcurrent int) error {
sem := make(chan struct{}, maxConcurrent)
var wg sync.WaitGroup
errs := make(chan error, len(items))
for _, item := range items {
// Check if context is cancelled before spawning
select {
case <-ctx.Done():
break
case sem <- struct{}{}: // Acquire slot (or block)
}
wg.Add(1)
go func(item string) {
defer wg.Done()
defer func() { <-sem }()
if err := processItemWithCtx(ctx, item); err != nil {
select {
case errs <- err:
default: // Don't block if error channel is full
}
}
}(item)
}
wg.Wait()
close(errs)
// Return first error if any
for err := range errs {
if err != nil {
return err
}
}
return nil
}
func processItemWithCtx(ctx context.Context, item string) error {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(100 * time.Millisecond):
return nil // simulated work
}
}
Pattern 2: Worker Pool
Worker pools are better than semaphores when jobs arrive continuously from a stream, queue, or channel:
type Job struct {
ID int
Payload string
}
type Result struct {
JobID int
Output string
Err error
}
func NewWorkerPool(ctx context.Context, numWorkers int, jobs <-chan Job) <-chan Result {
results := make(chan Result, numWorkers*2)
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return // channel closed, no more jobs
}
output, err := processJob(job)
results <- Result{JobID: job.ID, Output: output, Err: err}
}
}
}(i)
}
// Close results when all workers finish
go func() {
wg.Wait()
close(results)
}()
return results
}
func processJob(job Job) (string, error) {
time.Sleep(50 * time.Millisecond) // simulated work
return fmt.Sprintf("processed:%s", job.Payload), nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Fill the job queue
jobs := make(chan Job, 100)
go func() {
defer close(jobs)
for i := 1; i <= 50; i++ {
jobs <- Job{ID: i, Payload: fmt.Sprintf("data-%d", i)}
}
}()
// Process with 5 workers
results := NewWorkerPool(ctx, 5, jobs)
successCount := 0
for result := range results {
if result.Err != nil {
fmt.Printf("job %d failed: %v\n", result.JobID, result.Err)
} else {
successCount++
}
}
fmt.Printf("Completed: %d jobs\n", successCount)
}
Worker Pool vs Semaphore — When to Use Each
| Semaphore | Worker Pool | |
|---|---|---|
| Job source | Slice/array known upfront | Channel, stream, queue |
| Goroutine lifecycle | Per-job | Long-lived workers |
| Overhead | Goroutine per job | Fixed goroutine count |
| Best for | Batch processing | Continuous workloads |
Pattern 3: Weighted Semaphore (x/sync/semaphore)
When different jobs have different resource costs, a simple semaphore isn’t enough. A weighted semaphore lets heavy jobs acquire more weight:
import "golang.org/x/sync/semaphore"
const maxWeight = int64(100)
func processWithWeight(ctx context.Context, jobs []Job) {
sem := semaphore.NewWeighted(maxWeight)
var wg sync.WaitGroup
for _, job := range jobs {
weight := int64(job.EstimatedCost()) // e.g., 1 for small, 10 for large
// Acquire `weight` units from the semaphore
if err := sem.Acquire(ctx, weight); err != nil {
break // context cancelled
}
wg.Add(1)
go func(job Job, w int64) {
defer wg.Done()
defer sem.Release(w)
processJob(job)
}(job, weight)
}
wg.Wait()
}
Real example — limiting total memory used by concurrent file reads:
// Allow up to 256MB of files read concurrently
sem := semaphore.NewWeighted(256 * 1024 * 1024) // 256MB in bytes
for _, filePath := range filePaths {
info, _ := os.Stat(filePath)
fileSize := info.Size()
sem.Acquire(ctx, fileSize) // Acquire file-size bytes
go func(path string, size int64) {
defer sem.Release(size)
readAndProcess(path)
}(filePath, fileSize)
}
Setting the Right Concurrency Limit
The right value depends on your bottleneck:
import "runtime"
// CPU-bound work: number of logical CPUs
cpuWorkers := runtime.NumCPU()
// I/O-bound work: empirically 2-10× CPUs, tune with load tests
ioWorkers := runtime.NumCPU() * 4
// External API calls: match provider's rate limit
// e.g., Stripe allows 100 req/s → max 100 concurrent calls at ~1s each
apiWorkers := 100
Tuning approach:
- Start conservative (e.g.,
NumCPU()for CPU-bound,NumCPU() * 4for I/O) - Load test with realistic traffic
- Watch: CPU utilization, memory, downstream error rates, latency percentiles
- Increase limit until a bottleneck appears (CPU, memory, downstream limit)
- Set limit to 80% of that bottleneck
Observability: Tracking Concurrency in Production
You can’t tune what you can’t see:
import (
"sync/atomic"
"github.com/prometheus/client_golang/prometheus"
)
type InstrumentedPool struct {
sem chan struct{}
activeGauge prometheus.Gauge
queueGauge prometheus.Gauge
activeCount int64
}
func NewInstrumentedPool(max int, name string) *InstrumentedPool {
p := &InstrumentedPool{
sem: make(chan struct{}, max),
activeGauge: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "pool_active_goroutines",
ConstLabels: prometheus.Labels{"pool": name},
}),
queueGauge: prometheus.NewGauge(prometheus.GaugeOpts{
Name: "pool_queue_depth",
ConstLabels: prometheus.Labels{"pool": name},
}),
}
prometheus.MustRegister(p.activeGauge, p.queueGauge)
return p
}
func (p *InstrumentedPool) Run(ctx context.Context, fn func()) error {
p.queueGauge.Inc()
select {
case <-ctx.Done():
p.queueGauge.Dec()
return ctx.Err()
case p.sem <- struct{}{}:
}
p.queueGauge.Dec()
p.activeGauge.Inc()
atomic.AddInt64(&p.activeCount, 1)
go func() {
defer func() {
<-p.sem
p.activeGauge.Dec()
atomic.AddInt64(&p.activeCount, -1)
}()
fn()
}()
return nil
}
Backpressure Strategies
When your pool is full, you have four options:
func withBackpressure(ctx context.Context, sem chan struct{}, job func()) error {
select {
// Strategy 1: Block until slot available (default worker pool behavior)
case sem <- struct{}{}:
go func() { defer func() { <-sem }(); job() }()
return nil
// Strategy 2: Reject immediately with error (HTTP 429)
// case sem <- struct{}{}:
// ...
// default:
// return ErrOverloaded
// Strategy 3: Drop with timeout (shed load after deadline)
// case sem <- struct{}{}:
// ...
// case <-time.After(50 * time.Millisecond):
// return ErrTimeout
// Strategy 4: Context-aware (respects caller cancellation)
case <-ctx.Done():
return ctx.Err()
}
}
Use blocking for batch jobs where all work must complete. Use rejection (429) for user-facing APIs where it’s better to fail fast than queue indefinitely.
Common Mistakes
// MISTAKE 1: Forgetting defer on semaphore release
go func() {
sem <- struct{}{}
defer wg.Done()
// If processItem panics, sem never gets <-sem and the slot leaks forever
processItem(item)
<-sem // This line is never reached on panic
}()
// FIX:
go func() {
sem <- struct{}{}
defer wg.Done()
defer func() { <-sem }() // Always runs
processItem(item)
}()
// MISTAKE 2: Unbounded error channel that blocks
errs := make(chan error) // unbuffered
go func() {
errs <- err // blocks if nobody is reading
}()
// FIX: buffer the error channel
errs := make(chan error, numWorkers)
// MISTAKE 3: Spinning on context check outside select
for {
if ctx.Err() != nil { break }
// ... still processes one more item before noticing
}
// FIX: use select
select {
case <-ctx.Done(): return
case job := <-jobs: processJob(job)
}
Production Checklist
Before shipping code with goroutine pools:
- Concurrency limit is explicit and documented
- Semaphore release uses
defer(leak-proof) - Context cancellation propagates to goroutines
- Error collection is buffered to prevent goroutine leaks
- Active goroutine count is exposed as a metric
- Backpressure behavior is defined (block / reject / timeout)
- Load tested at 2× expected peak traffic
- Limit is tuned based on bottleneck (CPU, memory, downstream)
Summary
| Pattern | Use when |
|---|---|
| Buffered channel semaphore | Simple, batch processing from a slice |
| Worker pool | Continuous stream of jobs from a channel |
| Weighted semaphore | Jobs have variable resource cost |
| Context + timeout | Any production code — cancellation is mandatory |
The goal is deliberate concurrency: know your limit, enforce it, measure it, and tune it.
Resources
- Go Concurrency Patterns: Pipelines and Cancellation
- x/sync/semaphore
- Effective Go: Concurrency
- Go blog: Advanced Go Concurrency Patterns
Comments