Skip to content

Day 074 — Bounded Parallelism & Semaphores

Month 3 · Week 3 · ⬅ Day 073 · Day 075 ➡ · Journal index

🎯 Learning Objective

Cap how many goroutines run at once using a counting semaphore built from a buffered channel, so "one goroutine per task" stays safe even for huge task sets.

📚 Topics

  • Buffered channel as a counting semaphore (acquire = send, release = receive)
  • Bounded parallelism without a long-lived worker pool
  • Sizing the limit: CPU-bound vs I/O-bound; GOMAXPROCS

📖 Reading / Sources

📝 Notes

  • A counting semaphore is a buffered channel of capacity N: a send (sem <- struct{}{}) acquires a slot and blocks when full; a receive (<-sem) releases one → [[semaphore]].
  • Use chan struct{} for the token type — it carries no data and costs zero bytes per element → [[empty-struct]].
  • Pattern: spawn one goroutine per task (so each is independent), but each one acquires before working and releases (via defer) after. At most N are ever in the critical section → [[bounded-concurrency]].
  • This differs from a worker pool (Day 071): a pool reuses N long-lived goroutines; a semaphore spawns a fresh goroutine per task but throttles how many run. Pools win when spawn cost matters or you want a fixed set; semaphores win for simplicity and per-task lifetimes.
  • defer the release right after a successful acquire so an early return or panic can't leak a token → permanent under-utilisation or deadlock.
  • For cancellable acquisition, select on sem <- struct{}{} vs <-ctx.Done() so a stuck acquire respects deadlines → [[context-cancellation]].
  • Sizing: CPU-bound work → about runtime.GOMAXPROCS(0) (≈ #cores); I/O-bound work (network, disk) → higher, since goroutines spend most time blocked — measure with a benchmark, don't guess.
  • The weighted golang.org/x/sync/semaphore generalises this when tasks have different "costs"; the channel version is the unit-weight special case and needs no dependency.

💻 Code Examples

// Bound a flood of tasks to `limit` concurrent goroutines with a channel sem.
sem := make(chan struct{}, limit) // capacity == max concurrency
var wg sync.WaitGroup
for _, task := range tasks {
    wg.Add(1)
    go func(task Task) {
        defer wg.Done()
        sem <- struct{}{}        // acquire (blocks when `limit` already running)
        defer func() { <-sem }() // release
        task.Do()
    }(task)
}
wg.Wait()

Full code: examples/month-03/bounded-sem/main.go · Run: go run ./examples/month-03/bounded-sem

🏋️ Exercises / Practice

Exercise Status Link
Counting Semaphore (Acquire/Release/TryAcquire/AcquireCtx) exercises/month-03/week-3/semaphore/
Order-preserving bounded Map with context exercises/month-03/week-3/boundedmap/

🐛 Mistakes Made

  • Acquired with sem <- struct{}{} but forgot the release on an error path → tokens leaked, concurrency silently dropped to zero, program deadlocked. defer func(){ <-sem }() immediately after acquire fixes it.
  • Set the limit to a huge number for I/O work "to be fast" and instead exhausted file descriptors. Tuned it down with a benchmark.

❓ Open Questions

  • How do context deadlines combine with a semaphore acquire so a task gives up waiting? (Day 075's AcquireCtx.)

🧠 Active Recall (answer without looking)

  1. Q: How does a buffered channel act as a counting semaphore, and what are acquire/release?

    A Make `make(chan struct{}, N)`. Sending into it acquires a slot and blocks once `N` are held; receiving releases a slot. At most `N` goroutines can hold a token at once.

  2. Q: Worker pool vs semaphore — when would you pick each?

    A Pool: a fixed set of long-lived goroutines reused across jobs — good when spawn cost matters or you want a stable worker count. Semaphore: one fresh goroutine per task, throttled to N concurrent — simpler when tasks are independent and short-lived.

🪶 Feynman Reflection

A semaphore is a bowl of N tickets. Before a goroutine starts the limited work it grabs a ticket; if the bowl is empty it waits. When it's done it drops the ticket back. So no matter how many goroutines show up, only N ever work at once — and defer guarantees nobody pockets a ticket on the way out.

🕳️ Knowledge Gaps

  • Weighted semaphores for heterogeneous task costs (x/sync/semaphore) — note for later.

✅ Summary

I can bound parallelism with a chan struct{} semaphore: acquire-before-work, defer the release, and size the limit by workload type. I know how it differs from a worker pool.

⏭️ Next Steps / Prep for Tomorrow

  • Day 075: context cancellation, deadlines, and propagation through call chains.

Time spent Difficulty Confidence
90 min 🟦🟦⬜⬜⬜ 🟦🟦🟦⬜⬜

Suggested commit: feat(examples): bounded parallelism with a channel semaphore (day 074)