Python Advance 📂 Threads · 2 of 4 61 min read

Python Daemon Threads, Synchronization, Lock & RLock — Safe Concurrent Programming

Master the trickiest parts of Python threading — daemon threads that die with the main program, race conditions that corrupt shared data, Lock for mutual exclusion, and RLock (reentrant lock) for functions that call themselves or nested locking. Loaded with runnable examples showing broken vs fixed code, deadlock demos, a bank-account transfer example, and SVG diagrams for every concept.

Section 01

The Story That Explains Synchronization

Two People Editing the Same Google Doc Without Turn-Taking
Imagine you and a friend both open the same Google Doc, and — for some strange reason — the app has no live collaboration. You both read the current word count: 100. You add a paragraph and set it to 150. Meanwhile your friend, still seeing 100, also adds a paragraph and sets it to 140. Whoever saves last wins. The other person's work vanishes.

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 Core Insight

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.


Section 02

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:

👤 Non-Daemon (default)
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)
👾 Daemon
Killed abruptly when main exits
Use for background monitors, heartbeats, watchers
Example: log flusher, metrics ticker, garbage collector
t.daemon = True (BEFORE start!)
FIGURE 1 — What Happens When Main Exits
NON-DAEMON — main WAITS t=0 t=∞ MAIN finished worker still running… done ↑ program exits HERE (after worker) DAEMON — killed with main t=0 MAIN finished daemon KILLED ✗ ↑ program exits HERE (with main) Daemon gets no chance to flush files, release locks, or clean up temp data

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")
OUTPUT
[monitor] heartbeat [monitor] heartbeat [monitor] heartbeat main exiting — daemon monitor dies automatically
⚠️
daemon Must Be Set BEFORE start()

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()
OUTPUT
Non daemon enters daemon enters nonDaemon exits # program exits here — "Daemon exits" is NEVER printed
FIGURE — Timeline of the Example Above
t=0s t=1s t=2s t=3s t=4s non_d — sleeps 3s → exits ✓ d — daemon, running… wanted 1 more second ✗ ↓ non_d finishes → program exits daemon KILLED here nd d1 Non-daemon controls program lifetime — daemon dies with it

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.

📈
setDaemon() vs the daemon Attribute

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()
OUTPUT
Non daemon enters daemon enters Daemon exits ← daemon finishes at t=3s while non_d is still running nonDaemon exits ← non-daemon finishes at t=4s → program exits
🏆
The Full Picture

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
OUTPUT
main done — daemon writer gets KILLED mid-write # data.txt has only ~20 lines instead of 100
🔥
Never Daemon Threads That Own Resources

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

🔊
Perfect for Daemon
background helpers
Health-check tickers, cache expiry loops, metrics collectors, watchdog timers, log-rotation checkers. Anything that runs forever and doesn't own critical state.
💾
Never Use Daemon For
critical work
Writing files, DB transactions, HTTP responses to real users, cleanup work, anything with side effects that must complete. Use non-daemon + join().
⚖️
Compromise Pattern
Event + join
For long-running non-daemon threads, use a threading.Event flag to signal shutdown, then join(). Lets the thread finish its work cleanly.

Section 03

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.

FIGURE 2 — How counter += 1 Loses Updates
counter += 1 is actually THREE operations 1) read counter → 2) add 1 → 3) write back Thread A reads counter → 100 adds 1 → 101 writes 101 SHARED counter 100 → 101 Should be 102! One update LOST Thread B reads counter → 100 (A hasn't written yet!) adds 1 → 101 writes 101 (overwrites A!) Both threads intended to increment counter — result should be 102, but it's only 101.

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")
OUTPUT (typical — actual numbers vary each run)
expected: 1000000 got: 783412 lost: 216588 increments
🔥
Silent Data Corruption

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.


Section 04

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.

FIGURE 3 — How a Lock Serialises Access
Only one thread holds the lock at any time 🔒 LOCK held by Thread A SHARED DATA counter, list, file… Thread A ✓ lock.acquire() HOLDS the lock modify data safely reads + writes lock.release() Thread B ⏳ lock.acquire() BLOCKED — waiting for A to release will proceed only AFTER A's release() blocked B only starts once A calls release(). No more interleaving between read and write.

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
OUTPUT
expected: 1000000 got: 1000000

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.

❌ Fragile — manual release
lock.acquire()
risky_operation() ← may raise
lock.release() ← never runs on exception!
Lock leaks → all other threads deadlock
✅ Safe — with block
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

MethodWhat It DoesCommon Use
lock.acquire()Wait forever until the lock is free, then take itMost common
lock.acquire(blocking=False)Try to take the lock; return False if unavailableNon-blocking check
lock.acquire(timeout=2)Wait up to 2 seconds; return False on timeoutAvoid indefinite hang
lock.release()Release the lock (RuntimeError if not held)Prefer with block
lock.locked()True if any thread holds the lockDiagnostics only
with lock:Automatic acquire + release with exception safetyAlways 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()
OUTPUT (order varies)
worker 0 got the lock, working… worker 1 could not get lock — skipping worker 2 could not get lock — skipping worker 3 could not get lock — skipping

Section 05

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.

FIGURE 4 — The Classic Deadlock Cycle
Thread A has: Lock 1 🔒 needs: Lock 2 ⏳ waiting forever Thread B has: Lock 2 🔒 needs: Lock 1 ⏳ waiting forever A waits for B's Lock 2 B waits for A's Lock 1 DEADLOCK — program frozen 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
OUTPUT
A got lock1 B got lock2 # ... freezes forever ...
🔑
The Lock-Ordering Rule

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.


Section 06

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.

FIGURE 5 — Lock vs RLock When the Same Thread Re-acquires
✗ plain Lock — self-deadlock lock.acquire() ✓ takes it outer_function() begins calls inner_function() lock.acquire() ✗ BLOCKS waits for itself → deadlock ∞ Program hangs forever ✓ RLock — reentrant, works rlock.acquire() ✓ count=1 outer_function() begins calls inner_function() rlock.acquire() ✓ count=2 rlock.release() ✓ count=1 rlock.release() ✓ count=0 → freed

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
OUTPUT
deposit 100 alive after 2s? True ← the thread is frozen waiting for itself

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")
OUTPUT
deposit 100 [audit] deposit of 100 finished cleanly
📈
How RLock Actually Works

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()
OUTPUT
start In first 2 14 15

Step-by-Step — The Recursion Counter in Action

🔁 What the RLock counter does at each step
Step 1
all() starts → prints "start". Counter = 0.
Step 2
lock.acquire() inside all → thread becomes owner. Counter 0 → 1.
Step 3
first(2) called → prints "In first 2"lock.acquire(). Same owner, so allowed. Counter 1 → 2.
Step 4
Compute a = 12 + 2 = 14lock.release(). Counter 2 → 1 (NOT freed — still owned!). Print 14.
Step 5
second(3)lock.acquire(). Counter 1 → 2. Compute b = 15. lock.release(). Counter 2 → 1. Print 15.
Step 6
Back in all(): lock.release(). Counter 1 → 0. Lock is now genuinely freed — any other thread can acquire it.
❌ With plain Lock — hangs at Step 3
all() acquires lock ✓
first() prints "In first 2"
first() calls lock.acquire() → BLOCKS
Waiting for itself → deadlock ∞
Output stops at "In first 2"
✅ With RLock — completes cleanly
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
🔑
Golden Take-Away

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)
OUTPUT (actual behaviour)
Thread1 # th1 acquired the lock and printed "Thread1" # th2 is now BLOCKED forever waiting for the lock # after 5s, th3 tries lock.release() from a thread that never acquired # → RuntimeError: cannot release un-acquired lock # th2 remains blocked → program hangs forever

What Actually Happens — Trace by Trace

🚨 Sequence of Events
t = 0s
th1 starts second("Thread1") → acquires lock → prints "Thread1" → function returns without releasing. Lock still owned by th1.
t = 0s
th2 starts second("Thread2") → calls lock.acquire() → BLOCKS forever (lock owned by th1, different thread).
t = 0s
th3 starts third() → begins sleeping for 5 seconds.
t = 5s
th3 wakes up, calls lock.release() — but th3 never acquired the lock. RuntimeError: cannot release un-acquired lock is raised.
t = ∞
th2 stays blocked forever. th2.join() in main also blocks forever. Program hangs — "Total time" never prints.
🔥
Two Fatal Anti-Patterns in One Program

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)
OUTPUT
Thread1 Thread2 Thread3 Total time 0:00:03.00xxxx
🔑
Two Lessons From These Two Problems

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.


Section 07

Lock vs RLock — Side by Side

PropertyLockRLock
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

🔒
Use Lock When
simple, flat
One critical section, no nested calls, no recursion. Simplest and fastest — the right default for "just protect this counter" scenarios.
🔁
Use RLock When
nested, recursive
Class methods that call other locked methods, recursive traversals, decorators that wrap methods, or when you can't be sure whether a helper acquires the lock too.
🧠
When in Doubt
go safer
Prefer 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.

Section 08

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)")
OUTPUT (partial — many lines omitted)
[+] alice +10 → 1010 [-] bob -5 → 995 [-] alice -20 → 990 [+] bob +20 → 1015 => transferred 20 alice → bob ... etc ... final: alice=850 bob=1150 total: 2000 (should be 2000 — money is conserved)
🏆
Why This Works

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.


Section 09

Common Pitfalls

Forgetting to release()
Exception between acquire() and release() leaves the lock stuck. Every waiting thread deadlocks. Use with lock:, always.
use with block
Setting daemon After start()
Raises RuntimeError. Set daemon=True in the constructor or immediately after Thread(), never after start().
set before start
Different Lock Order in Different Threads
Classic deadlock recipe. Always acquire multiple locks in the SAME order across every thread that uses them.
global lock order
Use RLock for Method Chains
If any locked method might call another locked method (directly or through inheritance), use RLock. Cheaper than debugging self-deadlocks.
safer default for OOP
Prefer queue.Queue for Producer/Consumer
queue.Queue is a thread-safe container built on locks and conditions. Use it instead of hand-rolling lock-protected lists.
use built-in Queue
Keep Critical Sections Short
Only put the truly-shared operations inside with lock:. Slow I/O, logging, and computation should happen OUTSIDE the lock to reduce contention.
short critical sections

Section 10

Golden Rules

🌲 Daemon Threads & Synchronization — Non-Negotiable Rules
1
Set 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.
2
Always set daemon in the constructor or immediately after Thread(...) — before start(). Changing it later raises RuntimeError.
3
Any variable read AND written by more than one thread must be protected by a lock. Even counter += 1 is not atomic — it's read, add, write, and threads can interleave between those steps.
4
Always use with lock: instead of manual acquire()/release(). It guarantees the lock is released even if the code inside raises an exception — no leaked locks.
5
Keep critical sections short. Only the shared-state operations belong inside the lock. Slow I/O, logging, and pure computation should happen outside so other threads don't wait unnecessarily.
6
To prevent deadlocks with multiple locks, always acquire them in the same global order across every thread. If everyone grabs lock1 before lock2, no cycle can form.
7
Use 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.
8
Only the thread that acquired an 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.
9
For producer/consumer patterns, prefer 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.