{} The Go Reference

Concurrency pattern · Intermediate

Semaphore

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

Concurrency Intermediate ⏱ 2 min read 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:

semaphore.go — editable & runnable
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 / 5

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.

4. What guarantees a token is always released, even if the work panics?

If a goroutine acquires a slot and then panics or returns early without releasing, that slot is gone forever — concurrency silently shrinks until it deadlocks. Pair every acquire with a deferred release so it runs on every exit path.

5. To bound the number of goroutines (not just the work inside them), where do you acquire?

If you `go func(){ sem<-tok; ...; <-sem }()` you've already launched every goroutine — you bound work-in-flight but not goroutine count. Acquiring in the loop *before* the `go` blocks the launcher, capping how many goroutines exist at once (useful when each one is itself heavy).

Comments

Sign in with GitHub to join the discussion.