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¶
- Go blog — Go Concurrency Patterns: Pipelines and cancellation (bounded section)
-
golang.org/x/sync/semaphoredocs (weighted variant — for reference) -
runtime.GOMAXPROCSdocs
📝 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 andreleases (viadefer) after. At mostNare ever in the critical section → [[bounded-concurrency]]. - This differs from a worker pool (Day 071): a pool reuses
Nlong-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. deferthe 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,
selectonsem <- 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/semaphoregeneralises 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
contextdeadlines combine with a semaphore acquire so a task gives up waiting? (Day 075'sAcquireCtx.)
🧠 Active Recall (answer without looking)¶
-
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. -
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:
contextcancellation, deadlines, and propagation through call chains.
| Time spent | Difficulty | Confidence |
|---|---|---|
| 90 min | 🟦🟦⬜⬜⬜ | 🟦🟦🟦⬜⬜ |
Suggested commit: feat(examples): bounded parallelism with a channel semaphore (day 074)