Threads are like busy workers trying to use shared tools or enter a room. But sometimes, only a few workers can use the tool or fit in the room at the same time. Without control, things get messy and confusing.
A Semaphore acts like a smart gatekeeper. It counts how many workers are inside and only lets a fixed number through at once. This keeps everything orderly and safe. In this article, you’ll see how to use semaphores with simple, fun examples.
What is a Semaphore?
A semaphore is like a counter that keeps track of how many threads can enter a special area or use a shared resource at the same time. When a thread wants to enter, it must “acquire” the semaphore. When it finishes, it “releases” the semaphore so others can enter.
Imagine a bathroom with two stalls. Only two people can be inside at once. If both stalls are full, others must wait outside until someone leaves. This is exactly how a semaphore works to keep things organized in threading.
Creating a Semaphore
To create a semaphore in Python, use threading.Semaphore(value)
. The value
sets how many threads can enter at the same time. For example, if you set it to 3, only three threads can hold the semaphore simultaneously.
If you don’t provide a value, it defaults to 1, which means it works like a simple lock — only one thread can enter at a time.
import threading
sem = threading.Semaphore(3) # Only 3 threads can enter at once
This code creates a semaphore named sem
that allows up to three threads to enter its protected section at the same time. Any extra threads will wait until one of the three finishes and releases the semaphore.
Using a Semaphore with with
Using a semaphore inside a with
block is the easiest way to acquire and release it safely. The semaphore is acquired when the block starts and automatically released when the block ends. This keeps your code simple and clean, especially for short tasks.
Here’s a fun example: imagine five guests trying to enter a room that only has two chairs. Only two guests can be inside at once, while others wait for their turn.
import threading
import time
sem = threading.Semaphore(2)
def enter_room(name):
with sem:
print(f"{name} entered the room.")
time.sleep(1)
print(f"{name} left the room.")
for i in range(5):
threading.Thread(target=enter_room, args=(f"Guest-{i}",)).start()
In this example, only two guests can “sit” inside the room at the same time. The rest wait outside until a chair is free. Using with sem:
makes sure the semaphore is properly released, letting others in without hassle.
Manual Acquire and Release
Sometimes you may want more control than a with
block offers. You can manually call sem.acquire()
to take a permit, and sem.release()
to give it back. This is handy if you need to do extra work between acquiring and releasing, or want to manage the timing yourself.
For example, here’s a task where a thread acquires the semaphore, does some work, and then releases it after a delay:
import threading
import time
sem = threading.Semaphore(2) # Only 2 threads can enter at once
def task(name):
print(f"{name} is waiting to enter...")
sem.acquire()
try:
print(f"{name} entered and is doing work...")
time.sleep(1)
print(f"{name} finished work and is leaving.")
finally:
sem.release()
threads = []
for i in range(5):
t = threading.Thread(target=task, args=(f"Worker-{i}",))
threads.append(t)
t.start()
for t in threads:
t.join()
print("All workers are done.")
Using manual acquire and release means you must be careful to always release the semaphore, or other threads may get stuck waiting.
BoundedSemaphore
A BoundedSemaphore
works just like a regular Semaphore
, but with an extra safety check: it throws an error if a thread tries to release more times than it acquired. This helps catch bugs where a release()
call might happen too many times by mistake.
It’s like having a key counter that knows how many keys were handed out — and won’t let you turn in extra fake ones!
import threading
# Only 2 permits allowed
sem = threading.BoundedSemaphore(2)
# Acquire twice (OK)
sem.acquire()
sem.acquire()
# Release twice (OK)
sem.release()
sem.release()
# Releasing again (not OK, will raise ValueError)
try:
sem.release()
except ValueError as e:
print("Oops:", e)
This example highlights how a BoundedSemaphore
keeps things in check. Threads are allowed to acquire and release the semaphore up to its set limit. But if you release it more times than allowed — like letting in more guests than there are seats — Python will raise a ValueError
.
This is helpful in complex programs where keeping track of acquire/release calls can get tricky. BoundedSemaphore
acts like a safety net, making sure you don’t accidentally mess up the balance and cause confusing bugs later on.
Bonus Example: Limited Parking Lot
Imagine a tiny parking lot with only 2 spots. Cars (threads) come in, park for a bit, then leave. But the system won’t allow more cars to “leave” than actually parked — that’s where BoundedSemaphore
keeps things honest.
import threading
import time
# Parking lot with only 2 spots
parking_lot = threading.BoundedSemaphore(2)
def car(name):
print(f"{name} is trying to park...")
parking_lot.acquire()
print(f"{name} parked.")
time.sleep(2)
print(f"{name} is leaving.")
parking_lot.release()
# Start 4 cars
cars = [threading.Thread(target=car, args=(f"Car-{i}",)) for i in range(4)]
for c in cars:
c.start()
for c in cars:
c.join()
# Try to release extra spot (will fail)
try:
parking_lot.release()
except ValueError as e:
print("Parking lot error:", e)
In this example, only two cars can park at the same time because the BoundedSemaphore
was set with a limit of 2. Any other car arriving while both spots are taken will automatically wait for one to free up. This keeps access controlled and orderly.
Once a parked car leaves, it releases the spot, allowing the next waiting car to enter. At the end, we intentionally try to release the semaphore one extra time — more than what was originally acquired. This triggers a ValueError
, which is safely caught and printed. It demonstrates how BoundedSemaphore
helps prevent bugs by enforcing strict limits.
Real-Life Analogy: Parking Lot
Imagine a parking lot with only 3 parking spaces. Just like in real life, only 3 cars can be inside at any time. If more cars arrive, they must wait until someone leaves. In this section, we use a Semaphore
to represent the parking lot and simulate cars as threads. The semaphore’s count is set to 3, which means only 3 threads (cars) can “park” at once.
Each thread prints when it’s trying to park, when it’s successfully parked, and when it leaves. Threads that arrive while the lot is full will automatically wait until space becomes available. This is a simple and fun way to see semaphores in action.
import threading
import time
parking_lot = threading.Semaphore(3)
def park(car_id):
print(f"Car {car_id} trying to park...")
with parking_lot:
print(f"Car {car_id} parked.")
time.sleep(2)
print(f"Car {car_id} left the lot.")
for i in range(6):
threading.Thread(target=park, args=(i,)).start()
This example makes it easy to understand how semaphores manage access — letting a limited number of threads run a section of code at the same time.
Summary Table
Here’s a quick recap of the key semaphore tools in Python threading:
Concept | Description |
---|---|
Semaphore(n) | Allows up to n threads to enter a critical section at the same time. Great for limiting access. |
acquire() | Called by a thread to try entering. If the semaphore is at zero, the thread waits. |
release() | Called when a thread finishes. It frees up a slot so another thread can enter. |
with sem: | A clean and safe shortcut for using acquire() and release() together. |
BoundedSemaphore(n) | Works like Semaphore , but throws an error if release() is called too many times. Keeps logic clean and safe. |
This table helps you remember when and how to use each part — keeping your multithreaded programs neat and under control.
Best Practices
When working with semaphores in Python, it’s good to follow some simple habits to keep your code safe, clean, and easy to understand.
Always try to use with sem:
when possible. This handles both acquiring and releasing for you, so you avoid mistakes like forgetting to release. It also makes your code easier to read.
Use Semaphore(n)
if you want to allow up to n
threads to access a resource at once — like limiting the number of people in a room. If you’re just trying to control access without tracking overuse, this is usually enough.
Choose BoundedSemaphore(n)
when you want extra safety. It makes sure you never accidentally call release()
too many times, which can happen in complex programs with multiple threads.
Lastly, keep your critical sections short. Only put the minimum code needed inside the semaphore block. This helps prevent bottlenecks and makes it easier to see what part of the code is protected.
Conclusion
Semaphores are a simple yet powerful way to manage how many threads can access something at the same time. Whether you’re simulating a parking lot, controlling access to shared tools, or limiting entry to a small room, semaphores help keep everything running smoothly.
They make it easy to say, “Only this many at a time!” — giving you better control over your program’s flow. Whether you use Semaphore
for flexibility or BoundedSemaphore
for safety, these tools fit naturally into any multithreaded Python project.
Give them a try in your own thread-based programs and see how much more organized and fun your code can be!