Overview
In multithreading, when multiple threads try to modify the same variable or resource (files, databases, global variables, etc.) at the same time, data can lose consistency. This is called a “Race Condition.” To prevent this, we use threading.Lock. This article explains how to implement “Exclusive Control,” which makes other threads wait while one thread is processing.
Specifications (Input/Output)
- Input: A function that operates on shared resources (variables, etc.).
- Output: Correctly calculated results without contention.
- Features:
- Protection of critical sections (parts that should not run simultaneously).
- Synchronization between threads.
Syntax and Meaning
| Syntax | Meaning / Role |
lock = threading.Lock() | Creates a new lock object. Initial state is “unlocked.” |
with lock: | Uses a context manager to acquire the lock. It is automatically released (Release) when exiting the block. This is the recommended style. |
lock.acquire() | Manually acquires the lock. If another thread is currently locking, it waits until released. |
lock.release() | Manually releases the lock. The thread that acquired it must call this. |
Basic Usage
The basic pattern is to create a lock object and wrap the process you want to protect with a with statement.
import threading
# 1. Create a lock object
lock = threading.Lock()
shared_data = 0
def safe_update():
global shared_data
# 2. Start exclusive control
# Only one thread can execute inside this block at any time
with lock:
current = shared_data
current += 1
shared_data = current
# Create and start threads after this...
Full Code
This code simulates the difference between “unsafe (no lock)” and “safe (with lock)” states. Using a bank account balance update as an example, it demonstrates how calculations go wrong without exclusive control and how Lock fixes it.
import threading
import time
# Shared resource (assuming a bank account balance)
account_balance = 0
# Create a lock object for exclusive control
balance_lock = threading.Lock()
def update_balance_unsafe(amount, loops):
"""
[UNSAFE] Function to update balance without a lock.
Contention occurs easily because of the delay between reading and writing.
"""
global account_balance
for _ in range(loops):
# Read the current value
current = account_balance
# Simulating time lag like IO wait
time.sleep(0.001)
# Calculate and write back (another thread might have overwritten the value here)
account_balance = current + amount
def update_balance_safe(amount, loops):
"""
[SAFE] Function to update balance using a lock.
"""
global account_balance
for _ in range(loops):
# --- Start Critical Section ---
with balance_lock:
current = account_balance
time.sleep(0.001)
account_balance = current + amount
# --- End Critical Section ---
def run_simulation(safe_mode=True):
global account_balance
account_balance = 0 # Reset
threads = []
loops = 50
thread_count = 5
print(f"--- Starting Simulation (SafeMode: {safe_mode}) ---")
target_func = update_balance_safe if safe_mode else update_balance_unsafe
# Perform updates simultaneously in multiple threads
for _ in range(thread_count):
t = threading.Thread(target=target_func, args=(1, loops))
threads.append(t)
t.start()
for t in threads:
t.join()
# Expected value: Number of threads * Number of loops
expected = thread_count * loops
print(f"Final Balance: {account_balance} (Expected: {expected})")
if account_balance == expected:
print(">> Result: Success")
else:
print(">> Result: Data inconsistency occurred!")
print("-" * 30)
if __name__ == "__main__":
# 1. Run without a lock (likely to fail)
run_simulation(safe_mode=False)
# 2. Run with a lock (always succeeds)
run_simulation(safe_mode=True)
Customization Points
Benefits of using the with statement
You can manually write lock.acquire() and lock.release(), but it is not recommended. By using with lock:, the lock is automatically released even if an error (exception) occurs within the block. This prevents “Deadlocks,” where a thread stops due to an error while holding a lock, causing other threads to wait forever.
Adjusting Granularity
A golden rule is to keep the scope of the lock (the content inside with) to a minimum. If you include unnecessary calculations or heavy communication inside the lock, the benefits of parallel processing are lost, and the program slows down as it becomes essentially single-threaded.
Important Notes
Deadlock
If there are multiple locks (Lock A, Lock B), a deadlock can occur if Thread 1 holds A and waits for B, while Thread 2 holds B and waits for A. You need a design that unifies the order of lock acquisition.
Recursive Locking
A normal Lock will freeze even its own thread if acquire is called twice. If the same thread needs to acquire a lock multiple times (e.g., in recursive calls), use threading.RLock.
Performance
Acquiring and releasing locks has overhead. If you lock too frequently, it may actually decrease the processing speed.
Advanced Usage
Example using RLock (Reentrant Lock). The same thread can acquire the lock in a nested way.
import threading
# Use RLock
rlock = threading.RLock()
def recursive_worker(count):
with rlock:
print(f"Lock acquired: Depth {count}")
if count > 0:
# A normal Lock would freeze here, but RLock allows it
recursive_worker(count - 1)
if __name__ == "__main__":
recursive_worker(3)
Summary
Always use threading.Lock for exclusive control when handling shared data in multiple threads. The basic approach is using with lock:. By making the “read, calculate, and write” flow an atomic (indivisible) operation, you can protect data integrity.
