07 - Semaphore & Bounded Concurrency

📋 Jump to Takeaways

Sometimes you don't need a full worker pool. You just want to say: "run as many goroutines as you want, but no more than N at a time." That's a semaphore.

Think of it like a bouncer at a bar — only N people can be inside at once. Others wait at the door until someone leaves.

The simplest way to think about it:

Worker Pool — you hire 3 employees. They sit at their desks waiting. Work arrives in a pile. They grab one task at a time from the pile. When the pile is empty, they wait for more. The employees are always there.

Fan-Out/Fan-In — same 3 employees, but each has their own outbox. When they finish a task, they put the result in their own tray. A manager collects from all 3 trays and merges them into one pile for the next department.

Semaphore — you have 100 tasks and 100 volunteers ready to go. But the room only fits 3 people. So 3 go in, do their thing, come out. Next 3 go in. The volunteers are temporary — they show up, do one task, and leave.

Why Semaphores

A worker pool creates a fixed number of goroutines upfront — they wait for jobs through a channel. Fan-out/fan-in is similar but each worker has its own output channel, merged together — composable into pipelines. A semaphore takes a different approach: goroutines spawn freely (one per task), but only N can run at the same time. The rest block until a slot opens. All three achieve bounded concurrency — the difference is whether you limit workers upfront or gate them at runtime.

Worker Pool Fan-Out/Fan-In Semaphore
Goroutines Few, long-lived, reused Few, long-lived, reused Many, short-lived, one per task
Output Shared channel Per-worker channels, merged Each goroutine handles its own
Composability Standalone Chains into pipelines Standalone
Setup Channels, WaitGroups, worker loop Channels, WaitGroups, merge step A buffered channel or semaphore.Weighted
Best for Stream of jobs, long-running processing Pipeline stages that need parallel execution Batch of independent tasks, limiting parallel I/O

Use semaphores when:

  • You have a batch of tasks and want to limit how many run at once
  • You're making parallel HTTP calls and don't want to open 1,000 connections
  • You need to protect a resource with limited capacity (file handles, DB connections)
  • A full worker pool feels like too much ceremony for the job

Channel-Based Semaphore

The simplest semaphore in Go is a buffered channel.

func main() {
    sem := make(chan struct{}, 3) // max 3 concurrent
    var wg sync.WaitGroup

    for i := 0; i < 10; i++ {
        wg.Add(1)
        sem <- struct{}{} // acquire — blocks if 3 are already running

        go func(id int) {
            defer wg.Done()
            defer func() { <-sem }() // release

            fmt.Printf("task %d running\n", id)
            time.Sleep(time.Second) // simulate work
        }(i)
    }

    wg.Wait()
}

The buffer size is the concurrency limit. sem <- struct{}{} acquires a slot. <-sem releases it. When the buffer is full, the next acquire blocks until a slot opens.

Semaphore with Context

Add cancellation so you don't block forever waiting for a slot.

func acquire(ctx context.Context, sem chan struct{}) error {
    select {
    case sem <- struct{}{}:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    sem := make(chan struct{}, 3)
    var wg sync.WaitGroup

    for i := 0; i < 10; i++ {
        if err := acquire(ctx, sem); err != nil {
            fmt.Println("cancelled:", err)
            break
        }

        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            defer func() { <-sem }()

            fmt.Printf("task %d running\n", id)
            time.Sleep(2 * time.Second) // simulate work
        }(i)
    }

    wg.Wait()
}

If the context expires while waiting for a slot, the loop breaks instead of blocking forever.

Semaphore ≠ rate limiter. A semaphore limits how many operations run at the same time (max 3 concurrent). A rate limiter limits how many operations happen per unit of time (max 10/second). If all 3 semaphore slots free up in 1ms, the next 3 start immediately — no time gating. You can combine both when you need to limit concurrency AND throughput:

sem := make(chan struct{}, 3)         // max 3 concurrent (or use semaphore.Weighted — covered below)
limiter := rate.NewLimiter(10, 1)     // max 10/sec

for _, job := range jobs {
    limiter.Wait(ctx)                 // rate gate
    sem <- struct{}{}                 // concurrency gate
    go func(j Job) {
        defer func() { <-sem }()
        process(j)
    }(j)
}

Weighted Semaphore

The official golang.org/x/sync/semaphore package. More flexible than a channel — supports acquiring multiple slots at once.

go get golang.org/x/sync/semaphore

Basic usage — same as the channel approach but with context support built in:

import "golang.org/x/sync/semaphore"

func main() {
    ctx := context.Background()
    sem := semaphore.NewWeighted(3) // max 3 concurrent
    var wg sync.WaitGroup

    for i := 0; i < 10; i++ {
        // Weight 1 per task — if all tasks use the same weight, the number doesn't matter.
        // NewWeighted(3) + Acquire(1) = same as NewWeighted(6) + Acquire(2): max 3 concurrent.
        // Weights only matter when tasks have different sizes (see below).
        if err := sem.Acquire(ctx, 1); err != nil {
            fmt.Println("acquire error:", err)
            break
        }

        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            defer sem.Release(1)

            fmt.Printf("task %d running\n", id)
            time.Sleep(time.Second) // simulate work
        }(i)
    }

    wg.Wait()
}

Acquire(ctx, n) takes n slots. Release(n) returns them. The real power is weighted access — some tasks are heavier than others and can take more slots:

func main() {
    ctx := context.Background()
    sem := semaphore.NewWeighted(10) // 10 total units

    tasks := []struct {
        name   string
        weight int64
    }{
        {"light-1", 1},
        {"light-2", 1},
        {"heavy-1", 5},
        {"medium-1", 3},
        {"heavy-2", 5},
        {"light-3", 1},
    }

    var wg sync.WaitGroup
    for _, task := range tasks {
        if err := sem.Acquire(ctx, task.weight); err != nil {
            break
        }

        wg.Add(1)
        go func(name string, w int64) {
            defer wg.Done()
            defer sem.Release(w)

            fmt.Printf("%s (weight %d) running\n", name, w)
            time.Sleep(time.Second) // simulate work
        }(task.name, task.weight)
    }

    wg.Wait()
}

A heavy task (weight 5) blocks half the capacity. Light tasks (weight 1) can fill the remaining slots. This models real scenarios like database connection pools or memory-limited processing.

Practical Example: Bounded File Processing

Process files concurrently, but limit to 5 at a time to avoid opening too many file handles.

func processFile(ctx context.Context, path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close()

    // simulate processing
    time.Sleep(200 * time.Millisecond) // simulate file I/O
    fmt.Println("processed:", path)
    return nil
}

func processAll(ctx context.Context, paths []string, maxConcurrent int64) error {
    sem := semaphore.NewWeighted(maxConcurrent)
    var wg sync.WaitGroup
    errCh := make(chan error, len(paths))

    for _, path := range paths {
        if err := sem.Acquire(ctx, 1); err != nil {
            return err
        }

        wg.Add(1)
        go func(p string) {
            defer wg.Done()
            defer sem.Release(1)

            if err := processFile(ctx, p); err != nil {
                errCh <- fmt.Errorf("%s: %w", p, err)
            }
        }(path)
    }

    wg.Wait()
    close(errCh)

    for err := range errCh {
        return err // return first error
    }
    return nil
}

Simple, effective, and prevents resource exhaustion.

Key Takeaways

  • Buffered channel = simple semaphore. Buffer size = concurrency limit
  • sem <- struct{}{} to acquire, <-sem to release
  • Always use context with semaphores to avoid blocking forever
  • golang.org/x/sync/semaphore for weighted semaphores — tasks can take multiple slots
  • Semaphores limit concurrency without the setup of a worker pool
  • Use semaphores for bounding I/O (file handles, HTTP connections, DB queries)
  • Use worker pools when goroutines are long-lived and process many jobs

🚀 Ready to run?

Complete runnable examples for this lesson.

📝 Ready to test your knowledge?

Answer the quiz below to mark this lesson complete.

Spot something off? Report an issue

© 2026 ByteLearn.dev. Free courses for developers. · Privacy