Processes and Threads in Python: Complete Concurrency and Parallel Execution Implementation Guide

Processes and Threads in python

Processes and Threads in python

I used to treat “threads” and “processes” as basically interchangeable words for “doing things at the same time,” until a performance problem forced me to actually understand the difference — a threaded version of a CPU-heavy script ran no faster than the single-threaded original, while switching to processes gave me a near-linear speedup across cores. That gap taught me more about how Python actually executes code than any amount of reading alone had. Here’s the complete picture of processes, threads, and how to choose between them.

What a Process Actually Is

A process is an independent instance of a running program, with its own private memory space, its own Python interpreter (in the case of Python programs), and its own set of OS-level resources (file descriptors, environment variables, and so on). Processes don’t share memory with each other by default — if one process modifies a variable, no other process sees that change, because they’re operating on entirely separate copies of memory.

import multiprocessing
import os

def show_pid():
    print(f"Running in process ID: {os.getpid()}")

if __name__ == "__main__":
    print(f"Main process ID: {os.getpid()}")
    p = multiprocessing.Process(target=show_pid)
    p.start()
    p.join()

Each process here genuinely has a different process ID, reflecting that they’re entirely separate execution contexts at the operating system level.

What a Thread Actually Is

A thread is a unit of execution within a process. Multiple threads in the same process share the same memory space — they can all read and write the same variables directly, which makes communication between threads simpler (no serialization needed) but also introduces the risk of race conditions if that shared access isn’t properly synchronized.

import threading

shared_counter = 0

def increment():
    global shared_counter
    for _ in range(100_000):
        shared_counter += 1

threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(shared_counter)  # likely NOT 400,000 — a race condition!

This is a genuinely important demonstration: because shared_counter += 1 isn’t atomic (it involves reading the current value, adding one, and writing it back — three separate steps), multiple threads can interleave these steps and lose updates, resulting in a final count less than the expected 400,000. This is a classic race condition, and it’s exactly the kind of bug that only shows up intermittently, making it notoriously hard to debug.

The Global Interpreter Lock (GIL)

CPython’s GIL ensures that only one thread executes Python bytecode at any given instant, even on a multi-core system. This might seem to make the race condition above less likely, but it doesn’t eliminate it — the GIL can still switch between threads at points that split a seemingly simple operation like += 1 into multiple bytecode instructions, leaving room for interleaving.

The GIL exists primarily to simplify CPython’s internal memory management (specifically, reference counting for garbage collection) by avoiding the need for fine-grained locks around every single object — a design decision with real trade-offs that has shaped how Python concurrency works ever since.

import dis

def increment(counter):
    counter[0] += 1

dis.dis(increment)

Disassembling this reveals that even a simple increment compiles to multiple bytecode instructions (load, add, store) — and the GIL can switch to another thread between any of them, which is exactly why shared mutable state still needs explicit locking even with the GIL in place.

Fixing the Race Condition with a Lock

import threading

shared_counter = 0
lock = threading.Lock()

def increment():
    global shared_counter
    for _ in range(100_000):
        with lock:
            shared_counter += 1

threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(shared_counter)  # correctly 400,000

The Lock ensures that only one thread can execute the critical section (shared_counter += 1) at a time, making the overall increment sequence effectively atomic from the perspective of other threads — this eliminates the race condition entirely, at the cost of some synchronization overhead.

When Threads Genuinely Help: I/O-Bound Work

Despite the GIL, threads are still genuinely useful — specifically for I/O-bound tasks, where a thread spends most of its time waiting (for a network response, a disk read, or similar), rather than executing Python bytecode. During I/O waits, the GIL is released, allowing other threads to run — this is where threading provides real, meaningful concurrency in Python.

import threading
import time
import requests  # third-party library, for illustration

def fetch(url, results, index):
    response = requests.get(url)
    results[index] = response.status_code

urls = ["https://python.org"] * 5
results = [None] * len(urls)

start = time.perf_counter()
threads = [threading.Thread(target=fetch, args=(url, results, i)) for i, url in enumerate(urls)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(f"Threaded: {time.perf_counter() - start:.2f}s")  # much faster than sequential requests

Since each requests.get() call spends most of its time waiting on the network (not executing Python bytecode), the GIL is released during that wait, letting other threads make progress — this genuinely overlaps the waiting time across all five requests, producing a real speedup over doing them one at a time.

When Processes Are Necessary: CPU-Bound Work

For CPU-bound work — tight loops doing heavy computation — the GIL prevents any real parallelism between threads, since they’re all constantly competing for the single lock needed to execute Python bytecode. Processes, each with their own interpreter and GIL, are the way to achieve genuine parallel execution here.

import multiprocessing
import time

def cpu_heavy(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

if __name__ == "__main__":
    start = time.perf_counter()
    with multiprocessing.Pool(processes=4) as pool:
        results = pool.map(cpu_heavy, [5_000_000] * 4)
    print(f"Multiprocessing: {time.perf_counter() - start:.2f}s")

On a multi-core machine, this genuinely uses multiple cores simultaneously, providing real speedup roughly proportional to the number of cores used (up to the point of diminishing returns from overhead and contention for other shared resources like memory bandwidth).

Threads vs. Processes: A Direct Comparison

AspectThreadsProcesses
MemoryShared within the processSeparate for each process
Creation costLow (lightweight)Higher (full interpreter startup)
CommunicationDirect (shared variables), needs lockingExplicit (Queue, Pipe, shared memory), needs serialization
GIL impactLimits CPU-bound parallelismNot affected — each process has its own GIL
Best forI/O-bound workCPU-bound work
Failure isolationA crash in one thread can affect the whole processA crashed process doesn’t directly crash others

Thread Synchronization Primitives

Beyond Lock, Python’s threading module provides several other synchronization tools I reach for depending on the situation.

import threading

# RLock: a lock that can be acquired multiple times by the same thread (reentrant)
rlock = threading.RLock()

# Semaphore: limits how many threads can access a resource simultaneously
semaphore = threading.Semaphore(3)  # at most 3 threads at once

# Event: a simple flag threads can wait on
event = threading.Event()

def waiter():
    print("Waiting for event...")
    event.wait()
    print("Event was set, proceeding")

t = threading.Thread(target=waiter)
t.start()
event.set()  # releases the waiting thread
t.join()

# Condition: more advanced coordination, allowing threads to wait for a specific condition
condition = threading.Condition()

I use Semaphore most often when limiting concurrent access to a resource with a hard capacity (like a connection pool), and Event for simple one-time signaling between threads (like “setup is complete, workers can start”).

Daemon Threads and Clean Shutdown

import threading
import time

def background_task():
    while True:
        time.sleep(1)
        print("Working in the background...")

t = threading.Thread(target=background_task, daemon=True)
t.start()

time.sleep(3)
print("Main program exiting")  # daemon thread is killed automatically when main program exits

Marking a thread as a daemon thread means it won’t prevent the main program from exiting — the Python process terminates even if daemon threads are still running, immediately stopping them. This is useful for background tasks (like periodic housekeeping) that shouldn’t block a clean shutdown, but it also means daemon threads should never hold resources that need graceful cleanup, since they can be terminated abruptly without warning.

Choosing the Right Tool: A Decision Framework

"""
Is the work CPU-bound (heavy computation) or I/O-bound (waiting on network/disk)?

CPU-bound  -> multiprocessing / ProcessPoolExecutor
I/O-bound, moderate scale -> threading / ThreadPoolExecutor
I/O-bound, very large scale (thousands of concurrent operations) -> asyncio
"""

asyncio deserves a brief mention here even though it’s a separate topic from threads and processes proper — it achieves concurrency for I/O-bound work using a single thread and cooperative multitasking (functions explicitly yield control at await points), which scales to a much larger number of concurrent operations than OS threads typically can, since it avoids the memory and context-switching overhead of one OS thread per concurrent task.

Combining Processes and Threads

In more sophisticated applications, I sometimes combine both: a pool of processes for CPU-bound parallelism, where each process itself uses a few threads internally to handle I/O-bound sub-tasks (like fetching data before processing it). This hybrid approach lets each tool handle the part of the workload it’s actually suited for.

import multiprocessing
from concurrent.futures import ThreadPoolExecutor
import time

def fetch_and_process(item_id):
    # Simulated I/O-bound fetch inside a CPU-bound worker process
    time.sleep(0.1)  # stand-in for a network call
    result = item_id * item_id  # stand-in for actual CPU-bound processing
    return result

def worker_batch(item_ids):
    with ThreadPoolExecutor(max_workers=4) as executor:
        return list(executor.map(fetch_and_process, item_ids))

if __name__ == "__main__":
    batches = [[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12]]
    with multiprocessing.Pool(processes=3) as pool:
        results = pool.map(worker_batch, batches)
    print(results)

Real-World Applications

Common Mistakes

Expecting threads to speed up CPU-bound work. The GIL prevents genuine parallel bytecode execution across threads in a single process — this remains the single most common Python concurrency misconception.

Forgetting to synchronize access to shared mutable state between threads, leading to race conditions that may only manifest intermittently, making them notoriously difficult to reproduce and debug.

Assuming processes share memory the way threads do. Data must be explicitly passed between processes (through Queue, Pipe, or shared memory primitives) — a plain shared Python object between processes doesn’t actually behave as shared unless explicitly set up to be.

Using non-daemon threads without properly joining them, causing a program to hang on exit while waiting for threads that never finish.

Over-engineering concurrency for a workload that doesn’t need it. Not every script benefits from added parallelism — the complexity and potential for subtle bugs is a real cost that should be weighed against genuine performance needs.

Debugging Tips

Performance Considerations

FAQs

Does the GIL mean Python can never truly run code in parallel? Within a single process, correct — the GIL limits genuine parallel execution of Python bytecode across threads. Across separate processes (via multiprocessing), true parallelism is achieved, since each process has its own independent GIL.

Why would I ever use threads if the GIL limits them? Threads remain genuinely useful for I/O-bound work, since the GIL is released during I/O waits, allowing real concurrency (overlapping wait times) even though CPU-bound bytecode execution itself isn’t parallelized.

Is asyncio the same as threading? No — asyncio achieves concurrency within a single thread through cooperative multitasking at explicit await points, rather than using multiple OS threads or the preemptive scheduling threads rely on.

How do I know if my workload is CPU-bound or I/O-bound? Profile it — if most of the time is spent waiting (network calls, disk reads, sleeping), it’s I/O-bound; if most of the time is spent in tight computational loops with the CPU constantly busy, it’s CPU-bound. Tools like cProfile or simple timing instrumentation around specific code sections make this distinction concrete rather than guessed.

Summary

Threads and processes solve fundamentally different problems in Python. Threads share memory within a single process, making communication simple but requiring careful synchronization to avoid race conditions — and Python’s GIL means threads don’t provide genuine parallelism for CPU-bound work, though they remain valuable for I/O-bound concurrency, since the GIL is released during I/O waits. Processes, each with their own memory space and interpreter, sidestep the GIL entirely, providing true parallelism for CPU-bound work at the cost of higher creation overhead and the need for explicit inter-process communication. Matching the right tool — threads, processes, or asyncio — to the actual nature of your workload (I/O-bound versus CPU-bound) is the single most important decision in writing genuinely effective concurrent Python code.

References

Exit mobile version