A mutex is a lock that only one thread can hold at a time, while a semaphore is a counter that limits the number of threads that can access a resource simultaneously.

Let’s see a mutex in action. Imagine you have a shared counter that multiple threads need to increment. Without a mutex, two threads might read the same value, both increment it, and then both write back the same incremented value, effectively losing one of the increments.

import threading
import time

counter = 0
counter_lock = threading.Lock()

def increment_counter():
    global counter
    counter_lock.acquire()  # Acquire the lock
    try:
        current_value = counter
        time.sleep(0.01)  # Simulate some work
        counter = current_value + 1
    finally:
        counter_lock.release() # Release the lock

threads = []
for _ in range(100):
    t = threading.Thread(target=increment_counter)
    threads.append(t)
    t.start()

for t in threads:
    t.join()

print(f"Final counter value: {counter}")

In this example, counter_lock.acquire() ensures that only one thread can enter the try block at a time. The finally block guarantees that counter_lock.release() is always called, even if an error occurs within the try block. This prevents race conditions and ensures the counter is incremented correctly.

Now, let’s look at semaphores. A semaphore is useful when you want to limit access to a pool of resources, like database connections or worker threads.

import threading
import time
import random

MAX_CONCURRENT_TASKS = 3
task_semaphore = threading.Semaphore(MAX_CONCURRENT_TASKS)

def perform_task(task_id):
    task_semaphore.acquire()  # Acquire a permit from the semaphore
    try:
        print(f"Task {task_id} started.")
        time.sleep(random.uniform(0.5, 2.0)) # Simulate task work
        print(f"Task {task_id} finished.")
    finally:
        task_semaphore.release() # Release the permit back to the semaphore

threads = []
for i in range(10):
    t = threading.Thread(target=perform_task, args=(i,))
    threads.append(t)
    t.start()

for t in threads:
    t.join()

print("All tasks completed.")

Here, task_semaphore = threading.Semaphore(MAX_CONCURRENT_TASKS) initializes a semaphore that allows up to MAX_CONCURRENT_TASKS (which is 3 in this case) threads to acquire a permit concurrently. task_semaphore.acquire() blocks if all permits are already taken, and task_semaphore.release() makes a permit available again. This ensures that no more than 3 tasks run simultaneously.

The core problem these primitives solve is managing shared mutable state in concurrent environments. Without them, the unpredictable timing of thread execution can lead to corrupted data or unexpected program behavior. Mutexes provide exclusive access, guaranteeing that a critical section of code is executed by only one thread at a time. Semaphores, on the other hand, offer a more flexible way to control the degree of concurrency, allowing a defined number of threads to access a resource pool.

The distinction between a mutex and a semaphore can be subtle, but it boils down to their intended use and behavior. A mutex is essentially a binary semaphore (a semaphore initialized with a value of 1) that is designed for mutual exclusion—only one thread can "own" the lock at any given time. Semaphores, with initial values greater than 1, are used for signaling and controlling access to a pool of resources. A thread that acquires a semaphore permit doesn’t "own" it in the same way a thread "owns" a mutex; it simply consumes a permit from the available pool.

A common, often overlooked, pitfall with semaphores is when the release() operation is not performed. This can happen if an exception occurs within the try block and the finally block is bypassed or improperly handled. When this happens, a permit is permanently lost from the semaphore’s pool, effectively reducing the maximum number of concurrent tasks that can ever be run. Over time, this can lead to a deadlock situation where no new tasks can start because all permits are stuck in a state of being acquired but never released.

The next logical step is understanding how to use these primitives in more complex scenarios, such as coordinating work between multiple threads or implementing producer-consumer patterns.

Want structured learning?

Take the full Argo-workflows course →