Semaphore vs mutex vs lock
These three words get mixed up constantly, partly because they overlap, and partly because languages use them differently. The short version:
- A lock is the general idea: something that controls access to a shared resource.
- A mutex is a lock that allows one holder at a time, who then has to release it.
- A semaphore is a counter that controls how many threads may proceed, and any thread can raise it.
The rest of this article covers what that means, and why the differences matter. Demos ran on one Mac with Python 3.14 and C.
Why they exist
Threads share memory, and when two change the same data at once, updates get lost. In a test with two C threads each adding 20 million to a counter, only about half the increments survived (concurrency guide). Code that touches shared data is called a critical section, and it needs some way to keep other threads out.
Lock: the general term
“Lock” is the family name. It means any mechanism that limits concurrent access. Mutexes, spinlocks, read-write locks and file locks are all locks.
The word is also used loosely. Java’s ReentrantLock, Python’s threading.Lock and C++‘s std::mutex are all, more or less, mutexes under different names.
| Lock type | Allows |
|---|---|
| Mutex | One holder, who sleeps while waiting |
| Spinlock | One holder, who loops while waiting |
| Read-write lock | Many readers, or one writer |
| Reentrant lock | One holder, who can take it again |
| Distributed lock | One holder across machines |
Mutex
Mutex stands for mutual exclusion. Its rule is simple: at most one thread holds it.
import threading
lock = threading.Lock()
with lock:
# only one thread at a time runs this
counter += 1
The key idea is ownership. The thread that locks a mutex is meant to be the one that unlocks it. The mutex protects a piece of data, and the holder has exclusive use of it. It’s like the single key to a room: whoever takes it has to bring it back.
Ownership in practice
Ownership isn’t always enforced. This test tried to release each primitive from a thread that never acquired it:
import threading
results = {}
def try_release(name, primitive):
try:
primitive.release()
results[name] = "allowed"
except (RuntimeError, ValueError) as e:
results[name] = f"error: {e}"
lock = threading.Lock(); lock.acquire()
rlock = threading.RLock(); rlock.acquire()
sem = threading.Semaphore(0)
bsem = threading.BoundedSemaphore(1)
for name, p in (("Lock", lock), ("RLock", rlock), ("Semaphore", sem)):
t = threading.Thread(target=try_release, args=(name, p))
t.start(); t.join()
try_release("BoundedSemaphore over-release", bsem)
for k, v in results.items():
print(f"{k}: {v}")
Output:
Lock: allowed
RLock: error: cannot release un-acquired lock
Semaphore: allowed
BoundedSemaphore over-release: error: Semaphore released too many times
Python’s plain Lock allows it, and its RLock refuses, because it records an owner. POSIX’s default mutex gives undefined behaviour here, and an error-checking mutex type returns an error. So ownership is always the correct way to use a mutex, and only some implementations check it.
Ownership has real benefits:
- Priority inheritance is possible, because the system knows who to boost.
- Deadlock detection is possible, because it knows who holds what.
- Reentrancy is possible, because it knows who’s asking again.
- Errors are easier to find.
Reentrant locks
If a thread holds a normal mutex and tries to take it again, it waits for itself forever. A reentrant, or recursive, lock counts acquisitions by the same owner, and releases at zero.
lock = threading.RLock()
def outer():
with lock:
inner()
def inner():
with lock: # fine with RLock, would deadlock with Lock
...
Java’s synchronized is reentrant. It’s convenient, though needing one is often a sign that locking is mixed in with logic in a confusing way.
Semaphore
A semaphore is a counter with two atomic operations, invented by Edsger Dijkstra in the 1960s. He called them P and V, from Dutch words.
| Operation | Other names | Effect |
|---|---|---|
| P | wait, acquire, down | If the count is above 0, subtract 1. Otherwise sleep |
| V | signal, release, up, post | Add 1, and wake a waiter |
There’s no owner. Any thread can call either operation. A semaphore doesn’t know who’s “holding” it, because nothing is held. It’s closer to a count of permits than to a key.
A useful picture is a car park with a sign showing free spaces. Cars take a space and leave. The sign tracks the number, not which car is where.
Counting semaphores: limiting concurrency
The most common use is capping how many threads can do something. This allows three at a time:
import threading, time
slots = threading.Semaphore(3) # at most three at once
lock = threading.Lock()
running = peak = 0
def job():
global running, peak
with slots:
with lock:
running += 1
peak = max(peak, running)
time.sleep(0.05) # stands in for a database call
with lock:
running -= 1
start = time.perf_counter()
threads = [threading.Thread(target=job) for _ in range(12)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"peak running: {peak}, took {time.perf_counter() - start:.2f}s")
Output:
peak running: 3, took 0.22s
Twelve 50 ms jobs ran in four batches of three, so the program took about 0.2 seconds, and the count never went above 3.
Common uses include limiting connections to a database, requests to a rate-limited API, or concurrent downloads. In async code, asyncio.Semaphore does the same for tasks.
Note that a semaphore limits how many, and doesn’t protect data. In this example, the three running jobs still needed a real lock to update running safely.
Binary semaphores
A semaphore initialized to 1 allows one thread at a time, which makes it look like a mutex. It isn’t quite one.
| Mutex | Binary semaphore | |
|---|---|---|
| Purpose | Protect data | Signal, or limit to one |
| Owner | Yes | No |
| Who can release | The holder | Any thread |
| Priority inheritance | Possible | Not possible |
| Reentrant versions | Yes | No |
| Starting value | Unlocked | 0 or 1 |
The starting value shows the difference. A semaphore can start at 0, which means “nothing has happened yet”, and that has no mutex equivalent.
Signalling
Because any thread can release, semaphores are good for one thread telling another that something happened.
ready = threading.Semaphore(0)
def worker():
prepare()
ready.release() # signal
def main():
ready.acquire() # sleeps until signalled
use_it()
A semaphore has memory: if the signal comes first, the count stays at 1, and the waiter passes straight through. A condition variable doesn’t work this way: a signal with no waiter is lost.
Using a mutex for this would be wrong, since it’d mean one thread unlocking what another locked.
Bounded semaphores
Too many releases silently raise the count, so more threads get in than intended. Python’s BoundedSemaphore catches this, as the test above showed: “Semaphore released too many times”. It’s a good default when a semaphore guards a fixed resource.
The core difference
| Lock, in general | Mutex | Semaphore | |
|---|---|---|---|
| What it is | Family of mechanisms | Lock with one owner | Counter |
| Allows | Depends on the type | One | N |
| Ownership | Depends | Yes | No |
| Main purpose | Protect access | Protect data | Count permits, signal |
| Released by | Depends | The owner | Anyone |
| Mental model | A key | Permits |
The clearest single distinction: a mutex is for protecting something, and a semaphore is for counting or signalling.
How they’re built
Spinlocks
The simplest lock loops on an atomic instruction:
while (atomic_flag_test_and_set(&flag)) {
/* spin */
}
/* critical section */
atomic_flag_clear(&flag);
Spinning wastes CPU while waiting, but it’s fast if the wait is shorter than a context switch. Operating system kernels use spinlocks where sleeping isn’t allowed, as in interrupt handlers.
Futexes
On Linux, modern mutexes and semaphores are built on futexes, for “fast userspace mutex”. The idea is to avoid the kernel when possible.
- Try an atomic operation on a value in user memory.
- If that works, the lock is taken, with no system call.
- If not, ask the kernel to sleep until the value changes.
- An unlock wakes a sleeper only if there might be one.
A mutex often spins briefly first, on the bet that the holder will finish soon, and only then sleeps. A semaphore on a futex is the same idea, with a count in place of a flag.
What they cost
Average uncontended time for one lock and unlock, on one Mac:
| Primitive | Time |
|---|---|
| Spinlock | 1.0 ns |
| Dispatch semaphore | 3.6 ns |
| Read-write lock, read side | 6.1 ns |
| Mutex | 6.4 ns |
All of these are tiny, which shows that a lock with no contention costs almost nothing. These figures are for one thread, on one platform, and a semaphore isn’t generally faster than a mutex. Contention is what costs, because threads have to wait, sleep and wake. In the concurrency guide, two threads fighting over one mutex took about 7 ns per operation, or more, once they were contending.
Other related primitives
Read-write lock. Many readers, or one writer. It suits data that’s read far more than it’s written, and it needs a policy, or writers can starve. A read lock isn’t free: readers still touch a shared counter, so on short reads, the cache-line contention can cancel the benefit.
Condition variable. It lets a thread sleep until a condition holds. It works with a mutex, and it’s stateless, so waiters have to check in a loop.
with cond:
while not ready:
cond.wait()
Barrier. Makes N threads wait until all arrive.
Latch. A one-time gate, which opens when a count reaches zero.
Monitor. A mutex combined with condition variables, built into an object. Java’s synchronized is one.
Event. A flag that threads can wait on, and that stays set.
Classic problems
Producer-consumer
A bounded buffer shows why both tools are useful together. It needs:
- A semaphore counting free slots.
- A semaphore counting filled slots.
- A mutex protecting the buffer.
import threading, time
from collections import deque
SIZE = 3
items = deque()
mutex = threading.Lock()
empty = threading.Semaphore(SIZE) # free slots
full = threading.Semaphore(0) # filled slots
consumed = []
def producer(n):
for i in range(n):
empty.acquire()
with mutex:
items.append(i)
full.release()
def consumer(n):
for _ in range(n):
full.acquire()
with mutex:
consumed.append(items.popleft())
empty.release()
threads = [threading.Thread(target=producer, args=(1000,)),
threading.Thread(target=consumer, args=(1000,))]
for t in threads: t.start()
for t in threads: t.join()
print("consumed", len(consumed), "in order:", consumed == list(range(1000)), "left:", len(items))
Output:
consumed 1000 in order: True left: 0
The semaphores coordinate when threads run, and the mutex protects the data. The order of acquires matters. Taking the mutex first, then waiting on empty, can deadlock: a producer holds the mutex while it sleeps, so a consumer can never free a slot.
Readers and writers
Allow many readers or one writer. A simple solution gives readers priority, and writers can starve if readers keep arriving. Fair versions need more care.
Dining philosophers
Five philosophers share five forks, and each needs two. If each picks up the left fork first, all can hold one and wait forever. That’s deadlock, from circular wait. The usual fix is lock ordering: always take the lower-numbered fork first. Another is a semaphore allowing only four at the table.
What goes wrong
Deadlock
Threads wait for each other forever. The fix is to take locks in a consistent global order, and to avoid waiting while holding a lock. The concurrency guide has a working example.
Priority inversion
A low-priority thread holds a lock that a high-priority thread needs. A medium-priority thread then keeps the low one from running, so the high-priority thread waits on the medium one.
This happened on the Mars Pathfinder lander in 1997, causing repeated system resets. The fix was enabling priority inheritance, where a lock holder temporarily gets the priority of its highest waiter. That needs an owner, so it works for a mutex, and not for a semaphore.
Forgetting to release
An exception between lock and unlock leaves the lock held. Scoped forms solve this: with in Python, RAII in C++, and defer in Go.
{
std::lock_guard<std::mutex> guard(m);
// released automatically here, even on an exception
}
Lost signals
Using a condition variable without a state check can lose a wakeup, if the signal arrives before the wait. Semaphores avoid that, because they count.
Lock convoys
When threads queue on a busy lock, they may run in lockstep, each sleeping and waking in turn, and throughput falls badly.
Holding locks too long
A lock held during I/O makes everyone wait for the network. Keep critical sections short, and do slow work outside them.
Semaphore leaks
If a thread acquires a permit and dies, the permit is gone, and capacity silently falls. No owner exists to clean up. Always release in a finally or a with block.
Robust mutexes
If a mutex’s owner dies, other threads could wait forever. POSIX robust mutexes tell the next locker that the previous owner died, which is only possible because a mutex has an owner.
Language by language
| Language | Mutex | Semaphore | Notes |
|---|---|---|---|
| C (POSIX) | pthread_mutex_t | sem_t | Mutex has several types |
| C++ | std::mutex, std::recursive_mutex | std::counting_semaphore (C++20) | Use lock_guard |
| Java | synchronized, ReentrantLock | java.util.concurrent.Semaphore | synchronized is a monitor |
| Python | Lock, RLock | Semaphore, BoundedSemaphore | Lock doesn’t enforce ownership |
| Go | sync.Mutex | Buffered channel, or x/sync/semaphore | Mutex isn’t reentrant |
| Rust | std::sync::Mutex<T> | Crates, or tokio::sync::Semaphore | Mutex owns its data |
Rust’s design is especially instructive: the mutex wraps the data, so it can’t be reached without locking.
let counter = Mutex::new(0);
*counter.lock().unwrap() += 1;
In Go, a buffered channel works as a semaphore, because sending blocks when it’s full:
sem := make(chan struct{}, 3)
sem <- struct{}{} // acquire
<-sem // release
How to choose
| Need | Use |
|---|---|
| Protect shared data | Mutex |
| Limit concurrent access to N | Semaphore |
| Signal one thread from another | Semaphore or a channel |
| Wait for a condition on shared state | Condition variable |
| Many readers, rare writers | Read-write lock, after measuring |
| Very short kernel critical section | Spinlock |
| A simple counter or flag | Atomic |
| Coordinate across machines | A lease with a fencing token |
A simple test:
- Does one thread own something, and later give it back? That’s a mutex.
- Are you counting available things? That’s a semaphore.
- Is one thread telling another that something happened? That’s a semaphore, a condition variable, or a channel.
The takeaway
A lock is the general idea. A mutex is a lock with one owner, used to protect data. A semaphore is a counter with no owner, used to limit concurrency and signal between threads. A binary semaphore looks like a mutex, and it lacks ownership, which is what makes priority inheritance, deadlock detection and robust recovery possible. In most code, a scoped mutex is the right choice, with a semaphore when the job is counting.