Skip to main content

Limit Goroutines in Go: Concurrency Control Patterns That Scale

Published: April 24, 2026 Updated: August 28, 2026 Larry Qu 8 min read

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:

  1. Memory exhaustion — 100k goroutines × ~8KB stack = ~800MB minimum
  2. Scheduler thrashing — Go’s runtime scheduler has overhead managing thousands of goroutines
  3. Connection storms — each goroutine might open a DB connection or HTTP request, overwhelming downstream
  4. 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:

  1. Start conservative (e.g., NumCPU() for CPU-bound, NumCPU() * 4 for I/O)
  2. Load test with realistic traffic
  3. Watch: CPU utilization, memory, downstream error rates, latency percentiles
  4. Increase limit until a bottleneck appears (CPU, memory, downstream limit)
  5. 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

Comments

👍 Was this article helpful?