The concurrency primitive behind the name: what a binary semaphore does, how it differs from a mutex, and how to build one in Go.

© 2026 Binary Semaphore
A semaphore is a counter that hands out permission. Threads ask for it, and if the counter has anything left, they take one and continue. If it doesn't, they wait.
A binary semaphore is the smallest useful version: the counter holds 0 or 1.
Available, or taken. One key, one holder.
Dijkstra gave it two operations, P and V (from Dutch; he later glossed P as prolaag, "try to decrease", and V as verhogen, "increase"). Modern names are kinder:
1, drop it to 0 and continue. Otherwise
block until someone raises it.1, waking one waiter if any exist.Only one thread gets past wait until someone calls signal, so by convention a binary
semaphore gives you mutual exclusion. Note the word convention. The semaphore doesn't
enforce anything on its own. It counts. Discipline is on the callers.
The general form is a counting semaphore with a value up to N, which lets N
threads through at once: connection pools, worker limits, rate caps.
| Type | Range | Lets through | Typical use |
|---|---|---|---|
| Binary | 0–1 | one thread | mutual exclusion, signaling |
| Counting | 0–N | up to N threads | bounded pools, rate limits |
"A binary semaphore is just a counting semaphore with N = 1" is the usual one-liner,
and it's close enough to be useful and wrong enough to bite you.
In Dijkstra's formulation, V has no ceiling. Initialise a counting semaphore to 1,
call signal twice, and the value is now 2. Two threads walk into your critical
section. A real binary semaphore refuses that: FreeRTOS returns pdFAIL when you give
back a binary semaphore that's already available, and Windows ReleaseSemaphore fails if
the release would push past the maximum count.
So the honest version: a binary semaphore is a counting semaphore capped at 1, where the cap is enforced rather than assumed. That enforcement is the whole point, because double-release is the classic semaphore bug.
The usual answer is ownership: a mutex is owned by the thread that locked it, and only that thread may unlock it. A semaphore has no owner, so anyone can signal it.
That's the right intuition, but it's a property of specific implementations rather than a law:
MutexGuard, and unlocking is the
guard's destructor. There is no way to hand it to another thread and unlock from there.std::mutex says unlocking a mutex you don't own is undefined behaviour.PTHREAD_MUTEX_ERRORCHECK. The default
type is undefined behaviour, same as C++.sync.Mutex docs state that one goroutine may lock a
mutex and arrange for another goroutine to unlock it.That last one matters if you're writing Go, because the neat "mutex has an owner, semaphore doesn't" line is not true of the mutex sitting in your standard library.
What actually separates them in Go is intent and tooling:
sync.Mutex | channel semaphore | |
|---|---|---|
| Cancellable wait | No | Yes, via select on ctx.Done() |
| Try-lock | TryLock (Go 1.18+) | Yes, via select + default |
| Fairness | Barging, with a FIFO handoff after a waiter has been starved ~1ms | FIFO, channel wait queues are ordered |
| Uncontended cost | A single CAS | A runtime lock on the channel, noticeably more |
| Tooling | Mutex profile, race detector, deadlock hints | Nothing comparable |
| Capacity | 1 | Any N |
Rule of thumb: a mutex guards data, a semaphore counts permits. If you can name the thing being protected, use a mutex. If you can count the thing being limited, use a semaphore.
Go has no semaphore in the standard library, but a buffered channel is one. Capacity is the count, a send takes a permit, a receive gives it back.
Most write-ups stop at four lines. Those four lines have three problems: you can't cancel a wait, you can't attempt a wait, and a double-release deadlocks silently instead of telling you what you did. Here's a version that handles all three.
// Package semaphore provides counting and binary semaphores over buffered channels.
package semaphore
import (
"context"
"fmt"
)
// Semaphore admits a fixed number of concurrent holders.
//
// The zero value is not usable; use New or NewBinary. A Semaphore is safe for
// concurrent use and must not be copied after first use.
type Semaphore struct {
// An empty struct is zero bytes, so the buffer carries permission and no data.
permits chan struct{}
}
// New returns a semaphore admitting at most n holders at a time.
func New(n int
Mutual exclusion, the binary case:
sem := semaphore.NewBinary()
if err := sem.Acquire(ctx); err != nil {
return err // the caller gave up waiting
}
defer sem.Release()
// critical sectionBounded fan-out, the counting case:
func fetchAll(ctx context.Context, urls []string) error {
sem := semaphore.New(8)
g, ctx := errgroup.WithContext(ctx)
for _, u := range urls {
g.Go(func() error {
if err := sem.Acquire
(Since Go 1.22 the loop variable is per-iteration, so the old u := u shuffle is gone.)
Cross-goroutine signalling, which is the thing a mutex genuinely cannot do:
ready := semaphore.NewBinary()
_ = ready.Acquire(context.Background()) // start in the taken state
go func() {
warmCache()
ready.Release() // released by a goroutine that never acquired it
}()
if err := ready.Acquire(ctx); err != nil { // blocks until the cache is warm
return err
}It cannot detect all misuse. Release panics on an obviously unheld permit, but
consider: A releases correctly, B acquires, then buggy C releases a permit it never took.
C's release succeeds and now two goroutines think they hold the same slot. There is no way
to catch that without ownership, and ownership is exactly what a semaphore gives up. This
is the real cost of the primitive, and it's why "use a mutex to guard data" is good advice
rather than a stylistic preference.
It is slower than a mutex. An uncontended sync.Mutex lock is a compare-and-swap. A
channel send takes the channel's internal lock every time. If all you need is mutual
exclusion in one process, the mutex wins on both speed and diagnostics.
Permits are uniform. Every holder costs one. If your tasks have wildly different weights (a 4 GB job and a 40 MB job shouldn't count the same), you want a weighted semaphore.
Before writing your own, check whether one of these already fits:
sync.Mutex, or sync.RWMutex if reads dominate.errgroup.Group.SetLimit(n) does this in one line and ties
cancellation to the group.golang.org/x/sync/semaphore, which is context-aware and takes a
cost per acquire.close(ch), sync.Once, or a context. Closing a channel wakes
every waiter, which a semaphore's one-at-a-time release does not.golang.org/x/time/rate. A semaphore bounds how many run
at once, not how often they start.Write your own when you want the limit to carry a name from your domain, or when you want metrics and behaviour on it that a generic type doesn't expose. Otherwise, use the library.
The primitive is a good metaphor for the kind of software worth building: a small, well-defined thing that coordinates everything around it without getting in the way. It does one job, it does it in about forty lines, and its failure modes are worth understanding rather than hiding.
That's the bar these write-ups aim for too.