python processes Semaphore

Python: Managing Process Access with Semaphores

Python’s multiprocessing module allows us to run multiple processes at the same time, making full use of multiple CPU cores. But when these processes need to share access to a limited resource — like printers, database connections, or even toys on a playground — we need a way to control how many processes can use that resource at once.

This is where semaphores come in. A semaphore is a simple counter that keeps track of how many “slots” are available for access. If the counter is above zero, a process can enter and use the resource. If not, the process has to wait.

In this article, we’ll focus on how to use multiprocessing.Semaphore in Python to manage shared access across processes. We’ll walk through clear, hands-on examples that show how semaphores can limit simultaneous access, coordinate tasks, and help you control how processes interact with shared resources.

Creating and Using a Semaphore

To manage how many processes can access something at once, we use a semaphore. In Python, a semaphore is created using multiprocessing.Semaphore(value), where value is the number of available “slots” — or how many processes can access the resource at the same time. The methods .acquire() and .release() are used to take and return a slot. Thankfully, Python allows you to use the with statement, which handles both acquire and release cleanly and automatically.

from multiprocessing import Process, Semaphore
import time

def play(name, swing):

    with swing:
        print(f"{name} is using a swing.")
        time.sleep(1)
        print(f"{name} is done swinging.")


if __name__ == "__main__":

    swing = Semaphore(2)  # Only 2 swings
    kids = ["Brian", "Joe", "Chris", "Lois"]

    for kid in kids:
        Process(target=play, args=(kid, swing)).start()

In this example, the playground has only two swings. So even though four kids show up, only two can swing at a time. The other two wait until a swing is free. The semaphore acts like a gatekeeper, keeping the number of kids on the swings from going over the limit. The with swing: line makes sure that each kid gets their turn and gives up the swing afterward.

Controlling Access to Shared Resources

When several processes need to use a limited resource — like a device, a file, or network access — a semaphore is the right tool to control how many can access it at the same time. You set the semaphore’s count to match how many “slots” or units of the resource you have. Each process waits its turn, using .acquire() automatically with the with statement, and then gives up the slot when done.

from multiprocessing import Process, Semaphore
import time

def use_computer(name, lab):

    with lab:
        print(f"{name} is using a computer.")
        time.sleep(2)
        print(f"{name} has logged out.")

if __name__ == "__main__":

    lab = Semaphore(3)  # 3 computers available

    users = ["George", "Harry", "Hermione", "Ron", "Fred", "Samantha"]

    for user in users:
        Process(target=use_computer, args=(user, lab)).start()

In this example, the library has three computers. When six users walk in, only three of them can sit down and log in right away. The others must wait their turn. The semaphore makes sure no more than three people are on the computers at once, and that each user automatically releases the computer when done. It’s a fair, orderly system — and all handled by Semaphore.

Using .acquire() and .release() Manually

Sometimes, you need more control over when a semaphore is acquired and released — like when the locking section isn’t a neat block of code or when unlocking depends on some condition. In these cases, you can use .acquire() and .release() manually instead of using the with statement.

from multiprocessing import Process, Semaphore
import time

def work(name, gate):
    gate.acquire()

    print(f"{name} clocked in.")
    time.sleep(1)
    print(f"{name} clocked out.")

    gate.release()


if __name__ == "__main__":

    gate = Semaphore(2)
    workers = ["Heather", "Amber", "Mary", "Lucia"]

    for w in workers:
        Process(target=work, args=(w, gate)).start()

In this example, only two workers can clock in at a time. The .acquire() call blocks other workers until someone calls .release() — just like real workers waiting for a space at the time clock. This manual approach gives you the flexibility to place acquire() and release() anywhere in your logic, which is useful when the critical section isn’t a simple block or needs special handling.

BoundedSemaphore to Avoid Over-Release

When managing shared access, it’s possible (by mistake) to release a semaphore more times than it was acquired. This can lead to logic errors and unexpected behavior. Python’s BoundedSemaphore helps prevent that. It works just like a regular Semaphore, but it will raise an error if .release() is called more times than allowed.

from multiprocessing import Process, BoundedSemaphore
import time

def book_slot(name, spa):

    if spa.acquire(timeout=1):

        print(f"{name} booked a spa slot.")
        time.sleep(1)
        print(f"{name} is done relaxing.")
        spa.release()

    else:
        print(f"{name} couldn't find an open spa slot.")

if __name__ == "__main__":

    spa = BoundedSemaphore(2)
    guests = ["Arthur", "Molly", "Albus", "Sirius"]

    for guest in guests:
        Process(target=book_slot, args=(guest, spa)).start()

In this example, only two guests can book the spa at once. If a guest tries to release the semaphore more than once, BoundedSemaphore will catch that mistake. This helps make your code safer when it comes to shared limits, especially in more complex flows where .release() might get called unexpectedly.

Here’s an example that demonstrates how a BoundedSemaphore raises an error when .release() is called more times than .acquire(). This shows what happens if you accidentally over-release:

from multiprocessing import BoundedSemaphore

def demo_overrelease():

    spa = BoundedSemaphore(1)

    print("Acquiring once...")
    spa.acquire()

    print("Releasing once...")
    spa.release()

    print("Trying to release again (should raise an error)...")

    try:
        spa.release()  # This will raise a ValueError

    except ValueError as e:
        print("Error caught:", e)


if __name__ == "__main__":
    demo_overrelease()

This simple script creates a BoundedSemaphore with a limit of 1. After acquiring and releasing it once — which is fine — it tries to release it again without a new acquire. Since the semaphore is already at its max value, the second .release() call causes a ValueError, which we catch and print.

This helps avoid bugs where release calls are accidentally made more times than acquire calls, keeping access control tight and predictable.

Semaphore vs. BoundedSemaphore: Handling Releases

When using multiprocessing.Semaphore, calling .release() more times than .acquire() is allowed. Each .release() call simply increases the internal counter, meaning the semaphore will permit more future .acquire() calls. This behavior can be useful in some cases, but it can also silently allow logic errors—like releasing more than you’ve acquired.

Let’s look at an example where a semaphore is released more than it was acquired:

from multiprocessing import Semaphore

sem = Semaphore(2)

print('Counter: ', sem.get_value()) # Counter: 2

print("Acquiring twice...")

sem.acquire()
sem.acquire()

print("Both acquired.")

print("Releasing three times...")

sem.release()
sem.release()
sem.release()  # No error

print("Done releasing.")

print('Counter: ', sem.get_value()) # Counter: 3

Even though the semaphore started with a value of 2 and was acquired exactly twice, releasing it three times does not cause any problem. The internal counter now sits at 3, even though it started at 2.

This behavior is completely valid for Semaphore, but can lead to subtle bugs when the count goes beyond its intended maximum. If you need to prevent this kind of mistake, Python provides BoundedSemaphore, which is stricter:

from multiprocessing import BoundedSemaphore

sem = BoundedSemaphore(2)

sem.acquire()
sem.acquire()

# Try to release more than acquired
sem.release()
sem.release()
sem.release()  # Raises ValueError

This final .release() call raises a ValueError, making BoundedSemaphore safer when you want to enforce strict limits on resource usage.

In short, if you want flexibility and don’t mind the counter going above its starting value, Semaphore works just fine—it allows multiple .release() calls even if you haven’t called .acquire() as many times. But if you want strict control and want to prevent logic bugs from accidentally increasing the limit, BoundedSemaphore is the safer choice. It raises an error if .release() is called more times than allowed, helping you catch mistakes early.

Real-World Example: Limited Entry to an Exhibit

In this example, we simulate a dinosaur exhibit that can only hold a limited number of visitors at once. Using a semaphore initialized with a count of 3, we control access so that only three visitors can be inside the exhibit simultaneously. Each visitor is represented by a separate process.

from multiprocessing import Process, Semaphore
import time

def visit(name, exhibit):

    with exhibit:
        print(f"{name} entered the dinosaur exhibit.")
        time.sleep(1.5)
        print(f"{name} exited the exhibit.")


if __name__ == "__main__":

    exhibit = Semaphore(3)
    visitors = ["Meghan", "Chris", "Joe", "Glenn", "Hayley", "Peter"]

    for visitor in visitors:
        Process(target=visit, args=(visitor, exhibit)).start()

Each visitor calls the visit function, which acquires the semaphore before entering the exhibit. If all three slots are taken, the process waits until one visitor exits and releases a slot. The with statement simplifies acquiring and releasing the semaphore automatically. This example clearly shows how semaphores manage concurrent access and enforce capacity limits in a multi-process environment.

Conclusion

In this article, we explored how Semaphore and BoundedSemaphore help control access to limited resources across multiple processes. A Semaphore acts as a flexible counter that allows a set number of processes to run critical sections simultaneously. On the other hand, BoundedSemaphore adds a safety check to prevent releasing the semaphore more times than it was acquired.

Both tools are essential for managing concurrent access when resources are limited, ensuring processes wait their turn without conflicts. By using semaphores, you can simulate real-world situations like limiting the number of people in an exhibit or controlling access to shared computers.

Now it’s your turn—try creating your own examples such as controlling elevator capacity or managing print jobs at a shared printer. Semaphores offer a simple yet powerful way to coordinate multiple processes safely and efficiently.

Scroll to Top