Lock
A Lock allows only one thread to enter a critical section at a time.
import threading
lock = threading.Lock()
count = 0
def add():
global count
for _ in range(100_000):
with lock:
count += 1
t1 = threading.Thread(target=add)
t2 = threading.Thread(target=add)
t1.start(); t2.start()
t1.join(); t2.join()
print(count)What is a “critical section”?
A piece of code that must not be interrupted by another thread while it updates shared data.
RLock (re-entrant lock)
An RLock can be acquired multiple times by the same thread.
Use it when:
- a function that holds a lock calls another function that tries to acquire the same lock
import threading
lock = threading.RLock()
def outer():
with lock:
inner()
def inner():
with lock:
print("safe")
t = threading.Thread(target=outer)
t.start(); t.join()Best practices
- Keep
with lock:blocks short. - Always acquire locks in a consistent order if using multiple locks.
Why a lock is needed
Even a line as small as count += 1 is really three steps: read the value, add one, and write it back. If two threads interleave those steps, both may read the same old value and one increment is lost. This is a race condition, and it produces wrong answers that change from run to run, which makes it hard to reproduce. A lock guarantees that only one thread executes the protected steps at a time.
Reading the Lock example
Each thread does 100,000 increments inside with lock:. The with statement acquires the lock on entry and releases it on exit, even if an exception occurs, so you cannot forget to release. Because the increments cannot overlap, the final count is always exactly 200000. Remove the lock and the total can come out lower.
A Lock is not tied to a thread and does not care who holds it. That is the source of a classic trap: if a thread tries to acquire a plain Lock it already holds, it waits for itself forever.
Why RLock exists
An RLock (re-entrant lock) remembers which thread owns it and how many times it has been acquired. The same thread can acquire it again without blocking, and it is released for others only after the matching number of releases. This solves the case in the example, where outer() holds the lock and calls inner(), which needs it too. Try changing RLock to Lock there and the program hangs.
A realistic use: a class that protects its own data
import threading
class SafeCounter:
def __init__(self) -> None:
self._lock = threading.RLock()
self.value = 0
def increment(self) -> None:
with self._lock:
self.value += 1
def increment_twice(self) -> None:
with self._lock: # takes the lock...
self.increment() # ...and increment() takes it again: fine with RLock
self.increment()
counter = SafeCounter()
threads = [threading.Thread(target=counter.increment_twice) for _ in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
print(counter.value) # 20Common mistakes
- Using two different lock objects for the same data. Threads must share one lock to exclude each other.
- Calling
acquire()withoutrelease()on an error path. Preferwith. - Locking too much. Long critical sections make threads wait and remove the benefit of threading.
- Reaching for RLock by default. It can hide design problems; use a plain
Lockunless re-entry is truly needed.
When to use which
Use Lock to protect shared data that several threads change. Use RLock when methods that hold the lock call other methods that also need it.
🧪 Try It Yourself
Exercise 1 – Basic Lock Usage
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 – Lock 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 – Detect Locked State
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.