Back to Blog
Python

Python Lock vs RLock: Choosing the Right Threading Primitive

Understand the difference between threading.Lock and threading.RLock in Python, including reentrancy, deadlock risks, and when each is appropriate.

threadingconcurrencylocksRLockdeadlock
Illustration comparing Python's Lock and RLock threading primitives, showing reentrancy and nested acquisition.

Python's threading module offers two locking primitives with different reentrancy behavior. A threading.Lock is a non-reentrant mutex: if a thread tries to acquire the same lock again before releasing it, that thread blocks forever. A threading.RLock—a re-entrant lock—tracks the owning thread and allows that thread to acquire the lock multiple times, as long as every acquisition is matched by a release. For most threading code, the difference comes down to whether nested or recursive code paths may try to acquire the same lock more than once.

The Core Difference Between Lock and RLock

A threading.Lock is a non-reentrant mutex. Once a thread acquires it, any later acquire() call—including one from the same thread—blocks until the lock is released. This often happens when a function that holds a lock calls another function that tries to acquire the same lock.

A threading.RLock (re-entrant lock) tracks both the owning thread and a recursion count. The same thread can acquire it multiple times, but each acquire() must be matched by a release(). Other threads cannot acquire it until the owning thread has released it once for each acquisition.

import threading lock = threading.Lock() rlock = threading.RLock()

How Lock Behaves in Nested Acquisition

Consider a class that uses a lock to protect its internal state. If one public method acquires the lock and then calls another method that also acquires the same lock, the second acquisition will deadlock with a plain Lock.

class Counter: def __init__(self): self.value = 0 self.lock = threading.Lock() def increment(self): with self.lock: self._update() def _update(self): with self.lock: # Deadlock! The lock is already held by this thread self.value += 1

Running this code will hang because the same thread tries to acquire a non-reentrant lock it already owns. The thread never reaches the release in the outer with block, so the program stalls.

How RLock Allows Reentrant Acquisition

Replacing the Lock with an RLock fixes the nested acquisition problem. The RLock records the owner thread and allows that thread to acquire it multiple times. Each acquire() increments an internal counter, and each release() decrements it. The lock is only released for other threads when the counter returns to zero.

class SafeCounter: def __init__(self): self.value = 0 self.lock = threading.RLock() def increment(self): with self.lock: self._update() def _update(self): with self.lock: # Safe: same thread can re-acquire self.value += 1

This pattern is common when you have a public API that delegates to internal helpers, and you want to keep the lock granularity consistent without exposing separate lock-acquisition logic.

Deadlock Risks and When RLock Helps

Using a plain Lock when reentrancy is needed can cause a deadlock that is difficult to debug because the stack trace shows the thread blocked on an acquire call that appears logically correct. The root cause is that the lock is not reentrant, not that two different threads are competing for the same resource.

RLock eliminates this class of deadlock, but it does not solve all deadlock problems. If two threads acquire multiple locks in different orders, an RLock will not prevent a classic deadlock. For example:

# Thread A with lock_a: with lock_b: ... # Thread B with lock_b: with lock_a: ...

Here, RLock does not help because the locks are different and each thread is waiting for the other. Reentrancy only addresses the case where the same thread needs to acquire the same lock again.

Performance and Overhead Considerations

An RLock has to track the owning thread and a recursion count, so it performs a little more bookkeeping than a plain Lock. In most applications this difference is negligible, but in highly contended, performance-critical code it can matter. If your code never needs reentrant acquisition, a plain Lock is the simpler and lighter choice.

More important than micro-benchmarks is the cost of a deadlock. A deadlock that hangs a production service is far more expensive than a few extra instructions per lock acquisition. Choose the primitive that matches your actual usage pattern, not one optimized for an overhead difference you likely will not notice.

Choosing Between Lock and RLock

Use a plain Lock when you are certain that a thread will never attempt to acquire the same lock more than once. This is common for simple critical sections that protect a single operation, such as updating a shared counter or appending to a list.

Use an RLock when your code has nested functions that all acquire the same lock, or when you are building a public API that calls internal methods that also lock. RLock is also useful when you want to allow a method to call itself recursively while holding the lock.

A practical rule: if you ever find yourself writing with self.lock: inside another with self.lock: block, switch to RLock. If you are not sure, start with Lock and only change to RLock if you hit a deadlock caused by reentrant acquisition.

Common Mistakes and Edge Cases

One common mistake is using an RLock when a plain Lock would be simpler, and then assuming it provides protection against all concurrency issues. RLock only protects against reentrant deadlocks, not against race conditions or inconsistent lock ordering.

Another edge case is mixing acquire() and release() calls with with statements. The with statement is recommended because it releases the lock even if an exception is raised. If you call acquire() and release() manually, every acquire() must have a matching release(). For an RLock, the same thread must call release() as many times as it called acquire(); otherwise the lock remains held indefinitely.

Finally, remember that an RLock has an owner. If a different thread tries to release it, a RuntimeError is raised. This is intentional and prevents one thread from accidentally releasing another thread's lock.

When you need to share a lock across threads, document whether it is reentrant. This helps future maintainers understand why a nested with block is safe or why they must avoid calling certain methods while holding the lock.

Python Lock vs RLock: Practical Usage and Code Examples | RYUSLOG DEV