Concurrency pattern · Intermediate

Semaphore

Limit how many goroutines may run a section of code (or hold a resource) at the same time.

Concurrency Intermediate Complete

🅿️ Analogy

A parking lot has a fixed number of spaces and a gate. Cars arrive freely, but the gate only opens when a space is available; when a car leaves, the next waiting car gets in. The lot’s capacity is a semaphore — it caps how many are inside at once, no matter how many show up.

The problem

You launch many goroutines, but only a handful may do the heavy or scarce thing simultaneously — open database connections, hold file descriptors, or call a rate-limited API. A counting semaphore lets all the goroutines exist while admitting only N into the critical section at a time.

Structure

graph LR
  subgraph tokens["sem: buffered chan, cap N"]
    T1["•"]
    T2["•"]
  end
  G1["goroutine"] -->|acquire| tokens
  G2["goroutine"] -->|acquire| tokens
  G3["goroutine waits…"] -.blocked.-> tokens

Idiomatic Go

A buffered channel is the whole mechanism: send to acquire a slot, receive to release. Here six goroutines run, but the observed peak concurrency never exceeds the limit. Edit and Run:

package main

import (
	"fmt"
	"sync"
)

func main() {
	const limit = 2
	sem := make(chan struct{}, limit) // buffered channel = counting semaphore

	var wg sync.WaitGroup
	var mu sync.Mutex
	running, peak := 0, 0

	for i := 0; i < 6; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()

			sem <- struct{}{}        // acquire: blocks when 'limit' are in flight
			defer func() { <-sem }() // release on exit

			mu.Lock()
			running++
			if running > peak {
				peak = running
			}
			mu.Unlock()

			// simulate some work
			total := 0
			for k := 0; k < 2000000; k++ {
				total += k
			}

			mu.Lock()
			running--
			mu.Unlock()
		}()
	}
	wg.Wait()
	fmt.Printf("limit=%d, observed peak concurrency=%d\n", limit, peak)
}

🐹 Plain channel vs x/sync/semaphore

For a simple “at most N at once,” a chan struct{} of capacity N is the cleanest thing in the language — no imports. When you need weighted acquisition (a task that costs 3 slots) or blocking that respects a context deadline, reach for golang.org/x/sync/semaphore’s Weighted with Acquire(ctx, n).

In the standard library & ecosystem

  • A buffered chan struct{} — the idiomatic counting semaphore.
  • golang.org/x/sync/semaphore — weighted, context-aware.
  • golang.org/x/sync/errgroup with SetLimit — a semaphore baked into error-aware fan-out.

Pitfalls

⚠️ Always release — even on panic or early return

If a goroutine acquires a slot and then returns (or panics) without releasing, that slot is gone forever and the semaphore slowly starves. Pair every acquire with a defer release, exactly as above, so the slot comes back no matter how the goroutine exits.

When to use it — and when not

✅ Reach for it when

  • You spawn many goroutines but only N may touch a limited resource at once (connections, file handles, an API rate limit).
  • You want to cap concurrency for one section without restructuring into a worker pool.
  • You need a weighted limit (each task costs more than one slot) — use x/sync/semaphore.

⛔ Think twice when

  • You have a fixed queue of jobs — a worker pool models that more directly.
  • No real resource constraint exists — the limit just adds latency.

Check your understanding

Score: 0 / 3

1. What is the simplest counting semaphore in Go?

A `chan struct{}` with buffer N holds up to N tokens. Sending blocks once N are in flight (acquire); receiving frees a slot (release).

2. How does a semaphore differ from a worker pool?

Both bound concurrency, but a semaphore gates access (one goroutine per task, N allowed inside) while a pool routes tasks to N persistent workers.

3. When would you use golang.org/x/sync/semaphore over a channel?

x/sync/semaphore.Weighted lets a task acquire N units at once and supports Acquire(ctx, n) that respects cancellation — beyond what a plain buffered channel offers.