skip to content

Semaphores and Permits

Capping how many things run at once is a buffered channel used as a semaphore, or weighted permits when the units differ in size. Interviewers press on why a permit count is not a rate.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

4

How does a buffered `chan struct{}` limit how many goroutines do work at the same time?

level: juniorimportance: must knowfreq 60%

answer

  1. capacity is the permit count
  2. sending is the acquire
  3. an empty struct costs zero bytes
  4. release in a defer, not at the end

basics

~20 s

A buffered channel of capacity N holds N permits. Sending an empty struct takes a permit and blocks once N are outstanding; a receive in a defer gives it back. At most N goroutines run the guarded work.

solid answer

~40 s

The channel's buffer is the permit count: `sem := make(chan struct{}, 4)` means four permits. A goroutine acquires with `sem <- struct{}{}` and releases with `<-sem`. The fifth concurrent acquire finds the buffer full and blocks until somebody receives, which is the entire enforcement mechanism — there is no counter and no lock in the code. `struct{}` is used because an empty struct is zero bytes, so the values carry nothing and only occupancy matters; any element type would work. The release must be deferred immediately after a successful acquire (`defer func() { <-sem }()`), so an early return or a panic unwinding that goroutine still returns the permit. A release placed at the end of the function, or duplicated into error branches, is how the effective limit quietly ratchets down to zero.

code

go · 9 lines
go
sem := make(chan struct{}, 4) // four permits

for _, part := range parts {
	sem <- struct{}{} // acquire: blocks while four are in flight
	go func() {
		defer func() { <-sem }() // release on every exit path
		uploadPart(part)
	}()
}

go deeper

for a junior

Be ready to write the four lines from memory: make the channel with a capacity, send to acquire, defer a receive to release. Say out loud that the fifth acquire blocks rather than fails.

for a middle

Explain why the mechanism needs no counter and no lock: the channel's own buffer and wait queue do the accounting. Be precise that the release must be deferred right after a successful acquire.

for a senior

Show that you read the placement of the acquire and the release as the two review checkpoints, and that you can name what the limit does not cover — code paths that skip the acquire, and cost differences between units.

for a principal

Frame when a two-line channel idiom is enough and when a team is better served by a named helper with the acquire and release paired inside it, so the pattern cannot be half-copied into new call sites.

## The idea Go does not ship a counting semaphore type in its standard library, and it does not need one: a buffered channel already *is* one. A channel created with `make(chan struct{}, N)` can hold N values before a send blocks. If you agree, by convention, that "holding a value in the buffer" means "holding a permit", then: - **acquire** = `sem <- struct{}{}` — put one value in. Blocks while N values are already in. - **release** = `<-sem` — take one value out, freeing a slot for a blocked sender. Nothing else is required. The runtime's channel implementation supplies the mutual exclusion, the waiting queue and the wake-up. ## Why `struct{}` The empty struct type `struct{}` has size zero. The values sent are never read and never mean anything — only the *count* of values in the buffer matters — so the zero-size type says exactly that to the reader and costs no per-element memory in the channel's ring buffer. `chan bool` or `chan int` would behave identically; `chan struct{}` is the idiom because it makes the meaninglessness of the value explicit. The literal is slightly awkward: `struct{}{}` is the type `struct{}` followed by an empty composite literal `{}`. ## The shape in practice ```go sem := make(chan struct{}, 4) for _, part := range parts { sem <- struct{}{} go func() { defer func() { <-sem }() uploadPart(part) }() } ``` Four uploads proceed. The fifth iteration's send finds the buffer full and parks the loop until one of the running uploads releases. This is *bounded concurrency*: the work is still concurrent, but the degree of concurrency has a ceiling you chose. ## Why the release belongs in a `defer` A deferred call runs on **every** exit path of the function that registered it: a normal return, an early `return err`, and a panic unwinding that goroutine. Registering `defer func() { <-sem }()` as the first statement after a successful acquire therefore makes the acquire/release pair total. The two common ways to get this wrong: 1. **Release at the bottom of the function.** The first `return err` above it skips the release. That permit is now held by a function that has already returned, forever. Each occurrence lowers the effective limit by one, and enough of them wedge the system at zero concurrency with no error anywhere. 2. **Release inside each branch.** This works until somebody adds a branch and forgets, which is the same bug with a longer fuse. Note the closure: `defer <-sem` is not valid Go — `defer` takes a function call — so the receive is wrapped, `defer func() { <-sem }()`. The wrapping also means the receive happens at exit, not at the `defer` statement. ## What it does and does not guarantee - **It bounds simultaneity, not order.** Which blocked sender proceeds when a slot frees is decided by the runtime's channel wait queue; treat the order as unspecified for reasoning purposes. - **It bounds only the code that actually acquires.** A permit is a convention, not an enforcement: any code path that does the work without acquiring is invisible to the limit. - **It counts units, not cost.** Four permits mean four things in flight whether each is a hundred bytes or a hundred megabytes. - **It does not bound goroutines by itself.** Where the acquire sits relative to the `go` statement decides whether you are capping live goroutines or only the guarded work inside them. ## Acquiring without waiting forever A bare send has no timeout and no cancellation. Wrapping it in a `select` restores both: ```go select { case sem <- struct{}{}: defer func() { <-sem }() case <-ctx.Done(): return ctx.Err() } ``` The `defer` must be inside the successful case — registering it before the `select` would release a permit that was never acquired, and a receive from a semaphore channel that is empty blocks the releaser instead, which is a second, more confusing hang. ## Reading it in review When you see `make(chan struct{}, N)` in Go code, read the capacity as "the maximum number of X at once" and then look for exactly two things: that every acquire is matched by a deferred release, and that the acquire happens where you intend the blocking to occur.

  • What happens to the permit if the guarded work panics?
    The deferred receive still runs as the panic unwinds that goroutine, so the permit is returned. That is worth knowing but rarely decisive: an unrecovered panic in any goroutine terminates the whole process, so the returned permit only matters when a `recover` in a deferred function of that same goroutine stops the panic.
  • Does the channel have to carry `struct{}` for this to work?
    No. Only the buffer's occupancy is read, so `chan bool` or `chan int` behaves identically. `struct{}` is conventional because an empty struct occupies zero bytes, so the ring buffer costs nothing per element, and because it tells the next reader that the value itself is meaningless.
  • Why write `defer func() { <-sem }()` rather than something shorter?
    `defer` requires a function call, and `<-sem` is an expression, not a call — `defer <-sem` does not compile. Wrapping it in a closure is the idiom, and it also makes the timing explicit: the receive happens when the function exits, not when the `defer` statement is reached.

Four tokens sit on a table. You may not start until you have one in your hand, and you put it back the moment you finish — so at most four people are ever working, however many are waiting.

saying these in an interview costs you the question

  • Thinks the values sent matter rather than the buffer's occupancy
  • Says a buffered channel makes the guarded work run faster
  • Releases the permit on the last line instead of in a defer
  • Believes a send on a full channel drops the value instead of blocking
  • Claims each struct{} sent allocates memory
open as a page

Why send on a semaphore `chan struct{}` before the `go` statement rather than inside the goroutine?

level: middleimportance: should knowfreq 45%

basics

~10 s

Acquiring before the go statement makes the spawning loop block, so only about N goroutines ever exist. Acquiring inside the goroutine starts one per item immediately: it bounds work in flight, not memory.

open as a page

A bulk uploader's in-flight permit count sits pinned at its semaphore limit with nothing finishing — how do you find the cause?

level: seniorimportance: should knowfreq 35%

basics

~20 s

That shape means permits were taken and never returned. Look for an acquire whose release is not deferred immediately after it, since an early error return above the defer never registers it. Confirm with the goroutine profile.

open as a page

What does `semaphore.Weighted` from golang.org/x/sync give you that a buffered `chan struct{}` cannot?

level: middleimportance: nice to knowfreq 28%

basics

~20 s

Two things: weights, so one unit can consume several units of capacity rather than exactly one, and a context-aware Acquire that returns an error when the caller's context is cancelled. A channel semaphore counts identical, uncancellable permits.

open as a page