Python Parallel & Concurrent: multiprocessing, threads & the GIL

Speed up a slow loop the right way — tell CPU-bound from I/O-bound, learn why the GIL lets threads help waiting but not crunching, and use concurrent.futures to run work across threads or processes.

Programming

Run a slow Python loop in parallel the right way: tell CPU-bound from I/O-bound work, understand why the Global Interpreter Lock (GIL) means threads speed up waiting but not number-crunching, and use concurrent.futures (ThreadPoolExecutor / ProcessPoolExecutor) and multiprocessing.Pool — with the if name == “main” guard, worker counts, and the real gotchas.

Published

July 17, 2026

Modified

July 18, 2026

TipKey takeaways
  • First ask: is the loop crunching or waiting? CPU-bound work (numbers, parsing, compression) is limited by your processor; I/O-bound work (network calls, disk reads, database queries) spends its time waiting. The answer decides which tool speeds it up — get this wrong and parallelism does nothing.
  • The GIL (Global Interpreter Lock) lets only one thread run Python bytecode at a time. So threads don’t speed up CPU-bound work — but they do help I/O-bound work, because the lock is released while a thread waits on the network or disk, letting the others run.
  • CPU-bound → processes; I/O-bound → threads (or async). Multiple processes each get their own interpreter and their own GIL, so they crunch numbers on several cores at once. Threads share one interpreter — perfect for overlapping waits.
  • concurrent.futures is the clean, modern API for both. ThreadPoolExecutor and ProcessPoolExecutor share one interface — .map() for a parallel map, .submit() + as_completed() for results as they finish. Switch threads ↔︎ processes by changing one class name.
  • Process code needs an if __name__ == "__main__": guard and a script. Spawned workers re-import your module, so process-based code must live in a .py file behind that guard — otherwise it re-runs itself forever. That is why the process examples below are saved and run as scripts, not in a notebook.

Introduction

You have a loop that’s too slow. Maybe it calls an API 200 times; maybe it hashes a directory of files; maybe it runs the same heavy calculation over a list of inputs. The instinct is “run it in parallel” — but how you do that depends entirely on why it’s slow, and there are two very different answers.

There are two kinds of slow. CPU-bound work keeps the processor busy the whole time: crunching numbers, parsing text, resizing images, compressing data. It’s slow because there’s genuinely a lot to compute. I/O-bound work is slow because it waits: a network request, a disk read, a database query, a call to another service. The CPU is nearly idle — it’s just blocked until the data comes back. Telling these apart is the single most important decision in this lesson, because each is sped up by a different tool, and using the wrong one buys you nothing.

This lesson gives you the real decision and the real code. First the GIL — the one piece of Python internals you must understand, because it’s why threads help one kind of work and not the other. Then concurrent.futures, the clean high-level API for both threads and processes, with a runnable I/O demo. Then multiprocessing directly, for CPU-bound work across cores. Then a plain decision table and the real-world cautions (how many workers, and why more isn’t always better).

A note on how the code runs. The thread examples below execute live at build time, so their timings are real. The process examples are shown as short scripts you save and run yourself — not because they’re hypothetical (their output is stated and was verified), but because process-based code genuinely cannot run inside a notebook or interactive session: spawned worker processes re-import the module they came from, which needs a real script file and an if __name__ == "__main__": guard. That constraint is a core lesson here, not an inconvenience — so we teach it head-on. This lesson sits alongside Python iterators & generators (a generator feeds a worker pool one item at a time) and is followed in this series by asynchronous programming, the third tool for I/O-bound work.

The GIL: why threads don’t always help

Python has one famous constraint you need to understand before anything else: the GIL — the Global Interpreter Lock. It is a single lock inside CPython (the standard Python interpreter) that allows only one thread to execute Python bytecode at a time. Even if you start ten threads on a ten-core machine, only one of them is running Python code at any given instant; the others wait their turn for the lock.

That sounds like it makes threads useless. It doesn’t — you just have to know when they help:

  • CPU-bound work: threads do not help. If every thread wants to crunch numbers, they all need the GIL to run bytecode, so they take turns and you get no speedup — sometimes a slight slowdown from the overhead of passing the lock around. To use several cores for computation you need separate processes, each with its own interpreter and its own GIL.
  • I/O-bound work: threads help a lot. Here’s the key detail: a thread releases the GIL while it waits on I/O (a socket, a file, a time.sleep). So while thread A is blocked waiting for a network reply, threads B, C, and D can grab the lock and do their work. The waits overlap instead of stacking up end to end. Ten requests that each wait 200 ms finish in roughly one 200 ms wait, not two seconds.

So the rule falls straight out of the GIL: use threads to overlap waiting (I/O-bound), use processes to overlap computing (CPU-bound). Keep that sentence in mind for the rest of the lesson — everything else is just the API for expressing it.

Note

Recent CPython has an experimental free-threaded (“no-GIL”) build, and releases keep loosening the lock’s grip. But the default interpreter you’re almost certainly running still has the GIL, so the CPU-bound → processes rule is the safe, portable one to learn and rely on today.

concurrent.futures: the clean API

The modern, high-level way to run work in parallel is the standard-library concurrent.futures module. It gives you two interchangeable “executors” behind one identical interface:

  • ThreadPoolExecutor — a pool of threads, for I/O-bound work.
  • ProcessPoolExecutor — a pool of processes, for CPU-bound work.

Because they share the same methods, you pick your tool by choosing the class and change nothing else. The two methods you’ll use most are .map() (run a function over an iterable, like the built-in map, but in parallel) and .submit() + as_completed() (fire off individual jobs and collect results the moment each finishes).

Threads for I/O-bound work

Let’s simulate the classic I/O case: eight tasks that each wait 0.2 seconds — standing in for a network or disk call. We’ll run them serially, then through a ThreadPoolExecutor with four threads, and time both. Because a waiting thread releases the GIL, the four threads should overlap their waits and finish in roughly a quarter of the time.

import time
from concurrent.futures import ThreadPoolExecutor

def fetch(task_id):
    """Pretend to call a slow API: the work here is WAITING, not computing."""
    time.sleep(0.2)                 # the GIL is released during this wait
    return task_id, task_id * task_id

tasks = range(8)

# Serial: each 0.2s wait happens one after another.
start = time.perf_counter()
serial = [fetch(t) for t in tasks]
serial_time = time.perf_counter() - start

# Threaded: 4 waits overlap, because a waiting thread frees the GIL.
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as pool:
    threaded = list(pool.map(fetch, tasks))
threaded_time = time.perf_counter() - start

print(f"serial:   {serial_time:.2f} s")
print(f"threaded: {threaded_time:.2f} s")
print(f"speedup:  {serial_time / threaded_time:.1f}x")
print("same result:", serial == threaded)
serial:   1.66 s
threaded: 0.42 s
speedup:  4.0x
same result: True

The serial run took about 1.6 s — eight 0.2-second waits stacked end to end (8 × 0.2 = 1.6). With four threads it dropped to roughly 0.4 s: the four waits ran at the same time, so eight tasks cost about two rounds of waiting instead of eight. That’s a ~4× speedup — right in line with the four workers — and pool.map returned the results in the same order as the input, so serial == threaded is True. (Exact timings depend on your machine and OS scheduler; the shape — near-linear speedup up to the worker count — is what matters.) Crucially, this only works because the tasks wait; if fetch were crunching numbers instead of sleeping, the GIL would serialize them and the “threaded” time would be no better than serial.

ThreadPoolExecutor(max_workers=4) created a pool of four reusable threads; .map(fetch, tasks) handed each task to a free thread and gave back the results in input order; and the with block shut the pool down cleanly when we were done (it waits for everything to finish). That is the whole pattern for I/O-bound work.

submit + as_completed: results as they finish

.map() is perfect when you want results in order. Sometimes you’d rather process each result the instant it’s ready, regardless of order — say, to show progress as downloads land. For that, use .submit() to schedule each job (it returns a Future, a handle to a result that isn’t ready yet) and as_completed() to yield those futures as they finish. Here three “downloads” of different sizes take different times, so the smallest finishes first:

from concurrent.futures import ThreadPoolExecutor, as_completed

def download(name, seconds):
    time.sleep(seconds)             # bigger files "take" longer
    return name, seconds

jobs = [("small.csv", 0.1), ("medium.csv", 0.2), ("large.csv", 0.3)]

with ThreadPoolExecutor(max_workers=3) as pool:
    futures = [pool.submit(download, name, secs) for name, secs in jobs]
    for future in as_completed(futures):        # yields each as it finishes
        name, secs = future.result()            # .result() unwraps the return
        print(f"done: {name} (took {secs}s)")
done: small.csv (took 0.1s)
done: medium.csv (took 0.2s)
done: large.csv (took 0.3s)

The three jobs all started at once (three workers, three jobs), so they finished in duration ordersmall.csv, then medium.csv, then large.csvnot the order we submitted them. as_completed handed us each Future the moment its work was done, and future.result() pulled out the returned value (and would re-raise any exception the task hit, which is how you catch failures). Reach for submit + as_completed whenever you want to react to results as they arrive; reach for .map when in-order results are simpler.

Processes for CPU-bound work

Now the other half. When the work is computing, not waiting, threads can’t help — you need ProcessPoolExecutor, which runs your function in separate processes, each with its own interpreter and its own GIL, so several cores crunch at once. The API is identical to the thread pool; you swap the class name.

But process code has a hard requirement: because each worker process re-imports your module to get the function, the launching code must sit behind an if __name__ == "__main__": guard, in a real .py file. Without it, every child would re-run the launch code on import and spawn its own children — an infinite explosion of processes. That constraint is exactly why this example is a script you save and run, not a live block. Save it as cpu_pool.py and run python cpu_pool.py:

# cpu_pool.py  —  save this to a file and run:  python cpu_pool.py
import time
from concurrent.futures import ProcessPoolExecutor

def slow_square(n):
    """CPU-BOUND: a deliberately heavy calculation (no waiting)."""
    total = 0
    for i in range(10_000_000):
        total += (n * i) % 7
    return n, total

if __name__ == "__main__":                 # REQUIRED: workers re-import this file
    nums = [1, 2, 3, 4, 5, 6, 7, 8]

    start = time.perf_counter()
    serial = [slow_square(n) for n in nums]
    serial_time = time.perf_counter() - start

    start = time.perf_counter()
    with ProcessPoolExecutor(max_workers=4) as pool:
        parallel = list(pool.map(slow_square, nums))
    parallel_time = time.perf_counter() - start

    print(f"serial:   {serial_time:.2f} s")
    print(f"parallel: {parallel_time:.2f} s")
    print(f"speedup:  {serial_time / parallel_time:.1f}x")
    print("same result:", serial == parallel)

# Output on an 8-task run, 4 workers (verified on a 10-core machine):
#   serial:   2.00 s
#   parallel: 0.60 s
#   speedup:  3.3x
#   same result: True

Running this locally, the serial version took 2.00 s and the four-process pool 0.60 s — a ~3.3× speedup, spreading eight heavy computations across four cores. (You won’t get a perfect 4×: starting processes and shipping data to and from them has a cost, so real speedups fall a bit short of the worker count — more on that below.) The if __name__ == "__main__": guard is not optional here: it’s the line that lets a re-importing worker load slow_square without re-executing the launch block. Swap ProcessPoolExecutor for ThreadPoolExecutor in this exact code and the speedup vanishes — the GIL would serialize the number-crunching. That one-line contrast is the CPU-bound-vs-I/O-bound rule made concrete.

multiprocessing directly

ProcessPoolExecutor is actually a friendly wrapper over the older, lower-level multiprocessing module. You’ll still meet multiprocessing directly in existing code and when you need finer control, so it’s worth knowing its two staples: Pool (a process pool, like the executor) and the primitives for passing data between processes.

Pool.map is the direct equivalent of ProcessPoolExecutor.map — the same “run this function over this list, across processes” idea. The same __main__ guard rule applies, so again this is a script you run:

# pool_map.py  —  run:  python pool_map.py
from multiprocessing import Pool

def cube(n):
    return n ** 3

if __name__ == "__main__":
    with Pool(processes=4) as pool:
        results = pool.map(cube, [1, 2, 3, 4, 5])
    print(results)

# Output (verified):
#   [1, 8, 27, 64, 125]

Pool(processes=4) opened four worker processes; pool.map(cube, [...]) split the list across them and gathered the results back in order — printing [1, 8, 27, 64, 125]. For an embarrassingly-parallel job (each item independent), Pool.map is often all you need.

Separate processes don’t share memory, so to get data in and out you pass it explicitly. The simplest channel is a Queue — a process-safe pipe one process puts items into and another gets them out of:

# queue_demo.py  —  run:  python queue_demo.py
from multiprocessing import Process, Queue

def worker(items, out):
    for x in items:
        out.put(x * 10)             # push results back through the queue

if __name__ == "__main__":
    q = Queue()
    p = Process(target=worker, args=([1, 2, 3], q))
    p.start()                       # launch the child process
    p.join()                        # wait for it to finish
    print([q.get() for _ in range(3)])

# Output (verified):
#   [10, 20, 30]

The child Process ran worker, pushing 10, 20, 30 into the shared Queue; the parent joined (waited for it) then get-ed the three results back — [10, 20, 30]. Queue is the standard way to fan results back from workers; a Pipe does the same for a simple two-process back-and-forth. The catch to remember: anything you send between processes must be picklable (serializable), because it’s serialized to cross the process boundary — a lambda, an open file handle, or a database connection can’t make the trip (see Common issues).

Which tool? CPU-bound vs I/O-bound

Here is the whole decision on one screen. Start by asking what the slow work actually does, then read across:

Your work is… Example Bottleneck Use Why
CPU-bound Number-crunching, parsing, image/video processing, compression, ML feature computation The processor ProcessesProcessPoolExecutor or multiprocessing.Pool Each process has its own GIL, so cores compute in parallel
I/O-bound, moderate number of tasks A few dozen API calls, file reads, DB queries Waiting on I/O ThreadsThreadPoolExecutor A waiting thread releases the GIL, so waits overlap
I/O-bound, very many tasks Thousands of concurrent network requests Waiting on I/O async (asyncio) One thread juggles thousands of waits with far less overhead than thread-per-task
Embarrassingly parallel map (each item independent) Apply f to every item of a big list Either executor.map(f, items) One line; picks threads or processes by which executor you choose

Two quick reads of the table. If you’re not sure whether a task is CPU- or I/O-bound, ask: while it runs, is the CPU pegged, or mostly idle? Pegged → CPU-bound → processes. Idle-and-waiting → I/O-bound → threads or async. And for the very-high-concurrency I/O case (thousands of simultaneous connections), threads start to cost too much memory and scheduling overhead — that’s where asynchronous programming takes over, the third tool in this series’ concurrency trio, covered in the next lesson.

In the real world

A few things decide whether parallelism actually pays off:

  • More workers is not always faster. Every extra process costs memory and startup time, and every item shipped to a process must be serialized and sent. Past a point — usually around your core count for CPU-bound work — adding workers just adds overhead and slows things down. For I/O-bound work you can profitably run more threads than cores (they’re mostly waiting), but even there, thousands of threads thrash. Start near your core count and measure.
  • Size your pool to the machine. Use os.cpu_count() to discover how many cores you have and pick a sensible default, rather than hard-coding a number that’s wrong on the next machine:
import os

cores = os.cpu_count()
print("cores available:", cores)
print("a reasonable CPU-bound pool size:", cores)
cores available: 10
a reasonable CPU-bound pool size: 10

This machine reports the count above; a common default is one worker per core for CPU-bound work (leave a core free if the machine must stay responsive). The pool executors already default to a machine-appropriate size if you pass no max_workers, which is often the right call.

  • Chunk small tasks. If each task is tiny, the per-task overhead of shipping it to a process can dwarf the work itself. Both ProcessPoolExecutor.map and Pool.map take a chunksize argument that batches many items into each dispatch — e.g. pool.map(f, items, chunksize=100) — cutting the coordination cost dramatically for large inputs of cheap work. For a handful of heavy tasks, leave it at the default.
  • Parallelism has a floor of usefulness. For a loop that runs in milliseconds, the cost of starting threads or processes will exceed anything you save. Parallelize work that’s genuinely slow — seconds, not microseconds — and always measure before and after rather than assuming a speedup.
NoteThis lesson is reproducible

Every result above was produced by the code shown. The thread examples executed at build time (if they render, the code works), so you can copy any {python} block and run it as-is. The process examples are complete scripts — save each to a .py file and run it with python <file>.py; their stated output was verified the same way. There is no in-browser sandbox on this page (real threads and processes need a real Python runtime), so the workflow is copy-and-run-locally, not edit-and-re-render.

The same idea in R

Working across both languages? R expresses the same CPU-bound-parallelism idea through its parallel package and the modern future / furrr stack. Where Python writes ProcessPoolExecutor().map(f, xs), R writes future::plan(multisession) once to spin up worker sessions, then furrr::future_map(xs, f) — a drop-in parallel version of purrr::map(). Base R offers parallel::mclapply() (fork-based, on macOS/Linux) and parallel::parLapply() (cluster-based, cross-platform) as the direct analogues of a process pool. The mental model is identical: independent tasks fan out to worker processes that each run their own R session, then results come back. R sidesteps Python’s GIL question — it has no equivalent single lock — but pays the same process-startup and data-transfer costs, so the same “measure, and don’t over-parallelize tiny tasks” cautions apply. See the Programming pillar for the R side.

🟢 With an AI agent

Got a slow loop and not sure whether it’s CPU-bound or I/O-bound — or which pool to reach for? Paste it and ask Prova “is this loop CPU-bound or I/O-bound, and rewrite it with the right concurrent.futures pool”. It reads what the loop actually does, names the bottleneck, and writes the ThreadPoolExecutor (waiting) or ProcessPoolExecutor (crunching) version — with the if __name__ == "__main__": guard where processes need it. Then copy the code it gives you, run it on your own machine, and time both — because the honest test of “did this actually get faster?” is the stopwatch, not a plausible explanation. The runtime is the judge. Ask Prova →

Common issues

You forgot if __name__ == "__main__": and processes multiply (or crash). This is the classic multiprocessing bug. Spawned worker processes re-import your module to find the target function; if your pool-launching code isn’t guarded, each fresh import re-runs it, spawning yet more workers — a runaway explosion, or on some platforms a RuntimeError about starting a process before the current one has finished bootstrapping. The fix is the guard: put every line that starts processes (creating the pool, calling .map) inside if __name__ == "__main__":, in a real .py script. Plain function definitions stay outside it; only the launch code goes in. (This is exactly why the process examples here are scripts you run, not notebook cells.)

Threads didn’t speed up your CPU-bound loop at all. If you wrapped a number-crunching loop in a ThreadPoolExecutor and saw no improvement — or it got slightly slower — that’s the GIL working as designed: only one thread runs Python bytecode at a time, so pure computation can’t overlap. Switch to ProcessPoolExecutor (or multiprocessing.Pool) to use multiple cores. Threads are for work that waits (I/O), not work that computes.

PicklingError / Can't pickle … when sending work to a process pool. Data crossing a process boundary is serialized with pickle, so everything you pass to (or return from) a worker must be picklable. Lambdas, locally-defined (nested) functions, open file handles, database connections, and many custom objects are not. The fix: use a top-level, named function as the worker target (not a lambda), and pass plain data (numbers, strings, lists, dicts, DataFrames) rather than live resources — open the file or connection inside the worker instead of passing it in.

You added more workers and it got slower. Beyond your core count (for CPU-bound work), extra processes only add memory pressure, context-switching, and data-transfer overhead — there are no more cores to run them. Size the pool to os.cpu_count() (or a bit less), use chunksize to batch tiny tasks, and always measure: parallelism helps genuinely slow work, and can hurt fast loops where the setup cost dominates.

Frequently asked questions

Threading runs multiple threads inside one process, sharing the same memory and the same interpreter — and therefore the same GIL, so only one thread executes Python bytecode at a time. That makes threads great for I/O-bound work (a waiting thread releases the GIL so others run) but useless for CPU-bound work. Multiprocessing runs multiple separate processes, each with its own memory and its own interpreter and GIL, so they genuinely compute in parallel across cores — the right tool for CPU-bound work. The trade-off: processes cost more to start and can’t share memory directly (data is serialized to pass between them), while threads are lightweight but can’t parallelize computation.

The GIL is a single lock in CPython (the standard Python interpreter) that permits only one thread to run Python bytecode at a time. It exists because it makes the interpreter’s memory management simpler and single-threaded code faster. Its consequence: threads can’t run Python computation in parallel, so CPU-bound work sees no speedup from threading. It does not block I/O — a thread releases the GIL while waiting on the network or disk — so I/O-bound work still parallelizes well with threads. To parallelize computation you use multiple processes (each has its own GIL). Recent Python has an experimental free-threaded build that removes the GIL, but the default interpreter still has it.

Use multiprocessing (or ProcessPoolExecutor) when your work is CPU-bound — number-crunching, parsing, image or video processing, compression, heavy feature computation — and you want to spread it across multiple cores. Because each process has its own interpreter and GIL, they compute truly in parallel. Don’t reach for it when the work is I/O-bound (waiting on network or disk): there, threads or async are lighter and just as effective, and processes only add startup and data-transfer overhead. Also skip it for very fast loops — the cost of starting processes and shipping data can exceed the time you save.

They share an identical concurrent.futures interface, so the choice is purely about the workload. Use ThreadPoolExecutor for I/O-bound work — API calls, file reads, database queries — where tasks spend their time waiting; threads let those waits overlap. Use ProcessPoolExecutor for CPU-bound work — computation that keeps the processor busy — because separate processes each get their own GIL and run on different cores. Rule of thumb: waiting → threads, computing → processes. Since the API is the same (.map, .submit, as_completed), you can often switch between them by changing one class name and re-timing. Note that ProcessPoolExecutor needs the if __name__ == "__main__": guard and a script context.

Yes — that’s the whole point. The GIL is per-interpreter, and each process launched by multiprocessing (or ProcessPoolExecutor) has its own Python interpreter and therefore its own GIL. So multiple processes run Python bytecode genuinely in parallel across multiple cores, which is why multiprocessing is the tool for CPU-bound work. Threads, by contrast, share one interpreter and one GIL, so they cannot. The cost of stepping around the GIL this way is that processes don’t share memory — data passed between them is serialized (pickled), and there’s a startup cost per process — so multiprocessing pays off for genuinely heavy computation, not tiny tasks.

Test your understanding

You have two slow jobs. For each, decide whether it’s CPU-bound or I/O-bound, choose threads or processes, and write the concurrent.futures call.

Job A. check_url(url) sends an HTTP request and returns the status code. You have 50 URLs to check and each request takes about 0.3 s (mostly waiting for the server).

Job B. count_primes(n) counts the prime numbers below n with a plain loop. You have a list of 8 large n values, and each call keeps the CPU busy for a couple of seconds.

Task 1. For each job, name the bottleneck (CPU-bound or I/O-bound) and the right executor. Task 2. Write the concurrent.futures call for each, using .map. Remember which one needs an if __name__ == "__main__": guard.

Ask what each job does while it runs. check_url mostly waits on the network → I/O-bound → threads. count_primes keeps the CPU pegged → CPU-bound → processes. The thread version runs anywhere; the process version must live in a script behind the __main__ guard because workers re-import the module.

Task 1. Job A is I/O-bound (it waits on the network) → ThreadPoolExecutor. Job B is CPU-bound (it computes) → ProcessPoolExecutor.

Task 2.

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

# Job A — I/O-bound → threads. Runs fine anywhere.
with ThreadPoolExecutor(max_workers=10) as pool:
    statuses = list(pool.map(check_url, urls))     # 50 URLs, waits overlap

# Job B — CPU-bound → processes. MUST be in a script under the guard:
if __name__ == "__main__":
    with ProcessPoolExecutor(max_workers=8) as pool:
        counts = list(pool.map(count_primes, n_values))

Job A overlaps 50 network waits across ten threads (the GIL is released during each wait), turning ~15 s of serial waiting into a couple of seconds. Job B spreads eight heavy computations across processes so several cores run at once — and it needs ProcessPoolExecutor, because threads would be serialized by the GIL. Swapping the executor class between the two jobs would break both: threads wouldn’t speed up the primes, and processes would add needless overhead (and pickling) to the URL checks.

Quick check. You have a loop that computes SHA-256 hashes of a million small strings — the CPU runs flat out the whole time. You wrap it in a ThreadPoolExecutor with eight workers and see no speedup. Which tool actually speeds it up, and why?

Processes — a ProcessPoolExecutor (or multiprocessing.Pool). Hashing is CPU-bound: it keeps the processor busy with no waiting. Because of the GIL, only one thread can run Python bytecode at a time, so eight threads take turns and give no speedup. Separate processes each have their own interpreter and GIL, so they hash on multiple cores in parallel — the real speedup. (Threads would only help if the work waited on I/O.)

Conclusion

Parallelizing Python starts with one question: is the work crunching or waiting? CPU-bound work is limited by the processor and needs processes (ProcessPoolExecutor or multiprocessing.Pool) to use multiple cores; I/O-bound work is limited by waiting and is sped up by threads (ThreadPoolExecutor) — or, at very high concurrency, by async. The reason is the GIL: only one thread runs Python bytecode at a time, so threads can overlap waits but not computation. concurrent.futures gives you both behind one clean interface — .map for ordered results, .submit + as_completed for results as they finish — and you switch tools by changing the executor class. Remember the two rules that trip everyone: process code needs an if __name__ == "__main__": guard in a real script (workers re-import the module), and whatever crosses a process boundary must be picklable. And always measure — parallelism repays genuinely slow work, and can cost you on fast loops.

Reuse

Citation

BibTeX citation:
@online{2026,
  author = {},
  title = {Python {Parallel} \& {Concurrent:} Multiprocessing, Threads
    \& the {GIL}},
  date = {2026-07-17},
  url = {https://www.datanovia.com/learn/programming/python-intermediate/parallel-concurrent-python},
  langid = {en}
}
For attribution, please cite this work as:
“Python Parallel & Concurrent: Multiprocessing, Threads & the GIL.” 2026. July 17. https://www.datanovia.com/learn/programming/python-intermediate/parallel-concurrent-python.