SimPy Deadlock Explained: Why Your Simulation Hangs
Your simulation starts. Then stops. No error. No output. Just... nothing.
Welcome to deadlock.
What Is Deadlock?
Deadlock occurs when processes wait for each other in a circle. A waits for B. B waits for A. Nobody moves. Ever.
In SimPy, this typically happens with resources:
import simpy
def process_a(env, resource_1, resource_2):
with resource_1.request() as req1:
yield req1
print(f"{env.now}: A has resource 1")
yield env.timeout(1)
with resource_2.request() as req2:
yield req2 # STUCK - B has resource 2
print(f"{env.now}: A has both resources")
def process_b(env, resource_1, resource_2):
with resource_2.request() as req2:
yield req2
print(f"{env.now}: B has resource 2")
yield env.timeout(1)
with resource_1.request() as req1:
yield req1 # STUCK - A has resource 1
print(f"{env.now}: B has both resources")
env = simpy.Environment()
r1 = simpy.Resource(env, capacity=1)
r2 = simpy.Resource(env, capacity=1)
env.process(process_a(env, r1, r2))
env.process(process_b(env, r1, r2))
env.run() # Hangs forever
The Four Conditions for Deadlock
Deadlock requires all four conditions:
- Mutual exclusion - Resources can't be shared
- Hold and wait - Hold one resource, wait for another
- No preemption - Can't forcibly take resources
- Circular wait - A waits for B, B waits for A
Break any one, and deadlock becomes impossible.
Solution 1: Consistent Ordering
Always acquire resources in the same order:
def process_a(env, resource_1, resource_2):
# Always get resource 1 first, then resource 2
with resource_1.request() as req1:
yield req1
with resource_2.request() as req2:
yield req2
yield env.timeout(5)
def process_b(env, resource_1, resource_2):
# Same order: resource 1 first, then resource 2
with resource_1.request() as req1:
yield req1
with resource_2.request() as req2:
yield req2
yield env.timeout(5)
Simple. Effective. No circular wait possible.
Solution 2: Timeout on Requests
Don't wait forever:
def process_with_timeout(env, resource_1, resource_2):
with resource_1.request() as req1:
yield req1
req2 = resource_2.request()
result = yield req2 | env.timeout(10)
if req2 in result:
# Got the resource
yield env.timeout(5)
resource_2.release(req2)
else:
# Timed out - release first resource and retry later
print(f"{env.now}: Couldn't get resource 2, backing off")
req2.cancel()
yield env.timeout(1) # Back off
# Retry logic here
Solution 3: Try-All-Or-None
Request all resources at once:
def all_or_none(env, resources):
"""Request all resources or none."""
requests = [r.request() for r in resources]
# Wait for all
results = yield simpy.AllOf(env, requests)
try:
yield env.timeout(5) # Do work
finally:
for req, resource in zip(requests, resources):
resource.release(req)
Solution 4: Resource Manager
Centralise resource allocation:
class ResourceManager:
def __init__(self, env, resources):
self.env = env
self.resources = resources
self.lock = simpy.Resource(env, capacity=1)
def acquire_all(self, resource_names):
"""Acquire multiple resources atomically."""
with self.lock.request() as lock_req:
yield lock_req
requests = []
for name in resource_names:
req = self.resources[name].request()
yield req
requests.append((name, req))
return requests
def release_all(self, requests):
"""Release multiple resources."""
for name, req in requests:
self.resources[name].release(req)
Detecting Deadlock
Add monitoring to catch hangs:
def deadlock_detector(env, processes, timeout=100):
"""Detect if simulation makes no progress."""
last_time = env.now
while True:
yield env.timeout(timeout)
if env.now == last_time:
print(f"WARNING: No progress in {timeout} time units")
print("Possible deadlock detected")
# Log resource states
for name, resource in resources.items():
print(f" {name}: {resource.count}/{resource.capacity} busy, "
f"{len(resource.queue)} waiting")
last_time = env.now
Common Deadlock Patterns
The Dining Philosophers
Classic deadlock scenario:
def philosopher(env, left_fork, right_fork, name):
while True:
# Think
yield env.timeout(random.expovariate(1))
# Try to eat (potential deadlock!)
with left_fork.request() as left:
yield left
# All philosophers grab left fork...
with right_fork.request() as right:
yield right # ...then wait for right fork
yield env.timeout(random.expovariate(1))
Fix: Always grab the lower-numbered fork first.
The Producer-Consumer
def producer(env, buffer, producer_lock):
with producer_lock.request() as req:
yield req
# Wait for buffer space - but consumer needs producer_lock!
yield buffer.put(item)
def consumer(env, buffer, producer_lock):
with producer_lock.request() as req:
yield req
# Deadlock if buffer empty and producer waiting
item = yield buffer.get()
Fix: Don't hold locks while waiting on buffers.
Debugging Deadlock
When your simulation hangs:
import signal
import sys
def timeout_handler(signum, frame):
print("Simulation appears deadlocked!")
print(f"Current time: {env.now}")
# Print all pending events
for event in env._queue[:10]:
print(f" Pending: {event}")
sys.exit(1)
# Set timeout
signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(60) # 60 second timeout
env.run()
signal.alarm(0) # Cancel alarm
Prevention Checklist
Before running: 1. ✓ All processes acquire resources in consistent order 2. ✓ No nested resource requests without timeout 3. ✓ Resources released promptly after use 4. ✓ No circular dependencies in process design
Summary
Deadlock is silent and deadly. Your simulation just stops.
Prevention: - Consistent ordering - Timeouts on requests - All-or-none acquisition - Centralised resource management
Deadlock-free code is careful code.
Next Steps
Strengthen Your Python Skills
If you're finding Python tricky, get up to speed quickly with the 10-Day Python Bootcamp. It's designed to give you the confidence and skills to write clean, effective code.
Start the Python Bootcamp