Skip to content

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:

ApproachBest forWhy
threadingI/O-bound work (network, disk)Threads wait together; cheap to create.
multiprocessingCPU-bound work (number crunching)Separate processes use multiple cores.
asyncioMany I/O tasks at onceOne 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.

threading_basic.py
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")
threading_basic.py
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.

lock.py
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)
lock.py
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.

executor.py
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]
executor.py
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.

multiprocessing_basic.py
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)
multiprocessing_basic.py
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.

asyncio_basic.py
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 point
asyncio_basic.py
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 point

All three “fetches” overlap, so the total time is about 1 second, not 3.

Key asyncio pieces

PiecePurpose
async defasync defDefines a coroutine.
await xawait xWait 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

decision.txt
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?   -> asyncio
decision.txt
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?   -> asyncio

Common 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 LockLock around shared mutable data.
  • Blocking calls inside asynciotime.sleeptime.sleep or heavy CPU work freezes the whole event loop; use await asyncio.sleepawait asyncio.sleep and 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 + LockLock for simple concurrent I/O; concurrent.futuresconcurrent.futures for 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 coffee

Was this page helpful?

Let us know how we did