A mutex says "one at a time." A semaphore says "N at a time." It is a counter with two operations, and it solves a different problem from a mutex: coordinating threads and limiting concurrency, rather than protecting a piece of data.
How it works#
A semaphore starts with a count. Its two operations are:
acquire(): if count > 0, decrement it and continue; if count == 0, block.release(): increment the count, waking one blocked thread if any.
Here is a semaphore initialized with count = 3 and five threads arriving:
| Step | Thread | Call | Count after | Result |
|---|---|---|---|---|
| 1 | Thread 1 | acquire() |
2 | Enters |
| 2 | Thread 2 | acquire() |
1 | Enters |
| 3 | Thread 3 | acquire() |
0 | Enters |
| 4 | Thread 4 | acquire() |
0 | Blocked |
| 5 | Thread 5 | acquire() |
0 | Blocked |
| 6 | Thread 1 | release() |
0 | Thread 4 is woken and enters |
Think of a parking lot with 50 spaces and a counter at the entrance. Each car that enters lowers the counter; each car that leaves raises it. At zero, arriving cars wait in line. The count is how many more threads may enter right now.
(Classic texts call the operations P/wait/down and V/signal/up.)
Binary vs counting semaphores#
Semaphores come in two flavors, depending on the starting count.
- Binary semaphore (count 0 or 1): one thread at a time, so it looks like a mutex.
- Counting semaphore (count N): up to N threads at a time.
If a binary semaphore allows one thread, why not just use a mutex?#
The difference is ownership. A mutex remembers which thread locked it; a semaphore doesn't.
| Primitive | Thread 1 | Thread 2 | Result |
|---|---|---|---|
| Mutex | lock() |
unlock() |
Error: Thread 2 doesn't own it |
| Binary semaphore | acquire() |
release() |
Allowed: semaphores have no owner |
In Python, threading.RLock enforces ownership like this, while a plain threading.Lock does not.
No ownership is what makes semaphores good for signaling: one thread can release a semaphore that a different thread is waiting on.
It is also what makes them dangerous for protecting data. Take a binary semaphore that starts at 1:
| Step | Thread | Call | Count after | What happens |
|---|---|---|---|---|
| 1 | Thread 1 | acquire() |
0 | Inside the critical section, modifying data |
| 2 | Thread 2 | release() |
1 | Allowed: no ownership check |
| 3 | Thread 3 | acquire() |
0 | Enters |
Now Thread 1 and Thread 3 are both in the critical section, which is a race condition.
Thread 2's release doesn't kick Thread 1 out; Thread 1 has no idea it happened. The semaphore simply believes a slot is free. A mutex would have rejected Thread 2's unlock.
| Mutex | Binary semaphore | |
|---|---|---|
| Release by a different thread | Rejected | Allowed |
| Protecting a critical section | Safe | Unsafe if misused |
| Use for | Protecting shared data | Signaling between threads |
Mutex = protect data (ownership matters). Semaphore = signaling and counting (no ownership needed).
Use case: signaling and ordering#
Thread A must finish before Thread B continues. A semaphore that starts at 0 enforces that order:
import threading
data_ready = threading.Semaphore(0) # starts at zero: B must wait
def preparer():
load_from_database()
clean_data()
data_ready.release() # signal: B may proceed
def processor():
data_ready.acquire() # blocks until preparer releases
process_data()
A mutex can't do this, because B would need to wait on something that A unlocks, and A never locked it.
Waiting for N workers to finish:
done = threading.Semaphore(0)
def worker():
do_work()
done.release() # each worker adds one
for _ in range(3):
threading.Thread(target=worker).start()
for _ in range(3):
done.acquire() # main thread waits for all three
print("all workers finished")
(Python also provides dedicated tools for this, such as threading.Event, threading.Barrier or Thread.join().)
Use case: limiting concurrency#
A counting semaphore controls access to N identical resources or caps how many operations run at once.
Connection pool#
Say there are 10 connections and 100 threads want to query, with pool = Semaphore(10):
| Step | Threads | Call | Result |
|---|---|---|---|
| 1 | Threads 1–10 | acquire() |
Each gets a connection; count reaches 0 |
| 2 | Thread 11 | acquire() |
Blocked |
| 3 | Thread 3 finishes | release() |
Thread 11 wakes and gets the free connection |
Concurrency limiter for an API#
A semaphore of 5 keeps at most 5 requests in flight:
limiter = threading.Semaphore(5) # at most 5 requests in flight
def call_api(request):
with limiter: # acquire; released automatically on exit
return http_post(request)
Capping running jobs#
The same idea caps how many jobs run at once:
workers = threading.Semaphore(4)
def submit_job(job):
with workers: # wait if 4 jobs are already running
execute(job)
Semaphore + mutex together#
If the N resources live in a shared structure (like a list of connections), the semaphore alone isn't enough. It decides how many threads may proceed, while a mutex decides whether it's safe to touch the list right now.
import threading
slots = threading.Semaphore(10) # how many threads may hold a connection
list_lock = threading.Lock() # protects the shared list itself
pool = [make_connection() for _ in range(10)]
def get_connection():
slots.acquire() # wait if all 10 are in use
with list_lock: # one thread edits the list at a time
return pool.pop()
def return_connection(conn):
with list_lock:
pool.append(conn)
slots.release() # signal: a connection is free
| Setup | Result |
|---|---|
| Without the semaphore (one mutex guards everything) | Only 1 connection in use at a time; 9 sit idle |
| Without the mutex | 10 threads call pool.pop() at once: race condition on the shared list |
| With both | 10 concurrent connections, no race on the list |
| Scenario | What you need |
|---|---|
| Limit concurrency, no shared data | Semaphore |
| Shared data, no concurrency limit | Mutex |
| Limit concurrency and shared data | Semaphore + mutex |
The producer–consumer problem is the classic example of combining them.
Choosing the right tool#
Pick the primitive by the problem you are solving.
| Problem | Use |
|---|---|
| Protect shared mutable data | Mutex |
| Enforce execution order / "wait until something happens" | Binary semaphore (starting at 0) |
| Limit concurrent access to N things | Counting semaphore |
| Pool of identical resources | Counting semaphore |
| Cap in-flight requests | Counting semaphore |
Key takeaways#
- A semaphore is a counter:
acquiredecrements or blocks at zero,releaseincrements and wakes a waiter. - Counting semaphores cap concurrency at N; binary semaphores starting at 0 are for signaling.
- Semaphores have no owner, so any thread can release. That enables signaling but makes them unsafe as a data lock.
- Use a mutex to protect data, a semaphore to coordinate or limit, and both when limited resources live in a shared structure.