Python Concurrency — threading, multiprocessing, asyncio
Concurrency means making progress on more than one task at a time. Python offers three approaches, each suited to a different kind of work:
| Approach | Best for | Why |
|---|---|---|
| threading | I/O-bound work (network, disk) | Threads wait together; cheap to create. |
| multiprocessing | CPU-bound work (number crunching) | Separate processes use multiple cores. |
| asyncio | Many I/O tasks at once | One thread, cooperative awaitawait, very scalable. |
The GIL — why it matters
CPython has a Global Interpreter Lock (GIL): only one thread executes Python bytecode at a time. So threads don’t speed up CPU-bound code — but they’re great for I/O, where threads spend most time waiting (and release the GIL while waiting). For CPU-bound parallelism, use multiprocessing, which sidesteps the GIL with separate processes.
Rule of thumb: I/O-bound → threading or asyncio. CPU-bound → multiprocessing.
threading
Run functions in separate threads. Start them, then joinjoin to wait for completion.
import threading
import time
def worker(name):
print(f"{name} starting")
time.sleep(1) # simulates an I/O wait
print(f"{name} done")
threads = [threading.Thread(target=worker, args=(f"T{i}",)) for i in range(3)]
for t in threads:
t.start() # begin running concurrently
for t in threads:
t.join() # wait for each to finish
print("all done")import threading
import time
def worker(name):
print(f"{name} starting")
time.sleep(1) # simulates an I/O wait
print(f"{name} done")
threads = [threading.Thread(target=worker, args=(f"T{i}",)) for i in range(3)]
for t in threads:
t.start() # begin running concurrently
for t in threads:
t.join() # wait for each to finish
print("all done")Because the threads sleep concurrently, three 1-second waits finish in about 1 second, not 3.
Sharing data safely with a Lock
When threads modify shared state, guard it with a LockLock to avoid race conditions.
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(100_000):
with lock: # only one thread at a time
counter += 1
ts = [threading.Thread(target=increment) for _ in range(2)]
for t in ts: t.start()
for t in ts: t.join()
print(counter) # 200000 (correct, thanks to the lock)import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(100_000):
with lock: # only one thread at a time
counter += 1
ts = [threading.Thread(target=increment) for _ in range(2)]
for t in ts: t.start()
for t in ts: t.join()
print(counter) # 200000 (correct, thanks to the lock)concurrent.futures — a simpler interface
ThreadPoolExecutorThreadPoolExecutor / ProcessPoolExecutorProcessPoolExecutor manage a pool of workers and collect results for you.
from concurrent.futures import ThreadPoolExecutor
def square(n):
return n * n
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(square, [1, 2, 3, 4, 5]))
print(results) # [1, 4, 9, 16, 25]from concurrent.futures import ThreadPoolExecutor
def square(n):
return n * n
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(square, [1, 2, 3, 4, 5]))
print(results) # [1, 4, 9, 16, 25]Swap ThreadPoolExecutorThreadPoolExecutor for ProcessPoolExecutorProcessPoolExecutor to run CPU-bound work across cores — the API is identical.
multiprocessing
Each process has its own Python interpreter and memory, so they run truly in parallel on multiple cores.
from multiprocessing import Pool
def heavy(n):
return sum(i * i for i in range(n))
if __name__ == "__main__": # required guard on Windows/macOS
with Pool(processes=4) as pool:
results = pool.map(heavy, [10_000, 20_000, 30_000])
print(results)from multiprocessing import Pool
def heavy(n):
return sum(i * i for i in range(n))
if __name__ == "__main__": # required guard on Windows/macOS
with Pool(processes=4) as pool:
results = pool.map(heavy, [10_000, 20_000, 30_000])
print(results)Always wrap multiprocessing entry code in
if __name__ == "__main__":if __name__ == "__main__":— without it, spawned processes re-import and re-run your script.
asyncio — async/await
asyncioasyncio runs many I/O tasks on a single thread using cooperative multitasking. An async defasync def function is a coroutine; awaitawait yields control while waiting, letting other coroutines run.
import asyncio
async def fetch(name, delay):
print(f"{name} start")
await asyncio.sleep(delay) # non-blocking wait
print(f"{name} done")
return name
async def main():
# Run three coroutines concurrently
results = await asyncio.gather(
fetch("A", 1),
fetch("B", 1),
fetch("C", 1),
)
print(results)
asyncio.run(main()) # entry pointimport asyncio
async def fetch(name, delay):
print(f"{name} start")
await asyncio.sleep(delay) # non-blocking wait
print(f"{name} done")
return name
async def main():
# Run three coroutines concurrently
results = await asyncio.gather(
fetch("A", 1),
fetch("B", 1),
fetch("C", 1),
)
print(results)
asyncio.run(main()) # entry pointAll three “fetches” overlap, so the total time is about 1 second, not 3.
Key asyncio pieces
| Piece | Purpose |
|---|---|
async defasync def | Defines a coroutine. |
await xawait x | Wait for an awaitable without blocking the thread. |
asyncio.run(coro)asyncio.run(coro) | Run the top-level coroutine. |
asyncio.gather(*coros)asyncio.gather(*coros) | Run many coroutines concurrently, collect results. |
asyncio.create_task(coro)asyncio.create_task(coro) | Schedule a coroutine to run in the background. |
asyncio.sleep(s)asyncio.sleep(s) | Non-blocking sleep. |
Choosing the right tool
Is the work CPU-bound (math, parsing, compression)?
-> multiprocessing (use all cores, beat the GIL)
Is the work I/O-bound (HTTP, files, DB)?
A few tasks, simple code? -> threading / ThreadPoolExecutor
Hundreds/thousands of tasks? -> asyncioIs the work CPU-bound (math, parsing, compression)?
-> multiprocessing (use all cores, beat the GIL)
Is the work I/O-bound (HTTP, files, DB)?
A few tasks, simple code? -> threading / ThreadPoolExecutor
Hundreds/thousands of tasks? -> asyncioCommon pitfalls
- Threads won’t speed up CPU work — the GIL serializes them; use multiprocessing.
- Forgetting
joinjoin— the main program may exit before threads finish. - Unguarded shared state — use a
LockLockaround shared mutable data. - Blocking calls inside asyncio —
time.sleeptime.sleepor heavy CPU work freezes the whole event loop; useawait asyncio.sleepawait asyncio.sleepand run CPU work in an executor. - Missing
if __name__ == "__main__":if __name__ == "__main__":— breaks multiprocessing on Windows/macOS.
Practice Exercises
Exercise 1 – Run work in threads
Exercise 2 – Define and run a coroutine
Exercise 3 – Map work over a thread pool
Summary
- The GIL means threads don’t parallelize CPU work — they shine for I/O.
- threading +
LockLockfor simple concurrent I/O;concurrent.futuresconcurrent.futuresfor pools and results. - multiprocessing for CPU-bound parallelism across cores (guard with
__main____main__). - asyncio (
asyncasync/awaitawait,gathergather,runrun) scales to many concurrent I/O tasks on one thread. - Match the tool to the workload: CPU → processes, I/O → threads/async.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
