Back to Blog
Python

Python Lock vs Semaphore: Choosing the Right Threading Primitive

python lock vs semaphore: Understand the difference between Python's Lock and Semaphore, when to use each, and how they affect thread coordination and resource control.

threadingconcurrencysynchronizationpythonmultithreading
Illustration comparing Python Lock and Semaphore with a gate allowing one thread versus a gate allowing multiple threads.

python lock vs semaphore requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

When you start coordinating threads in Python, the threading module offers two synchronization primitives that look similar at first glance: Lock and Semaphore. Both control access to shared resources, but they solve different problems. The key distinction is that a Lock guarantees exclusive access, while a Semaphore limits access to a fixed number of concurrent users. This article explains the behavior of each, shows practical examples, and gives you clear criteria for choosing between them.

What a Lock Does in Python

A threading.Lock is a mutual exclusion primitive. Only one thread can hold the lock at a time. When a thread calls acquire(), it blocks until the lock is free. Once acquired, other threads that call acquire() will block until the first thread calls release(). This guarantees that a critical section of code is executed by only one thread at a time.

import threading counter = 0 lock = threading.Lock() def increment(): global counter for _ in range(1000): with lock: counter += 1 threads = [threading.Thread(target=increment) for _ in range(10)] for t in threads: t.start() for t in threads: t.join() print(counter) # 10000

The with statement acquires the lock before entering the block and releases it when leaving, even if an exception occurs. Without the lock, the increment operation would race and produce a value less than 10000. The lock ensures that the read-modify-write sequence is atomic from the perspective of other threads.

What a Semaphore Does in Python

A threading.Semaphore maintains an internal counter. Each call to acquire() decrements the counter; if the counter is zero, the thread blocks until another thread calls release(), which increments the counter. A semaphore does not enforce exclusive access. Instead, it allows up to N threads to enter a critical section simultaneously, where N is the initial counter value.

import threading import time semaphore = threading.Semaphore(3) def worker(worker_id): with semaphore: print(f"Worker {worker_id} started") time.sleep(1) print(f"Worker {worker_id} finished") threads = [threading.Thread(target=worker, args=(i,)) for i in range(6)] for t in threads: t.start() for t in threads: t.join()

In this example, the semaphore allows only three workers to run concurrently. The other three wait until a slot becomes available. This is useful for limiting access to a resource pool, such as database connections, network sockets, or a fixed number of worker slots.

Key Differences Between Lock and Semaphore

AspectLockSemaphore
PurposeMutual exclusionResource limiting
Internal stateBinary (locked/unlocked)Counter (0 to initial value)
Max concurrent users1N (initial counter)
Typical use caseProtecting a critical sectionLimiting access to a pool
Release behaviorMust be called by the owning threadCan be called by any thread
Reentrant variantRLock for reentrant lockingNo standard reentrant semaphore

A lock is essentially a semaphore with a counter of 1, but the semantics differ. A lock is tied to the thread that acquired it; only that thread can release it. A semaphore is not tied to a specific thread; any thread can call release(), which makes it useful for signaling between threads. This distinction is important when designing coordination logic.

When to Use a Lock vs a Semaphore

Choose a Lock when you need to protect a shared variable, a file, or any resource that must be accessed by one thread at a time. The critical section should be short and non-blocking to avoid contention. For example, updating a counter, appending to a list, or modifying a dictionary.

Choose a Semaphore when you need to limit the number of threads that can access a resource concurrently. The resource itself can handle multiple readers or workers, but there is a finite capacity. For example, a connection pool with a maximum of 10 connections, or a rate limiter that allows 5 requests per second.

If you are unsure, ask whether the requirement is "only one thread at a time" or "at most N threads at a time." The former points to Lock, the latter to Semaphore. A semaphore can be used as a lock by initializing it with 1, but that loses the ownership guarantee and can lead to subtle bugs if a thread releases a semaphore it did not acquire.

Common Pitfalls and Runtime Behavior

One frequent mistake is forgetting to release a lock, which causes deadlock. Always use with or a try/finally block to ensure release. With semaphores, releasing more times than acquired increments the counter beyond the initial value, allowing more threads than intended. This can happen if a thread calls release() without a corresponding acquire(). The threading.BoundedSemaphore class prevents this by raising ValueError if the counter exceeds the initial value.

Another issue is using a lock to protect a long-running I/O operation. Holding a lock while waiting for network or disk I/O blocks other threads unnecessarily. In such cases, consider using a semaphore to limit concurrency instead of a lock, or restructure the code to avoid holding the lock during the I/O.

Deadlocks can also occur when multiple locks are acquired in different orders. For example, thread A holds lock 1 and waits for lock 2, while thread B holds lock 2 and waits for lock 1. This is a classic deadlock scenario. Use a consistent lock ordering or a single lock to avoid it.

Performance and Contention Considerations

Locks and semaphores add overhead to thread execution. When a thread blocks on acquire(), the operating system may perform a context switch, which is expensive. High contention on a lock can degrade performance because threads spend more time waiting than doing useful work. To mitigate this, keep critical sections short and avoid nested locks.

Semaphores with a high initial value can reduce contention because more threads can proceed without blocking. However, the counter itself is a shared resource, so the atomic operations on it still incur some overhead. For extremely high-throughput scenarios, consider using lock-free data structures or threading.local storage to avoid synchronization altogether.

Python's Global Interpreter Lock (GIL) means that only one thread executes Python bytecode at a time, but locks and semaphores still matter for I/O-bound operations and for coordinating threads that release the GIL during blocking calls. The GIL does not make synchronization unnecessary; it only limits CPU-bound parallelism.

Advanced Usage: Semaphore as a Signaling Mechanism

Semaphores are not just for limiting concurrency; they can also signal events between threads. Because release() can be called from any thread, a semaphore can be used to notify a waiting thread that a resource is available. For example, a producer thread can call release() after adding an item to a queue, and a consumer thread can call acquire() to wait for an item.

import threading import time semaphore = threading.Semaphore(0) item_ready = [] def producer(): time.sleep(1) item_ready.append("data") semaphore.release() def consumer(): semaphore.acquire() print(item_ready.pop()) threading.Thread(target=producer).start() threading.Thread(target=consumer).start()

Here, the semaphore starts at 0, so the consumer blocks until the producer calls release(). This pattern is a lightweight alternative to Condition or Event when you only need to signal once. Note that Semaphore(0) is not the same as a lock; it does not provide mutual exclusion, but it does provide a way to coordinate execution order.

When using semaphores for signaling, be careful about the number of release() calls. Each release() allows one acquire() to proceed. If you call release() more times than the consumer calls acquire(), the counter grows, and future acquire() calls may not block as expected. This is where BoundedSemaphore can help by enforcing a maximum counter value.

In practice, the choice between Lock and Semaphore comes down to whether you need exclusive access or limited concurrent access. For most shared-state protection, a Lock is the right tool. For resource pools and signaling, a Semaphore is more appropriate. Understanding the internal counter and ownership semantics will help you avoid subtle concurrency bugs in your Python applications.

python lock vs semaphore: Practical Usage and Code Examples | RYUSLOG DEV