bigonotes
CS ConceptsOS Fundamentals

Semaphores

How a semaphore's counter limits concurrency to N threads, enables signaling, and differs from a mutex because it has no owner.

5 min readLast updated OS FundamentalsListen · Watch on NotebookLM

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: acquire decrements or blocks at zero, release increments 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.

Found a mistake? Tell me on LinkedIn ↗

Further reading#