17.1 Concurrency Concepts and Terminology
A thread, in programming, is the smallest independently-schedulable unit of execution within a running program — a single sequence of instructions the CPU works through, with its own call stack and instruction pointer. Every running program starts with exactly one thread automatically (the main thread); more can be created to run additional sequences of instructions inside the same program.
Thread vs. process — a thread is not the same thing as a process (a running instance of a program, with its own private memory, created by the OS):
| Thread | Process | |
|---|---|---|
| Memory | shared with every other thread in the same process | its own separate, isolated address space |
| Creation cost | lightweight, fast to create | heavyweight, slower to create |
| Communication | direct — reads/writes the same variables | needs explicit IPC (a Queue, a pipe, ...) (17.4) |
| Crash isolation | one thread crashing can take the whole process down | one process crashing doesn't affect others |
| Parallelism in Python | limited by the GIL for CPU-bound work (17.5) | true parallelism across CPU cores (17.4) |
Concurrency vs. parallelism — another pair that looks interchangeable but isn't:
Concurrency (can happen on a single core - tasks take turns, interleaved):
core 1: [-- task A --][-- task B --][-- task A --][-- task B --]
Parallelism (needs multiple cores - tasks run at the literal same instant):
core 1: [------ task A ------]
core 2: [------ task B ------]
Concurrency is about structuring a program to deal with more than one task at once — it doesn't require more than one CPU core. Parallelism is about actually executing more than one task at the literal same instant, which does require multiple cores.
Why bother at all — it depends on what a task spends its time doing:
| Task type | Spends most time... | Best fit |
|---|---|---|
| I/O-bound | waiting (network, disk, user input) | threading (17.2) or asyncio (17.6) — either lets the CPU work on something else while waiting |
| CPU-bound | computing | multiprocessing (17.4) — the only one of the three that achieves real parallel computation in Python |
The rest of this chapter works through Python's three concurrency tools in that order:
multithreading, multiprocessing, and asyncio — then 17.7 gives a single table for
picking between them. Every program already has at least a MainThread, visible via
threading:
import threading
print(threading.active_count()) # 1 -> only the main thread exists so far
print(threading.current_thread().name) # MainThread
print(threading.current_thread() is threading.main_thread()) # True
1 MainThread True
17.2 Multithreading
The threading module offers three ways to get code running on another thread —
picking one over another is mostly a matter of how much structure the job needs:
| Way | Shape | Best for |
|---|---|---|
Thread(target=fn, args=...) |
pass a plain function | one-off, simple jobs |
Subclass Thread, override run() |
a class | a job that needs its own state/methods |
concurrent.futures.ThreadPoolExecutor |
a managed pool | running many similar jobs, capped concurrency |
Way 1 — target= and args=. The function runs on a new thread as soon as
.start() is called; .join() blocks the calling thread until it finishes:
import time
def greet(name):
time.sleep(0.1)
print(f"Hello, {name}!")
t = threading.Thread(target=greet, args=("Ada",)) # target + args
t.start() # begins running greet() on a new OS thread
t.join() # block here until t finishes
print("main thread continues") # only reached after "Hello, Ada!" has printed
Hello, Ada! main thread continues
Way 2 — subclassing Thread. Override run() instead of passing target=; useful
when the job needs its own attributes or several helper methods around it. __init__
must call super().__init__() first, to set up the Thread machinery it relies on:
class GreeterThread(threading.Thread):
def __init__(self, name):
super().__init__() # required - sets up the Thread machinery
self.person = name
def run(self): # override run() instead of passing target=
time.sleep(0.1)
print(f"Hello, {self.person}!")
t = GreeterThread("Grace")
t.start() # calls run() on a new thread
t.join()
print("main thread continues")
Hello, Grace! main thread continues
Way 3 — ThreadPoolExecutor. For running the same job across many inputs, a
pool manages a fixed number of worker threads and reuses them, instead of creating and
destroying a new Thread object per job. .map() runs the function across every item
and collects the results in order:
from concurrent.futures import ThreadPoolExecutor
def slow_greet(name):
time.sleep(0.1)
return f"Hello, {name}!"
with ThreadPoolExecutor(max_workers=2) as pool: # a managed pool of (at most 2) worker threads
results = pool.map(slow_greet, ["Ada", "Grace", "Alan"])
print(list(results)) # ['Hello, Ada!', 'Hello, Grace!', 'Hello, Alan!']
['Hello, Ada!', 'Hello, Grace!', 'Hello, Alan!']
In practice — an API client or web scraper. This ThreadPoolExecutor pattern is
exactly how a real API client pulls data for many items at once — fetching account
details for 500 users from a REST API, or scraping product pages across 100 URLs. Each
request spends almost all of its time waiting on the network, so a pool of a few dozen
threads (each running something like requests.get(url)) finishes the whole batch many
times faster than looping through the requests one at a time. asyncio (17.6) is the
usual next step once the number of concurrent requests grows into the thousands, where
per-thread overhead starts to matter.
.start() schedules run() to begin executing on a new OS thread and returns
immediately — it does not wait for that code to finish. It can only be called
once per Thread object; calling it again raises RuntimeError:
def worker():
time.sleep(0.2)
t = threading.Thread(target=worker)
t.start()
try:
t.start() # a Thread can only be started ONCE
except RuntimeError as e:
print("RuntimeError:", e) # threads can only be started once
t.join()
RuntimeError: threads can only be started once
.join([timeout]) is the primary tool for waiting on a thread — it blocks the
calling thread until the target thread finishes (or timeout seconds pass, if
given). Skipping it means the main thread can reach code that depends on the worker's
result before that worker is actually done:
def worker_that_prints():
time.sleep(0.2)
print("worker done")
t1 = threading.Thread(target=worker_that_prints)
t1.start()
print("without join(): this often prints FIRST, before the worker finishes")
t2 = threading.Thread(target=worker_that_prints)
t2.start()
t2.join() # blocks the main thread here until t2 actually finishes
print("with join(): this only prints AFTER the worker is done")
without join(): this often prints FIRST, before the worker finishes worker done worker done with join(): this only prints AFTER the worker is done
Introspecting threads — a Thread object carries identifying information, and
threading itself can list every thread currently alive:
| Gives | |
|---|---|
.name |
a human-readable label — auto-generated ("Thread-N (target)") unless name= is passed |
.ident |
a unique integer identifier, assigned once the thread has actually started; None before that |
.is_alive() |
whether the thread is currently running |
threading.active_count() |
how many Thread objects are currently alive, including MainThread |
threading.enumerate() |
a list of every currently alive Thread object |
A couple of threads, introspected at each stage of their life, makes all of this concrete:
def slow_worker():
time.sleep(0.3)
t1 = threading.Thread(target=slow_worker) # default name, auto-generated
t2 = threading.Thread(target=slow_worker, name="Downloader") # explicit name
print(t1.name) # Thread-N (slow_worker) -> auto-generated, includes the target's name
print(t2.name) # Downloader
print(t1.ident) # None -> not started yet, no OS-level identifier assigned
print(t1.is_alive()) # False -> not started yet
t1.start()
t2.start()
print(t1.ident is not None) # True -> now it has a real thread identifier
print(t1.is_alive()) # True -> currently running
print(threading.active_count()) # 3 -> MainThread + t1 + t2
print([th.name for th in threading.enumerate()]) # every currently alive thread, by name
t1.join()
t2.join()
print(t1.is_alive()) # False -> finished
print(threading.active_count()) # 1 -> back down to just MainThread
Thread-6 (slow_worker) Downloader None False True True 3 ['MainThread', 'Thread-6 (slow_worker)', 'Downloader'] False 1
A thread's exceptions stay local. If a Thread's target function raises and
nothing inside it catches the exception, Python prints the traceback to stderr and
that thread simply stops — the exception does not propagate to the main thread and
does not crash the process; everything else keeps running as if nothing happened,
which can make a silently-dead worker thread a surprisingly hard bug to notice.
17.3 Daemon Threads
A daemon thread is a background thread that doesn't keep the program alive — when every non-daemon thread has finished, Python exits immediately, killing any daemon threads still running, without waiting for them. A regular ("non-daemon") thread does the opposite: the program won't exit until it finishes, however long that takes.
Every thread is non-daemonic by default — .daemon starts out False unless set
otherwise, so a plain Thread(target=...) will always be waited for:
def worker():
time.sleep(0.1)
t = threading.Thread(target=worker)
print(t.daemon) # False -> non-daemonic by default
False
Setting .daemon must happen before .start() — either as a constructor
argument (Thread(target=..., daemon=True)) or by assignment beforehand. Trying to
flip it on an already-running thread raises RuntimeError:
t.start()
try:
t.daemon = True # too late - must be set BEFORE start()
except RuntimeError as e:
print("RuntimeError:", e) # cannot set daemon status of active thread
t.join()
RuntimeError: cannot set daemon status of active thread
Seeing the actual exit behaviour needs a real, separate process — inside this
notebook's own process, "exiting" isn't observable. Running two tiny scripts and timing
them makes the difference concrete: one has a daemon=True background thread, the
other a plain (non-daemon) one, otherwise identical:
import subprocess, tempfile, os
work_dir = tempfile.mkdtemp()
daemon_script = os.path.join(work_dir, "daemon_demo.py")
with open(daemon_script, "w") as f:
f.write(
"import threading, time\n"
"def background_work():\n"
" time.sleep(1)\n"
" print('background finished')\n"
"t = threading.Thread(target=background_work, daemon=True)\n"
"t.start()\n"
"print('main exiting')\n"
)
start = time.time()
result = subprocess.run(["python3", daemon_script], capture_output=True, text=True)
elapsed = time.time() - start
print(result.stdout.strip()) # main exiting -> the ONLY output; "background finished" never runs
print(elapsed < 1) # True -> the process exited immediately, didn't wait a full second
main exiting True
nondaemon_script = os.path.join(work_dir, "nondaemon_demo.py")
with open(nondaemon_script, "w") as f:
f.write(
"import threading, time\n"
"def background_work():\n"
" time.sleep(1)\n"
" print('background finished')\n"
"t = threading.Thread(target=background_work)\n" # daemon defaults to False
"t.start()\n"
"print('main reaching its end')\n"
)
start = time.time()
result = subprocess.run(["python3", nondaemon_script], capture_output=True, text=True)
elapsed = time.time() - start
print(result.stdout.strip()) # main reaching its end \n background finished -> both lines this time
print(elapsed >= 1) # True -> the process waited for the non-daemon thread
main reaching its end background finished True
17.4 Multiprocessing
The multiprocessing module mirrors threading's API almost exactly —
Process(target=fn, args=...), .start(), .join() — but each Process is a
genuinely separate operating-system process, with its own private memory and its
own Python interpreter, rather than another thread inside the same one. That's what
buys real parallelism (17.5): two Processes can run Python bytecode on two different
CPU cores at the literal same instant, which two Threads in the same process cannot.
Common mistake — no if __name__ == "__main__": guard. Starting a child process
re-imports the launching script in a fresh interpreter to set itself up; without the
guard (9.4), that re-import re-runs everything at module level, including the
Process(...).start() call itself — spawning children recursively. Every demo below
uses the guard for exactly this reason.
Separate memory, made concrete — a global variable changed by a thread is visible everywhere (shared memory); the same change made inside a process never reaches the parent at all (separate memory):
Going deeper — platform note. "Re-imports the launching script" describes the
spawn start method specifically — the default on Windows and macOS, where a child
process starts as a brand-new interpreter with nothing inherited from the parent, so it
has to re-import the module to rebuild what it needs. Linux defaults to fork
instead, which clones the existing parent process (memory and all) rather than
starting fresh — no re-import happens there, though the guard is still good practice
for portability. One more spawn-specific gotcha: because a spawned child rebuilds
everything from scratch, whatever is passed as a Process's target=/args= must be
picklable — a lambda, an open file handle, or a live network/database connection
will fail to cross the process boundary, even though the exact same code runs fine
under fork on Linux.
import multiprocessing
counter = 0
def bump_in_thread():
global counter
counter += 1
def bump_in_process():
global counter
counter += 1
print("counter INSIDE the child process:", counter) # sees its own separate copy
if __name__ == "__main__":
t = threading.Thread(target=bump_in_thread)
t.start()
t.join()
print("counter after a THREAD modifies it:", counter) # 1 -> shared memory, the change is visible
p = multiprocessing.Process(target=bump_in_process)
p.start()
p.join()
print("counter after a PROCESS modifies it:", counter) # still 1 -> separate memory, no effect here
counter after a THREAD modifies it: 1 counter after a PROCESS modifies it: 1
Since a child process can't hand results back through shared memory, getting a value
out requires explicit inter-process communication (IPC) — multiprocessing.Queue
is a process-safe queue built exactly for this:
def compute_square(n, result_queue):
result_queue.put(n * n) # the way to get a value back - shared memory doesn't work here
if __name__ == "__main__":
q = multiprocessing.Queue()
p = multiprocessing.Process(target=compute_square, args=(7, q))
p.start()
p.join()
print(q.get()) # 49 -> retrieved across the process boundary
49
In practice — background task queues. Job-processing systems like Celery are
built on this same worker-process model: a task (send a confirmation email, generate a
PDF invoice, resize an uploaded image) is placed on a message broker's queue, and a
pool of separate Celery worker processes pulls tasks off it and runs them outside the
request/response cycle of the web server that enqueued them. multiprocessing.Queue
above is that same idea in miniature — work and results crossing a process boundary
through a queue, since (as just shown) shared memory isn't an option.
17.5 The GIL and Performance Implications
The Global Interpreter Lock (GIL) is a single lock inside CPython (the reference interpreter this material targets) that only lets one thread execute Python bytecode at a time — even on a machine with many CPU cores. This is exactly the limitation 1.1.6 mentions: multiple threads in CPython can never run Python bytecode in parallel, no matter how many cores are available.
The GIL is released, though, whenever a thread is waiting — on time.sleep(), a
network response, a disk read — which is precisely why threading still helps I/O-bound
work (17.1) despite it. Three timed comparisons make the whole picture concrete:
def cpu_bound(n):
total = 0
for i in range(n):
total += i * i
return total
N = 8_000_000
start = time.time()
cpu_bound(N)
cpu_bound(N)
sequential_time = time.time() - start
start = time.time()
t1 = threading.Thread(target=cpu_bound, args=(N,))
t2 = threading.Thread(target=cpu_bound, args=(N,))
t1.start(); t2.start()
t1.join(); t2.join()
threaded_time = time.time() - start
print(f"sequential: {sequential_time:.2f}s, threaded: {threaded_time:.2f}s") # roughly equal
print(threaded_time >= sequential_time * 0.9) # True -> no real speedup - the GIL serializes CPU-bound work
sequential: 0.85s, threaded: 0.86s True
Going deeper — this is the standard-build story specifically (1.1.6 already flags
this). CPython also offers an optional free-threaded build that removes the GIL
entirely, distinct from the standard build most people actually run. On that build, the
timed comparison above stops holding: two threads running cpu_bound genuinely execute
on separate cores, with a real speedup much like the multiprocessing version below —
without needing separate processes or IPC at all. sys._is_gil_enabled() reports which
kind of build is currently running — True on the standard build, False on the
free-threaded one.
Swapping those same two cpu_bound calls onto separate processes instead of
threads sidesteps the GIL entirely — each process has its own interpreter and its own
GIL, so they run on separate cores in true parallel:
if __name__ == "__main__":
start = time.time()
p1 = multiprocessing.Process(target=cpu_bound, args=(N,))
p2 = multiprocessing.Process(target=cpu_bound, args=(N,))
p1.start(); p2.start()
p1.join(); p2.join()
multiprocess_time = time.time() - start
print(f"sequential: {sequential_time:.2f}s, multiprocess: {multiprocess_time:.2f}s")
print(multiprocess_time < sequential_time * 0.75) # True -> a real speedup, on separate cores
sequential: 0.85s, multiprocess: 0.47s True
In practice — CPU-bound batch processing. A batch thumbnail-generation service
(resizing every image a user uploads into several sizes) or a data pipeline parsing and
transforming millions of CSV/log rows are classic CPU-bound workloads where
multiprocessing (or ProcessPoolExecutor) delivers a real, measurable speedup on a
multi-core machine. Each worker process has its own interpreter and its own GIL, so,
unlike threads, several of them really can crunch numbers on separate cores at the
same instant.
By contrast, an I/O-bound task (time.sleep() standing in for a network/disk wait)
does speed up with threads — because the GIL is released for the entire duration of
the wait, leaving it free for another thread to use:
def io_bound():
time.sleep(0.3) # stands in for waiting on a network/disk response
start = time.time()
io_bound()
io_bound()
sequential_io_time = time.time() - start
start = time.time()
t1 = threading.Thread(target=io_bound)
t2 = threading.Thread(target=io_bound)
t1.start(); t2.start()
t1.join(); t2.join()
threaded_io_time = time.time() - start
print(f"sequential: {sequential_io_time:.2f}s, threaded: {threaded_io_time:.2f}s")
print(threaded_io_time < sequential_io_time * 0.75) # True -> a real speedup - the GIL was released during sleep
sequential: 0.60s, threaded: 0.30s True
17.6 Asynchronous Programming
asyncio achieves concurrency (17.1) with no extra threads or processes at all —
everything runs on a single thread, in a single process, taking turns cooperatively
instead of being preemptively switched by the OS. An async def function is a
coroutine function: calling it doesn't run its body, it returns a coroutine
object, which only actually executes when it's awaited. Each await is a
deliberate handoff point — "pause me here, let something else run, resume me once
what I'm waiting on is ready" — managed by an event loop, started with
asyncio.run(). asyncio.gather() runs several coroutines concurrently and waits for
all of them:
import asyncio
async def fetch(name, delay):
print(f"{name}: starting")
await asyncio.sleep(delay) # yields control back to the event loop instead of blocking
print(f"{name}: done")
return name
async def main():
start = time.time()
results = await asyncio.gather(fetch("A", 0.3), fetch("B", 0.3), fetch("C", 0.3))
print(results) # ['A', 'B', 'C']
print(time.time() - start < 0.6) # True -> all three ran CONCURRENTLY, not one after another
asyncio.run(main())
# A: starting -> all three reach their first "await" before any of them resumes
# B: starting
# C: starting
# A: done -> then all three finish sleeping at roughly the same time
# B: done
# C: done
A: starting B: starting C: starting A: done B: done C: done ['A', 'B', 'C'] True
In practice — high-throughput web services. This single-thread, many-coroutines
model is the standard architecture behind modern async Python web frameworks:
FastAPI and aiohttp use asyncio under the hood to hold thousands of
concurrent client connections open at once — an API gateway, a chat backend, or a
webhook/notification dispatcher — without needing a dedicated OS thread per connection,
which stops scaling long before that.
Common mistake — forgetting await. Calling a coroutine function is not the same
as running it — without await, the call just produces an unstarted coroutine object
and moves on, silently skipping the body entirely:
async def greet():
print("hello")
async def demo_missing_await():
result = greet() # forgot "await" - this does NOT run the coroutine body yet
print(type(result)) # <class 'coroutine'> -> just an unstarted coroutine object
await result # NOW it actually runs
asyncio.run(demo_missing_await())
# <class 'coroutine'>
# hello
<class 'coroutine'> hello
Common mistake — calling a blocking function inside a coroutine. asyncio is
cooperative (17.6): every coroutine runs on the same single thread, and control only
passes to another task at an await. A genuinely blocking call like time.sleep() or
a synchronous (non-async) HTTP request doesn't await anything, so it never hands
control back — it freezes the entire event loop, not just the task that made the
call, until it returns:
async def bad_task(name):
print(f"{name}: starting (blocking sleep)")
time.sleep(0.2) # BLOCKS the entire event loop - no other task can run meanwhile
print(f"{name}: done")
async def demo_blocking():
start = time.time()
await asyncio.gather(bad_task("A"), bad_task("B"))
print(f"took {time.time() - start:.2f}s") # ~0.4s -> ran SEQUENTIALLY, not concurrently
asyncio.run(demo_blocking())
# A: starting (blocking sleep)
# A: done -> B never even gets to start until A's blocking call finally returns
# B: starting (blocking sleep)
# B: done
# took 0.40s -> compare to the ~0.3s asyncio.sleep() version above, which ran truly concurrently
A: starting (blocking sleep) A: done B: starting (blocking sleep) B: done took 0.40s
17.7 Choosing a Concurrency Model
Putting 17.2/17.4/17.6 side by side:
| Threading | Multiprocessing | asyncio |
|
|---|---|---|---|
| Best for | I/O-bound, using existing blocking libraries | CPU-bound work | I/O-bound, many concurrent tasks |
| Real parallelism? | no (GIL, 17.5) | yes — separate processes/cores | no — single thread, single process |
| Memory | shared | separate (needs IPC, 17.4) | shared (it's all one thread) |
| Overhead per task | moderate (an OS thread) | high (a whole process) | very low (just a coroutine object) |
| Requires "async-aware" code? | no | no | yes — every blocking call needs an await-compatible version |
Is the task CPU-bound (computing) or I/O-bound (waiting)?
|
+----------- CPU-bound -+- I/O-bound -----------+
| |
multiprocessing (17.4) how MANY tasks at once?
(only real way around the GIL) |
+---- a few ----+---- very many -----+
| |
threading (17.2) asyncio (17.6)
(simplest; works with existing (lowest overhead per task,
blocking libraries as-is) but needs async-aware code)
None of these is "the" right answer — a program can mix them (e.g. asyncio for
thousands of open network connections, handing CPU-heavy parsing off to a
multiprocessing pool).
from concurrent.futures import ProcessPoolExecutor
def cpu_heavy(n):
total = 0
for i in range(n):
total += i * i
return total
async def run_mixed():
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pool:
# hands CPU-bound work off to a separate process, without blocking the event loop
result = await loop.run_in_executor(pool, cpu_heavy, 5_000_000)
return result
if __name__ == "__main__":
print(asyncio.run(run_mixed())) # 41666654166667500000 -> asyncio + multiprocessing, mixed
41666654166667500000
17.9 Thread Synchronization: Race Conditions and Locks
A race condition happens when multiple threads read and write shared, mutable
state without coordinating, so the final result depends on the unpredictable order
their operations happen to interleave in. counter += 1 looks like one indivisible
step, but it's really three: read counter, compute counter + 1, write the
result back — and another thread can run in between any of those steps. Forcing a
visible gap between the read and the write makes the corruption reliable to observe:
shared_counter = 0
def increment_unsafe():
global shared_counter
for _ in range(5):
temp = shared_counter # read
time.sleep(0.001) # a deliberate window another thread can interleave into
shared_counter = temp + 1 # write - based on a possibly-now-stale "temp"
threads = [threading.Thread(target=increment_unsafe) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(shared_counter) # expected 20 (4 threads x 5 increments); usually LESS
print(shared_counter < 20) # True -> some increments were silently lost
5 True
Real code never sleeps in the middle of an operation like this — the interleaving that
actually causes a race condition is driven by unpredictable OS thread scheduling, which
can switch threads at any point regardless. The time.sleep(0.001) above only widens
that window artificially, so the corruption reproduces reliably on every run of this
demo instead of only occasionally.
In practice — a real production bug class. This is precisely how a race condition shows up in production checkout or inventory code: two concurrent requests both read "5 items in stock" before either writes back the decremented count, letting a product oversell — or two concurrent "charge card" requests both pass a stale balance check and double-charge a customer. It's why payment and inventory code paths almost never trust a read-check-write sequence to be atomic on its own, reaching instead for a lock (in-process, as below) or a database transaction/row lock (18.5).
threading.Lock fixes this — only one thread at a time can be inside a locked
block (via .acquire()/.release(), or a with lock: context manager, 16.6), so the
read-sleep-write sequence can no longer be interrupted midway:
safe_counter = 0
lock = threading.Lock()
def increment_safe():
global safe_counter
for _ in range(5):
with lock: # only one thread at a time can be inside this block
temp = safe_counter
time.sleep(0.001)
safe_counter = temp + 1
threads = [threading.Thread(target=increment_safe) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
print(safe_counter) # 20, every time
print(safe_counter == 20) # True -> the Lock forces the increments to happen one at a time
20 True
A few related threading synchronization primitives, each solving a variant of the
same coordination problem:
| Tool | One-liner |
|---|---|
Lock |
one thread inside a block at a time (above) |
RLock |
a "reentrant" lock — the same thread may re-acquire it without deadlocking itself |
Semaphore(n) |
up to n threads inside a block at once, instead of just one |
Event |
one thread signals (.set()), others wait for it (.wait()) |
RLock — a "reentrant" lock: the same thread that already holds it can acquire it
again without blocking, as long as it releases it the same number of times it acquired
it. A plain Lock doesn't allow this — a thread trying to re-acquire a Lock it
already holds blocks forever, waiting for itself to release it, which is exactly the
situation a method that calls another locking method (on the same object, same thread)
runs into:
class Counter:
def __init__(self):
self.lock = threading.RLock()
self.value = 0
def increment(self):
with self.lock: # first acquire
self.value += 1
self.log() # calls another method that ALSO acquires the same lock
def log(self):
with self.lock: # re-acquires the SAME lock, on the SAME thread - fine with RLock
print(f"value is now {self.value}")
c = Counter()
t = threading.Thread(target=c.increment)
t.start()
t.join()
# value is now 1
# a plain Lock would DEADLOCK here: increment() holds it, then log() tries to acquire
# the SAME Lock on the SAME thread and blocks forever waiting for itself to release it
value is now 1
Semaphore(n) generalizes Lock from "one thread at a time" to "up to n threads
at a time" — useful for capping concurrent access to a limited resource (a connection
pool, a rate-limited API) among more threads than there are slots:
sem = threading.Semaphore(2) # only 2 threads allowed inside at once
running = []
def limited_worker(n):
with sem:
running.append(n)
print(f"worker {n} running, concurrent: {running}")
time.sleep(0.05)
running.remove(n)
threads = [threading.Thread(target=limited_worker, args=(i,)) for i in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
# 5 threads total, but the printed "concurrent" list never shows more than 2 names at once -
# Semaphore(2) only ever lets 2 of them past ".acquire()" (the "with sem:") at the same time
worker 0 running, concurrent: [0] worker 1 running, concurrent: [0, 1] worker 2 running, concurrent: [2] worker 3 running, concurrent: [2, 3] worker 4 running, concurrent: [4]
Event is a simple flag one thread can wait on and another can set — .wait()
blocks until .set() is called elsewhere (or an optional timeout passes), a common
pattern for "wait for a start signal" or "wait until setup is complete":
event = threading.Event()
def waiter():
print("waiter: waiting for the signal")
event.wait() # blocks here until another thread calls event.set()
print("waiter: got the signal, proceeding")
t = threading.Thread(target=waiter)
t.start()
time.sleep(0.1) # give the waiter time to reach event.wait() first
print("main: sending the signal")
event.set() # unblocks the waiting thread
t.join()
# waiter: waiting for the signal
# main: sending the signal
# waiter: got the signal, proceeding
waiter: waiting for the signal main: sending the signal waiter: got the signal, proceeding