DEV Community

qing
qing

Posted on

Master Python: 3 Key Differences

Python multiprocessing vs threading: When to Use Each

You’ve probably written a script that feels slow, watched your CPU sit at 20% utilization while waiting for a network response, or seen a data-processing job crawl for hours. The culprit isn’t always bad code—it’s often the wrong concurrency model. In Python, choosing between multiprocessing and threading isn’t just a technical detail; it’s the difference between your app running at 1x speed or 8x speed on a multi-core machine.

The golden rule is simple but powerful: If your program is mostly waiting, use threads. If it’s mostly working, use processes. Let’s break down why this matters, when to pick each, and how to implement them today.

The Core Difference: Concurrency vs. Parallelism

Python’s concurrency story is dominated by one notorious feature: the Global Interpreter Lock (GIL). This lock ensures that only one thread executes Python bytecode at a time within a single process.

  • Threading provides concurrency: multiple threads share the same process and memory, but they take turns executing due to the GIL. They’re great for hiding latency (e.g., waiting for APIs) but useless for CPU-heavy work.
  • Multiprocessing provides parallelism: each process runs its own Python interpreter with its own GIL, allowing true parallel execution across CPU cores [4][12].

Think of it like a kitchen:

  • Threads = one chef (the GIL) juggling multiple tasks, switching between them quickly.
  • Processes = multiple chefs, each with their own stove, working simultaneously.

When to Use Threading: The “Waiting” Workload

Use threading when your program spends most of its time waiting for external resources. This includes:

  • Web scraping: Fetching hundreds of pages at once [4]
  • Networking: Handling concurrent requests in a server [4]
  • File operations: Reading/writing multiple large files [4]
  • API calls: Waiting for responses from external services

Threads shine here because the GIL releases while the thread is blocked on I/O, letting other threads run. You’re not speeding up computation—you’re hiding latency.

Practical Example: Concurrent API Calls

import threading
import time
import requests

def fetch_data(url):
    print(f"Fetching {url}")
    response = requests.get(url)
    print(f"Got {len(response.text)} bytes from {url}")

urls = [
    "https://api.github.com/users/octocat",
    "https://api.github.com/users/torvalds",
    "https://api.github.com/users/microsoft",
]

start = time.time()
threads = []

for url in urls:
    t = threading.Thread(target=fetch_data, args=(url,))
    t.start()
    threads.append(t)

for t in threads:
    t.join()

print(f"Total time: {time.time() - start:.2f}s")
Enter fullscreen mode Exit fullscreen mode

This script fetches three URLs concurrently. Without threads, it would take ~3 seconds (one after another). With threads, it takes ~1 second because the waits overlap [3][4].

When to Use Multiprocessing: The “Working” Workload

Use multiprocessing when your program is CPU-bound: crunching numbers, processing images, training ML models, or transforming large datasets [1][4].

In these scenarios, threads are a trap. The GIL prevents true parallelism, so you’re just adding overhead without speedup. Multiprocessing bypasses the GIL entirely by spawning independent processes, each with its own interpreter and memory space [4][5].

Common CPU-Bound Use Cases

Task Why Multiprocessing?
Data crunching Leverages all CPU cores for max speed [4]
Machine learning training Parallelizes heavy matrix operations [4]
Image/video processing Applies filters to multiple files in parallel [4]
Numerical simulations Avoids GIL bottleneck on intensive math [6]

Practical Example: CPU-Heavy Data Processing

import multiprocessing
import time
import math

def process_chunk(numbers):
    return [math.sqrt(n) * math.log(n) for n in numbers]

# Generate a large dataset
data = list(range(10_000_000))
chunks = [data[i:i+2_500_000] for i in range(0, len(data), 2_500_000)]

start = time.time()

# Multiprocessing: true parallelism
with multiprocessing.Pool(processes=4) as pool:
    results = pool.map(process_chunk, chunks)

print(f"Multiprocessing time: {time.time() - start:.2f}s")

# Threading: no speedup (GIL bottleneck)
start = time.time()
threads = []
thread_results = []

for chunk in chunks:
    t = threading.Thread(target=lambda c=chunk: thread_results.append(process_chunk(c)))
    t.start()
    threads.append(t)

for t in threads:
    t.join()

print(f"Threading time: {time.time() - start:.2f}s")
Enter fullscreen mode Exit fullscreen mode

On a 4-core machine, multiprocessing will be 3–4x faster than threading for this CPU-heavy task. Threading barely improves performance because the GIL forces threads to serialize execution [4][6].

Key Trade-offs to Consider

Factor Threading Multiprocessing
Parallelism ❌ No (GIL limits to one thread) ✅ Yes (true parallelism)
Memory usage Low (shared memory) High (each process has own copy) [8]
Startup cost Fast (lightweight) Slow (process spawning) [9]
I/O performance ✅ Excellent (hides latency) ✅ Good (but higher overhead) [8]
CPU performance ❌ Poor (no speedup) ✅ Excellent (uses all cores) [4]

Multiprocessing has higher overhead because processes don’t share memory and must serialize data (via pickle) to communicate [6][8]. But for CPU-bound tasks, that cost is worth the massive speedup.

Actionable Checklist: What to Use TODAY

Before you write your next concurrent script, ask:

  1. Is my task CPU-bound or I/O-bound?

    • CPU-bound (math, image processing, ML) → multiprocessing [3][4]
    • I/O-bound (network, files, APIs) → threading [3][4]
  2. How many cores do I have?

    • For CPU work, match processes to os.cpu_count() [4]
    • For I/O, you can use more threads than cores (e.g., n_threads = m * n_cores) [8]
  3. Do I need shared memory?

    • If yes, threads are easier (but watch for race conditions)
    • If no, processes are safer and faster for CPU work [5]
  4. Can I use concurrent.futures?

    • ThreadPoolExecutor for I/O tasks [2]
    • ProcessPoolExecutor for CPU tasks [2]

Don’t Just Guess: Benchmark Your Choice

The only way to be sure is to benchmark. Run side-by-side tests with identical workloads, measuring:

  • Throughput (tasks/sec)
  • p95 latency
  • CPU% and memory per worker
  • Cost per 1k tasks (instance hours × RAM) [2]

Use ThreadPoolExecutor and ProcessPoolExecutor from concurrent.futures to keep code identical except for the concurrency model [2].

Final Thoughts: Pick the Right Tool, Not the Trendy One

Python’s concurrency models aren’t interchangeable. Threading hides latency; multiprocessing increases speed. If you’re scraping websites, use threads. If you’re training a model or processing images, use processes.

The moment you misalign your model with your workload, you’ll see slowdowns, wasted resources, and frustration. But when you match them correctly? Your app will feel instant, your CPU will hum at 100%, and your users will thank you.

Try this today: Take one slow script in your project. Ask: “Is it waiting or working?” Then swap to the right executor. You’ll likely see a 2–4x performance boost immediately.

What’s your next concurrency challenge? Drop it in the comments—and let’s debug it together. 🚀


If you found this helpful, consider buying me a coffee ☕ — it keeps these articles coming!

Also check out my AI tools collection: AI 次元世界 — free AI tools for developers.


💡 Related: **Content Creator Ultimate Bundle (Save 33%)* — $29.99*


喜欢这篇文章?关注获取更多Python自动化内容!

Top comments (0)