
Comparing Mutexes, Semaphores and Channels for Shared-State Safety
Two goroutines, one shared counter, and a test suite that passes locally but fails in CI about once a week. That is the shape of most concurrency bugs. Nothing crashes; a result is simply wrong under one particular interleaving of threads. The repair is rarely clever — it usually comes down to picking the right synchronisation primitive and applying it consistently.
Three primitives come up again and again: the mutex, the semaphore and the channel. They overlap, they can often substitute for one another, and the wrong choice gives you code that is either needlessly slow or quietly deadlocked. The examples below are in Go, where all three are first-class, but the reasoning transfers to Rust, Java, C# and anything else with threads.
Start With the Race, Not the Tool
A race condition appears when two threads touch shared state without an agreed order. The classic is a read-modify-write:
count := counter[key]
counter[key] = count + 1
If a second goroutine reads counter[key] between the read and the write, one increment vanishes. Every synchronisation primitive imposes an order on that sequence. What differs is the kind of order: a mutex serialises a section of code, a semaphore limits how many can be inside at once, and a channel moves a value from one owner to another.
Mutexes: Exclusive Access to a Critical Section
A mutex is a lock with a single slot. Whoever holds it may touch the guarded state; everyone else waits in line.
var mu sync.Mutex
var totals = map[string]int{}
func add(key string) {
mu.Lock()
defer mu.Unlock()
totals[key]++
}
The defer is not decoration. It guarantees the unlock on an early return or a panic, which turns a clean failure into a clean failure instead of a permanently frozen program.
Two habits keep mutexes healthy. Keep the critical section small — no network calls, no disk writes, no callbacks into code you do not control. And know whether your language's mutex is reentrant. Many, including Go's sync.Mutex, are not: locking twice in the same goroutine deadlocks immediately, and that mistake is easy to make when a locked helper calls another locked helper. For read-heavy data, a read-write mutex lets many readers in at once while a writer has the floor, though it is only worth the extra bookkeeping when reads genuinely dominate.
Semaphores: Permission Slips for Concurrent Work
A semaphore is a counter with a queue. Each goroutine takes a permit before starting and returns it when finished. With a count of one it behaves much like a mutex, but its usual purpose is different: it bounds concurrency rather than protecting a variable.
Go has no semaphore in the standard library, so the idiom is a buffered channel. Here the channel holds eight permits:
sem := make(chan struct{}, 8)
Acquiring is a send — sem <- struct{}{} — and releasing is a receive, <-sem. A worker that cannot acquire simply blocks until someone else finishes, so no more than eight are ever in flight.
Reach for a semaphore when the resource is genuinely countable: database connections, outbound HTTP requests, open files, jobs on a GPU. A weighted semaphore, such as the one in golang.org/x/sync/semaphore, handles work of unequal size, where one task costs five units and another costs one.
The failure to watch for is a permit that is never returned. Release in a defer immediately after acquiring, and keep the acquires and releases matched. A forgotten release does not announce itself; it quietly lowers your limit until the program grinds to a halt.
Channels: Hand Over the Data Instead of Sharing It
Channels take a different route. Rather than locking shared memory, they move the value itself from one goroutine to another, so exactly one goroutine holds it at any moment. A worker pool shows the appeal:
jobs := make(chan int, 100)
results := make(chan int, 100)
for w := 0; w < 3; w++ {
go func() {
for j := range jobs {
results <- j * j
}
}()
}
Three workers, no locks, and the queue depth is visible in a single line. Closing jobs ends the loop cleanly, which is the channel equivalent of a stop signal.
The rules are unforgiving in a helpful way. Sending on a closed channel panics, so only the sender closes; receiving from a nil channel blocks indefinitely, which is sometimes used deliberately to disable one branch of a select.
The trade-off is overhead. A channel send involves a queue, a scheduler hand-off and often an allocation, so using one to guard a three-line counter costs more than a mutex. Save channels for ownership transfer, pipelines, fan-out and cancellation — not for guarding a map because channels feel more modern.
Choosing Between Them
- One shared structure, many writers, short work: a mutex around the whole structure, exposed through small methods.
- Read-mostly data: a read-write mutex, provided reads really do dominate.
- A cap on concurrent operations: a semaphore sized to the resource, not to the number of goroutines you happen to start.
- Passing data between goroutines or building a pipeline: a channel, with the sender closing it when the stream ends.
- Waiting for a known set of tasks: a WaitGroup or equivalent, plus a channel only if you need the results.
- A simple counter or flag with no other invariant: an atomic, which is cheaper than all of the above.
A useful sanity check: the primitive you pick should make your intent obvious to the next reader. If a channel exists purely to protect one map, engineers will assume messages are flowing through it. If a mutex is held while eight jobs run, they will assume the limit comes from the lock.
Staying Out of Deadlock
Safety and liveness are separate problems. A mutex can make your data correct and your program frozen. These habits cover most of the ground:
- Fix a lock order. If one function takes mutex 1 then 2, and another takes 2 then 1, a single unlucky interleaving is enough to hang both.
- Never call out while holding a lock. No HTTP, no disk, no user callbacks if you can avoid it.
- Give every wait a way out. Use select with a context or a timeout, so a message that never arrives fails loudly instead of hanging forever.
- Release with defer straight after acquiring, not at the end of a long function body.
- Close channels from the sender, once. Receivers never close, and a closed channel cannot be reopened.
Go's race detector, go test -race, finds unsynchronised reads and writes, which is most of the battle. It will not find deadlocks or logical ordering bugs, so pair it with a test that runs many goroutines under a timeout that fails the test rather than hanging the build.
Pick One Owner and Enforce It in One Place
Before choosing a primitive, write down the invariant: "this total always equals the sum of the parts", or "no more than eight requests are in flight". Then decide who owns it. Either a mutex guards it and every access goes through methods that lock, or a single goroutine owns it and everyone else talks to that goroutine over a channel.
Mixing the two is where confusion starts: two half-owners, two mental models, no single place to check correctness. Wrap the state in a small type, keep the lock or the channel inside it, and let callers see ordinary method calls. The rest of the codebase then stays free of lock discipline, and there is one file to read when something goes wrong. Draw that boundary well, and the primitive matters less than the shape of the code around it.
Photo: Chorn Travit / Pexels
Designing Backpressure into Event-Driven Systems
Queues, rate limits and load shedding are the three levers that stop a busy producer overwhelming a slower...
Debugging Race Conditions with ThreadSanitizer: A Practical Walkthrough
ThreadSanitizer catches data races that only appear under load. Here is how to build with it, read its...




