What is a semaphore?
A semaphore controls access to a limited resource.
Example: only 3 workers may access an API at once.
Example
import threading
import time
sem = threading.Semaphore(3)
def worker(i: int) -> None:
with sem:
print("start", i)
time.sleep(0.5)
print("end", i)
threads = [threading.Thread(target=worker, args=(i,)) for i in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()BoundedSemaphore
BoundedSemaphore raises an error if you release too many times.
Use it to catch bugs.
import threading
sem = threading.BoundedSemaphore(1)
sem.acquire()
sem.release()
# sem.release() # would raise ValueErrorA counter with a waiting room
A Semaphore holds a number of permits. acquire() takes one, and if none are left the thread waits. release() gives one back. Unlike a Lock, which lets in exactly one thread, a Semaphore(3) lets in up to three. A lock is simply a semaphore with one permit.
Think of a cafe with three tables. New customers sit down while a table is free, and otherwise queue at the door. When someone leaves, the next person in line is seated.
Reading the example
Ten threads start, but only three can be inside with sem: at any moment. You will see three start lines immediately, then after about half a second three end lines, and the next three start lines follow as permits are released. The tenth thread runs alone in the final wave. The with block acquires on entry and releases on exit, even when an error happens inside.
A practical case: limiting API calls
Many web APIs refuse too many simultaneous requests. A semaphore keeps you inside their limit while still allowing some parallelism:
import threading
import time
sem = threading.Semaphore(2) # at most 2 downloads at once
active = 0
peak = 0
guard = threading.Lock()
def download(i: int) -> None:
global active, peak
with sem:
with guard:
active += 1
peak = max(peak, active)
time.sleep(0.2) # pretend to download
with guard:
active -= 1
threads = [threading.Thread(target=download, args=(i,)) for i in range(6)]
for t in threads:
t.start()
for t in threads:
t.join()
print("most at once:", peak) # 2The counters, protected by a small lock, prove that the peak never exceeds two.
Semaphore versus BoundedSemaphore
A plain Semaphore lets you release more times than you acquired, silently raising the number of permits above the limit. That is nearly always a bug. BoundedSemaphore raises ValueError when this happens, so the mistake is caught immediately. Use it by default.
Common mistakes
- Releasing without acquiring, which grows the permit count. Use
with sem:andBoundedSemaphore. - Confusing a semaphore with a lock. A semaphore with more than one permit does not protect shared data from concurrent changes; you still need a lock for that.
- Forgetting the release on an error path. Using
withavoids this. - Setting the count too high, so it limits nothing.
When to use it
Use a semaphore to cap access to a limited resource: database connections, API rate limits, open files, or CPU-heavy sections that should not run all at once.
🧪 Try It Yourself
Exercise 1 – Create a Semaphore
Tab indents. Press Escape, then Tab, to leave the editor. Control plus Enter runs the code; Control plus Shift plus Enter submits it.
Press Run to see what your code prints.Exercise 2 – Semaphore as Context Manager
Tab indents. Press Escape, then Tab, to leave the editor. Control plus Enter runs the code; Control plus Shift plus Enter submits it.
Press Run to see what your code prints.Exercise 3 – BoundedSemaphore
Tab indents. Press Escape, then Tab, to leave the editor. Control plus Enter runs the code; Control plus Shift plus Enter submits it.
Press Run to see what your code prints.