/user/kayd @ devops :~$ cat python-threading-concurrency.md

Python Threading & Concurrency Cheatsheet Python Threading & Concurrency Cheatsheet

QR Code linking to: Python Threading & Concurrency Cheatsheet
Karandeep Singh
Karandeep Singh
• 6 minutes

Summary

Python concurrency notes covering threading primitives, queue producer/consumer, concurrent.futures, multiprocessing, asyncio, and when to use which.

Half my automation scripts start out sequential and end up needing to hit 200 hosts at once. That’s when this sheet comes out. Python has four concurrency tools (threads, processes, executors, asyncio), and picking the wrong one for the workload is the classic mistake. If you’re new to Python itself, start with the Python Basics & CLI Cheatsheet first.

The GIL in One Minute

Docs: Global interpreter lock in the Python glossary

  • CPython’s Global Interpreter Lock allows only one thread to execute Python bytecode at a time.
  • Threads still help for I/O-bound work (HTTP calls, disk, DB, subprocess) because the GIL is released while waiting on I/O.
  • Threads do not help CPU-bound work (parsing, hashing, compression). Use processes for that.
  • C extensions (numpy, hashlib on large buffers) often release the GIL internally, so results vary. Measure.

Which One to Use When

Docs: Concurrent Execution, Python 3 docs

WorkloadBest toolWhy
I/O-bound, few dozen tasksThreadPoolExecutorSimple, GIL released during I/O
I/O-bound, thousands of tasksasyncioOne thread, cheap coroutines
CPU-boundProcessPoolExecutor / multiprocessingReal parallelism, sidesteps GIL
Mixed pipeline, decoupled stagesthreads + queue.QueueProducer/consumer backpressure
Fire-and-forget background workThread(daemon=True)Dies with the main process

threading Module

Docs: Python threading docs

Thread

import threading

def worker(host: str) -> None:
    print(f"[{threading.current_thread().name}] pinging {host}")

t = threading.Thread(target=worker, args=("web1",), name="ping-1", daemon=True)
t.start()
t.join(timeout=5)            # wait for it (with a timeout)
t.is_alive()                 # True if still running

Lock & RLock

lock = threading.Lock()
counter = 0

def increment() -> None:
    global counter
    with lock:               # always use `with` - releases on exception
        counter += 1

# RLock: same thread may re-acquire (needed when locked methods call each other)
rlock = threading.RLock()

class Registry:
    def __init__(self):
        self._lock = threading.RLock()
        self._data = {}

    def set(self, k, v):
        with self._lock:
            self._data[k] = v

    def set_many(self, items):
        with self._lock:          # re-enters fine because it's an RLock
            for k, v in items:
                self.set(k, v)

Event, Semaphore, Condition

# Event - one-shot broadcast flag (shutdown signals, "ready" gates)
stop = threading.Event()

def poller():
    while not stop.is_set():
        check_health()
        stop.wait(timeout=5)  # sleep, but wake immediately on set()

stop.set()                    # tell all waiters to stop

# Semaphore - cap concurrency (e.g. max 5 simultaneous API calls)
sem = threading.BoundedSemaphore(5)

def call_api(url):
    with sem:
        return fetch(url)

# Condition - wait for a state change under a lock
cond = threading.Condition()
items = []

def consumer():
    with cond:
        cond.wait_for(lambda: len(items) > 0, timeout=10)
        item = items.pop()

def producer(x):
    with cond:
        items.append(x)
        cond.notify()         # notify_all() to wake every waiter

queue.Queue Producer/Consumer

Docs: Python queue docs

import queue, threading

q: queue.Queue = queue.Queue(maxsize=100)   # maxsize gives you backpressure
SENTINEL = object()

def producer():
    for job in jobs:
        q.put(job)                # blocks when full
    q.put(SENTINEL)

def consumer():
    while True:
        job = q.get()             # blocks when empty; q.get(timeout=5) raises queue.Empty
        if job is SENTINEL:
            q.put(SENTINEL)       # let sibling consumers see it too
            break
        try:
            process(job)
        finally:
            q.task_done()

threads = [threading.Thread(target=consumer, daemon=True) for _ in range(4)]
[t.start() for t in threads]
producer()
q.join()                          # blocks until every task_done() is called

queue.LifoQueue (stack) and queue.PriorityQueue ((priority, item) tuples) share the same API.

concurrent.futures

Docs: concurrent.futures on docs.python.org

The interface I reach for 90% of the time. Same code works for threads and processes.

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor, as_completed

urls = [f"https://host{i}/health" for i in range(50)]

# map - results in submission order
with ThreadPoolExecutor(max_workers=10) as pool:
    for status in pool.map(fetch, urls, timeout=30):
        print(status)

# submit + as_completed - results as they finish, per-task error handling
with ThreadPoolExecutor(max_workers=10) as pool:
    futures = {pool.submit(fetch, url): url for url in urls}
    for fut in as_completed(futures, timeout=60):
        url = futures[fut]
        try:
            print(url, fut.result())
        except Exception as e:
            print(f"{url} failed: {e}")

# CPU-bound? One-line switch:
with ProcessPoolExecutor(max_workers=4) as pool:
    checksums = list(pool.map(sha256_file, big_files))

multiprocessing Basics

Docs: Python multiprocessing docs

import multiprocessing as mp

def crunch(chunk):
    return sum(x * x for x in chunk)

if __name__ == "__main__":                # REQUIRED guard on every entry point
    with mp.Pool(processes=mp.cpu_count()) as pool:
        results = pool.map(crunch, chunks)

    # Explicit Process + IPC
    q = mp.Queue()
    p = mp.Process(target=worker, args=(q,))
    p.start(); p.join()

    # Shared state (rarely needed - prefer passing data)
    counter = mp.Value("i", 0)
    with counter.get_lock():
        counter.value += 1

Notes: arguments and results must be picklable; each process has its own memory (no shared globals); default start method is spawn on macOS/Windows and since 3.14 on Linux too, so keep everything under the __main__ guard.

asyncio Quick Reference

Docs: Python asyncio docs

import asyncio
import aiohttp   # pip install aiohttp - requests is blocking, don't use it here

async def fetch(session: aiohttp.ClientSession, url: str) -> int:
    async with session.get(url) as resp:
        return resp.status

async def main() -> None:
    async with aiohttp.ClientSession() as session:
        # gather - run concurrently, keep order
        statuses = await asyncio.gather(
            *(fetch(session, u) for u in urls),
            return_exceptions=True,          # exceptions become results, not crashes
        )

        # Tasks - start now, await later
        task = asyncio.create_task(fetch(session, "https://x/health"))
        status = await task

        # Timeouts
        try:
            async with asyncio.timeout(5):           # 3.11+
                await fetch(session, slow_url)
        except TimeoutError:
            print("gave up")
        # pre-3.11 equivalent: await asyncio.wait_for(coro, timeout=5)

        # Structured concurrency (3.11+) - all-or-nothing task group
        async with asyncio.TaskGroup() as tg:
            for u in urls:
                tg.create_task(fetch(session, u))

asyncio.run(main())
# Other essentials
await asyncio.sleep(1)                       # never time.sleep() in async code
sem = asyncio.Semaphore(10)                  # cap concurrent coroutines
async with sem: ...
await asyncio.to_thread(blocking_func, arg)  # run blocking code without freezing the loop

Common Pitfalls

Docs: Developing with asyncio, Python 3 docs

PitfallFix
time.sleep() inside async codeawait asyncio.sleep()
Shared counter += 1 across threadsWrap in Lock; += is not atomic
Forgetting if __name__ == "__main__" with multiprocessingSpawn re-imports your module: infinite process bomb
Unbounded thread creation per requestUse an executor with max_workers
Daemon threads doing writes at shutdownNon-daemon + Event for clean shutdown
Calling fut.result() with no timeoutPass timeout= so hung tasks can’t hang you

Go handles all this very differently, with goroutines that are cheap and CPU-parallel by default. See the Go (Golang) Cheatsheet for the comparison.

  • Python Basics & CLI: comprehensions, pathlib, argparse, and the interpreter flags these concurrency examples assume
  • Go (Golang): goroutines, channels, and select, which is what concurrency looks like without a GIL
  • Python Boto3: where I use these pools most, for parallel S3 transfers and multi-region API sweeps

There’s more in the Python group, and the full cheatsheet library has everything else.

Similar Articles

More from development

Go (Golang) Cheatsheet

Go cheatsheet covering the go toolchain, syntax essentials, goroutines and channels, table-driven …

Bash Basics Cheatsheet

Quick-reference Bash basics cheatsheet: variables, parameter expansion, conditionals, loops, arrays, …

Python Boto3 Cheatsheet

Boto3 cheatsheet for AWS automation: sessions, S3, EC2, Lambda, DynamoDB, SSM, presigned URLs, …