The Story That Explains Synchronization
This is a race condition — two workers reading and writing the same shared data with no coordination. In a real Google Doc, live sync prevents this. In a Python program with multiple threads sharing a variable, nothing prevents it by default. Two threads doing
counter += 1 at the same time can produce
counter = 1 instead of counter = 2 — and there is no error
message, just silently wrong data.A Lock is Python's turn-taking token. Only the thread holding the lock may touch the shared data. Everyone else waits. This is thread synchronization — the art of letting threads share state without stepping on each other.
This tutorial covers the two most-used tools for safe threading: daemon threads
(background helpers that die when the program exits) and the two lock types —
Lock and RLock — that stop threads from corrupting shared data.
The moment two or more threads read and write the same piece of memory, you have a synchronization problem. Locks solve it by serialising access — turning many concurrent writes into a single-file line where each thread waits its turn.
Daemon Threads — Background Helpers That Die With Main
Every thread you create is either a non-daemon thread (the default) or a daemon thread. The difference is only visible at program shutdown:
| Python waits for it to finish before exiting |
| Use for tasks that MUST complete |
| Example: saving user data, flushing logs, finishing an HTTP response |
t.daemon = False (or unset) |
| Killed abruptly when main exits |
| Use for background monitors, heartbeats, watchers |
| Example: log flusher, metrics ticker, garbage collector |
t.daemon = True (BEFORE start!) |
Non-daemon threads keep the process alive. Daemon threads do not — Python kills them the instant the last non-daemon thread finishes.
Creating a Daemon Thread
import threading, time
def background_monitor():
# Infinite loop — would never let a non-daemon program exit
while True:
time.sleep(1)
print("[monitor] heartbeat")
# Way 1: set in constructor (recommended)
mon = threading.Thread(target=background_monitor, daemon=True)
# Way 2: set as attribute BEFORE start()
# mon = threading.Thread(target=background_monitor)
# mon.daemon = True
mon.start()
time.sleep(3.5)
print("main exiting — daemon monitor dies automatically")
You cannot change t.daemon after the thread has started. Set it in the
constructor (Thread(..., daemon=True)) or immediately after
Thread(...), but always before t.start(). Attempting to
change it later raises RuntimeError.
Classic Textbook Example — Daemon Killed Before Finishing
Here's the shortest, clearest demo of daemon behaviour. We spawn two threads — one non-daemon (sleeps 3 seconds) and one daemon (sleeps 4 seconds). Watch what happens: the daemon never gets to print its exit message because the program terminates the moment the non-daemon finishes.
from threading import Thread
import time
def non_d():
print("Non daemon enters")
time.sleep(3) # will finish at t = 3s
print("nonDaemon exits")
def d():
print("daemon enters")
time.sleep(4) # would finish at t = 4s… but gets killed at 3s
print("Daemon exits") # NEVER prints
nd = Thread(target=non_d)
nd.start()
d1 = Thread(target=d)
d1.setDaemon(True) # old-style setter; equivalent to d1.daemon = True
d1.start()
The daemon's time.sleep(4) is cut short at the 3-second mark, the instant the non-daemon finishes. Its "Daemon exits" print never runs.
d1.setDaemon(True) is the old-style setter and still works, but
in modern Python (3.10+) it's deprecated in favour of the property syntax
d1.daemon = True or passing daemon=True directly to the
constructor. All three do exactly the same thing — use the property style in new code.
Try This Variation — Reverse the Times
Swap the sleep durations: make the non-daemon sleep 4 seconds and the daemon sleep 3. Now the daemon does get to print its exit message, because the non-daemon is still keeping the program alive when the daemon naturally finishes.
from threading import Thread
import time
def non_d():
print("Non daemon enters")
time.sleep(4) # now the LONGER one
print("nonDaemon exits")
def d():
print("daemon enters")
time.sleep(3) # now the SHORTER one
print("Daemon exits") # now it DOES print
nd = Thread(target=non_d); nd.start()
d1 = Thread(target=d); d1.daemon = True; d1.start()
Python keeps the process alive as long as any non-daemon thread is still running. The instant the last non-daemon finishes, Python signals all daemon threads to die immediately — no matter what they were doing.
The Danger of Daemon Threads — No Clean Shutdown
import threading, time
def important_writer():
try:
with open("data.txt", "w") as f:
for i in range(100):
f.write(f"line {i}\n")
time.sleep(0.05) # pretend slow disk
print("wrote all 100 lines")
except Exception as e:
print(f"interrupted: {e}")
# If we mark this as daemon, main exits mid-write → file is truncated!
t = threading.Thread(target=important_writer, daemon=True)
t.start()
time.sleep(1) # only ~20 lines written by now
print("main done — daemon writer gets KILLED mid-write")
# Result: data.txt contains partial data, no "wrote all 100 lines" message
Daemon threads get SIGKILL-style treatment — no finally blocks, no
with block cleanup, no buffer flushes. Never use daemon for anything
writing files, holding database transactions, or acquiring locks that other
processes depend on. Use daemon only for pure-in-memory background loops.
When to Use Which
join().
threading.Event flag to signal
shutdown, then join(). Lets the thread finish its work cleanly.
The Race Condition Problem
Before we introduce locks, let's see the exact problem they solve. A race condition happens when two or more threads read-modify-write the same value, and the interleaving of their operations produces a wrong result.
The interpreter can switch between threads at any point — including between the read and the write. Both threads see the old value and produce the same new value, losing one update.
Race Condition Demo — Broken Code
import threading
counter = 0
def increment():
global counter
for _ in range(100_000):
counter += 1 # NOT atomic!
threads = [threading.Thread(target=increment) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()
print(f"expected: {10 * 100_000}")
print(f"got: {counter}")
print(f"lost: {10 * 100_000 - counter} increments")
No exception. No warning. No error log. Just wrong numbers. This is the most dangerous kind of bug — it doesn't crash, it just quietly produces incorrect results. And because thread scheduling is non-deterministic, it may look "fine" in testing and fail only under production load.
Lock — The Turn-Taking Token
threading.Lock is Python's most basic synchronization primitive. It has just
two states — unlocked and locked — and two operations
— acquire and release. The rule is simple: only one
thread can hold the lock at a time. Everyone else who tries to acquire it is put to sleep
until the current holder releases.
The lock does not protect the data directly — it protects the critical section of code that touches the data. All threads must agree to acquire it before accessing.
Fixing the Race Condition
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(100_000):
lock.acquire() # wait if someone else holds it
counter += 1 # now safe — we're alone
lock.release() # let the next thread in
threads = [threading.Thread(target=increment) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()
print(f"expected: {10 * 100_000}")
print(f"got: {counter}") # exactly 1_000_000
The Right Way — with Statement
Calling acquire() and release() manually is fragile — if your
code raises an exception between them, the lock stays locked forever and every waiting
thread deadlocks. Use the with statement instead. It calls acquire()
on entry and guarantees release() on exit, even on exceptions.
lock.acquire() |
risky_operation() ← may raise |
lock.release() ← never runs on exception! |
| Lock leaks → all other threads deadlock |
with lock: |
risky_operation() |
| Lock released even if exception raised |
| Never forget to release — it's automatic |
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(100_000):
with lock: # acquires on enter, releases on exit
counter += 1 # exception-safe
threads = [threading.Thread(target=increment) for _ in range(10)]
for t in threads: t.start()
for t in threads: t.join()
print(f"counter = {counter}")
Lock Methods Reference
| Method | What It Does | Common Use |
|---|---|---|
lock.acquire() | Wait forever until the lock is free, then take it | Most common |
lock.acquire(blocking=False) | Try to take the lock; return False if unavailable | Non-blocking check |
lock.acquire(timeout=2) | Wait up to 2 seconds; return False on timeout | Avoid indefinite hang |
lock.release() | Release the lock (RuntimeError if not held) | Prefer with block |
lock.locked() | True if any thread holds the lock | Diagnostics only |
with lock: | Automatic acquire + release with exception safety | Always prefer this |
import threading, time
lock = threading.Lock()
def try_get_lock(worker_id):
if lock.acquire(blocking=False):
try:
print(f"worker {worker_id} got the lock, working…")
time.sleep(1)
finally:
lock.release()
else:
print(f"worker {worker_id} could not get lock — skipping")
threads = [threading.Thread(target=try_get_lock, args=(i,)) for i in range(4)]
for t in threads: t.start()
for t in threads: t.join()
Deadlock — When Locks Attack
A deadlock is when two or more threads are stuck waiting for locks the others hold. Nothing crashes, nothing prints — the program just freezes forever.
Each thread holds one lock and needs the other. Neither can proceed. The OS sees them as "waiting", not "crashed" — so your program just hangs silently.
import threading, time
lock1 = threading.Lock()
lock2 = threading.Lock()
def thread_a():
with lock1:
print("A got lock1")
time.sleep(0.1) # give B time to grab lock2
with lock2: # will wait FOREVER
print("A got lock2 (never prints)")
def thread_b():
with lock2:
print("B got lock2")
time.sleep(0.1)
with lock1: # will wait FOREVER
print("B got lock1 (never prints)")
threading.Thread(target=thread_a).start()
threading.Thread(target=thread_b).start()
# Program hangs — Ctrl-C to exit
To prevent deadlocks, always acquire multiple locks in the same global order
across every thread. If every thread grabs lock1 before lock2
(never the reverse), the cycle in the diagram above becomes impossible. Or use
lock.acquire(timeout=N) so a stuck thread eventually gives up and can
retry with backoff.
RLock — The Reentrant Lock
Sometimes a thread needs to acquire the same lock more than once — usually because
one locked function calls another locked function. With a plain Lock, this
deadlocks the thread against itself. RLock (reentrant lock) solves it by
remembering which thread owns it and how many times it's been acquired.
A plain Lock has no idea who is holding it — even the same thread re-acquiring is a "different" caller and blocks. An RLock tracks the owner and a counter, so the same thread can safely acquire it multiple times.
The Problem Plain Lock Creates
import threading
lock = threading.Lock()
def deposit(amount):
with lock: # acquire #1
print(f"deposit {amount}")
audit(f"deposit of {amount}") # calls audit — which also locks!
def audit(msg):
with lock: # acquire #2 — SELF-DEADLOCK
print(f"[audit] {msg}")
t = threading.Thread(target=deposit, args=(100,))
t.start()
t.join(timeout=2)
print(f"alive after 2s? {t.is_alive()}") # True — deadlocked
The RLock Fix
import threading
rlock = threading.RLock() # reentrant version
def deposit(amount):
with rlock: # count = 1
print(f"deposit {amount}")
audit(f"deposit of {amount}")
def audit(msg):
with rlock: # count = 2 — same thread, OK
print(f"[audit] {msg}")
t = threading.Thread(target=deposit, args=(100,))
t.start()
t.join()
print("finished cleanly")
An RLock internally stores two things: the owner thread's identity
and a recursion counter. Each acquire() from the same thread
bumps the counter. Each release() decrements it. Only when the counter
hits zero is the lock actually freed and other threads can grab it.
Problem #1 — Nested Function Calls (Where RLock Shines)
Here's a textbook demonstration of the exact scenario RLock was designed for. A single
thread runs the function all(), which acquires the lock and then calls
first() and second() — both of which also acquire the
same lock. With a plain Lock this self-deadlocks immediately. With
RLock, the same thread is allowed to re-enter.
import threading
lock = threading.RLock()
def first(n):
print("In first", n)
lock.acquire() # count: 1 → 2 (same thread, allowed)
a = 12 + n
lock.release() # count: 2 → 1
print(a)
def second(n):
lock.acquire() # count: 1 → 2 (same thread, allowed)
b = 12 + n
lock.release() # count: 2 → 1
print(b)
def all():
print("start")
lock.acquire() # count: 0 → 1 (thread owns it now)
first(2) # re-acquires + releases inside
second(3) # re-acquires + releases inside
lock.release() # count: 1 → 0 (truly freed)
th1 = threading.Thread(target=all)
th1.start()
Step-by-Step — The Recursion Counter in Action
all() starts → prints "start". Counter = 0.
lock.acquire() inside all → thread becomes owner. Counter 0 → 1.
first(2) called → prints "In first 2" → lock.acquire(). Same owner, so allowed. Counter 1 → 2.
a = 12 + 2 = 14 → lock.release(). Counter 2 → 1 (NOT freed — still owned!). Print 14.
second(3) → lock.acquire(). Counter 1 → 2. Compute b = 15. lock.release(). Counter 2 → 1. Print 15.
all(): lock.release(). Counter 1 → 0. Lock is now genuinely freed — any other thread can acquire it.
| all() acquires lock ✓ |
| first() prints "In first 2" |
| first() calls lock.acquire() → BLOCKS |
| Waiting for itself → deadlock ∞ |
| Output stops at "In first 2" |
| all() acquires lock (count=1) |
| first() re-acquires (count=2), releases (count=1) |
| second() re-acquires (count=2), releases (count=1) |
| all() releases (count=0) |
| All output prints, thread exits |
Every acquire() from the owning thread bumps the counter. Every
release() decrements it. The lock is only really freed when the count
hits zero. This is exactly why nested function calls that each grab the same lock
work seamlessly under RLock.
Problem #2 — Cross-Thread Release (Where RLock Refuses)
Now here's a case that looks like RLock should save us — but it can't, because RLock deliberately enforces ownership. One thread acquires the lock, and a different thread tries to release it. Watch what happens.
import threading
import time
import datetime
lock = threading.RLock()
t1 = datetime.datetime.now()
def second(n):
lock.acquire() # acquires but NEVER releases!
print(n)
def third():
time.sleep(5)
lock.release() # releases a lock this thread never acquired!
print("Thread3")
th1 = threading.Thread(target=second, args=("Thread1",))
th1.start()
th2 = threading.Thread(target=second, args=("Thread2",))
th2.start()
th3 = threading.Thread(target=third)
th3.start()
th1.join()
th2.join()
th3.join()
t2 = datetime.datetime.now()
print("Total time", t2 - t1)
What Actually Happens — Trace by Trace
second("Thread1") → acquires lock → prints "Thread1" → function returns without releasing. Lock still owned by th1.
second("Thread2") → calls lock.acquire() → BLOCKS forever (lock owned by th1, different thread).
third() → begins sleeping for 5 seconds.
lock.release() — but th3 never acquired the lock. RuntimeError: cannot release un-acquired lock is raised.
th2.join() in main also blocks forever. Program hangs — "Total time" never prints.
This example packs both of RLock's strongest rules into one broken program:
(1) Never split acquire and release across different functions or threads
— acquire and release must always live in the same scope, ideally via a with
block. (2) Only the owning thread can call release() — RLock tracks the
owner and raises RuntimeError if any other thread tries.
The Fix — Each Function Manages Its Own Lock Lifecycle
import threading, time, datetime
lock = threading.RLock()
t1 = datetime.datetime.now()
def second(n):
with lock: # acquire + guaranteed release
print(n)
time.sleep(1) # pretend we're doing critical work
def third():
time.sleep(2)
with lock: # properly acquire before touching shared state
print("Thread3")
threads = [
threading.Thread(target=second, args=("Thread1",)),
threading.Thread(target=second, args=("Thread2",)),
threading.Thread(target=third),
]
for t in threads: t.start()
for t in threads: t.join()
print("Total time", datetime.datetime.now() - t1)
Problem #1 tells us WHY RLock exists — nested acquires from the same
thread. Problem #2 tells us WHAT RLock cannot do — it cannot fix code
that splits acquire/release across threads or leaks locks by not releasing at all. The
cure for both is the same discipline: use with lock: so acquire and
release always live in the same code block and always run in the same thread.
Lock vs RLock — Side by Side
| Property | Lock | RLock |
|---|---|---|
| Same thread re-acquires | Deadlocks | Works — counter bumps |
| Different thread waits | Blocked (correct) | Blocked (correct) |
| Speed | Faster — simpler | ~2× slower — extra bookkeeping |
| Release must be by | Any thread | Only the owner thread |
| Number of acquires needed to free | 1 | Same as number of acquires |
| Use when | Single flat critical section | Nested calls / recursive functions / method calls another method |
Rule of Thumb
RLock. The performance cost is tiny; the safety it buys against
accidental self-deadlock is huge. Modern threading code uses RLock more often than
plain Lock.
Real-World Example — Thread-Safe Bank Account
Let's build a BankAccount class where multiple threads can deposit, withdraw,
and transfer between accounts without corrupting the balance. This shows why RLock is
the natural choice: transfer() calls withdraw() and
deposit() — nested locking.
import threading, time
class BankAccount:
def __init__(self, name, balance=0):
self.name = name
self.balance = balance
self.lock = threading.RLock() # reentrant — allows nesting
def deposit(self, amount):
with self.lock:
new_bal = self.balance + amount
time.sleep(0.001) # simulate DB latency
self.balance = new_bal
print(f" [+] {self.name} +{amount} → {self.balance}")
def withdraw(self, amount):
with self.lock:
if amount > self.balance:
raise ValueError(f"{self.name}: insufficient funds")
new_bal = self.balance - amount
time.sleep(0.001)
self.balance = new_bal
print(f" [-] {self.name} -{amount} → {self.balance}")
def transfer(self, to_account, amount):
# RLock lets us acquire our own lock twice — deposit/withdraw re-acquire it
with self.lock: # outer lock on source
with to_account.lock: # lock on destination
self.withdraw(amount) # re-acquires self.lock — OK with RLock
to_account.deposit(amount) # re-acquires to_account.lock — OK
print(f" => transferred {amount} {self.name} → {to_account.name}")
# Two accounts, many concurrent operations
alice = BankAccount("alice", 1000)
bob = BankAccount("bob", 1000)
def busy_customer():
for _ in range(5):
alice.deposit(10)
bob.withdraw(5)
alice.transfer(bob, 20)
threads = [threading.Thread(target=busy_customer, name=f"cust-{i}")
for i in range(3)]
for t in threads: t.start()
for t in threads: t.join()
print(f"\nfinal: alice={alice.balance} bob={bob.balance}")
print(f"total: {alice.balance + bob.balance} (should be 2000 — money is conserved)")
Without a lock, concurrent deposits and withdrawals would corrupt balance
(the same race condition as the counter example). With an RLock,
transfer() can safely call withdraw() and deposit()
— both re-acquire the same account's lock without deadlocking. The total balance
across both accounts stays exactly 2000, no matter how many concurrent threads pound
on them.
Common Pitfalls
with lock:, always.daemon=True in the constructor or immediately after Thread(), never after start().queue.Queue is a thread-safe container built on locks and conditions. Use it instead of hand-rolling lock-protected lists.with lock:. Slow I/O, logging, and computation should happen OUTSIDE the lock to reduce contention.Golden Rules
daemon=True only for background helpers that hold
no critical state — heartbeats, monitors, tickers. Never for threads that write files,
hold DB transactions, or must run cleanup.
daemon in the constructor or immediately
after Thread(...) — before start(). Changing it later raises
RuntimeError.
counter += 1 is not atomic — it's read, add,
write, and threads can interleave between those steps.
with lock: instead of manual
acquire()/release(). It guarantees the lock is released even
if the code inside raises an exception — no leaked locks.
lock1 before lock2, no cycle can form.
RLock when one locked method might call another
locked method — nested calls, recursion, method chains inside classes. A plain
Lock will self-deadlock the thread against itself.
RLock can release it, and it must
release it exactly as many times as it acquired it. Mismatched
acquire/release counts leave the lock permanently held.
queue.Queue over
hand-rolled lock-protected lists. It's already thread-safe, supports blocking
put/get, and eliminates a whole class of synchronization
bugs.