Skip to Content
Course content

215: Choosing Between Threading, Multiprocessing, and Asyncio

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I see this mistake happen constantly. A developer realizes their code is running slowly, remembers that they have a 12-core processor, and decides to "parallelize" the work using the threading module. They expect a linear speedup, but instead, they find the code is actually running slower than the original single-threaded version.

Here is a classic example. Let's say you're writing a script to calculate some heavy mathematical operations—in this case, checking if a large list of numbers are primes.

import threading
import time

def is_prime(n):
    if n < 2: return False
    for i in range(2, int(n**0.5) + 1):
        if n % i == 0: return False
    return True

numbers = [1000000007, 1000000009, 1000000021, 1000000033] * 10

start = time.time()
threads = []
for n in numbers:
    t = threading.Thread(target=is_prime, args=(n,))
    threads.append(t)
    t.start()

for t in threads:
    t.join()

print(f"Time taken: {time.time() - start:.2f} seconds")

The GIL is Killing Your Performance

If you run this, you'll notice something frustrating: it doesn't actually run the calculations in parallel. On most Python implementations (CPython), we have the Global Interpreter Lock (GIL). The GIL is a mutex that protects access to Python objects, preventing multiple threads from executing Python bytecodes at once.

In the code above, your threads are spending all their time fighting over the GIL. Because is_prime is CPU-bound (it's doing heavy math, not waiting for a website to load), the threads just take turns. The overhead of creating the threads and switching between them actually makes the program slower than if you'd just used a simple for loop.

Bypassing the GIL with Multiprocessing

To actually use those CPU cores, we need to stop sharing a single GIL. The solution is multiprocessing. Instead of creating threads within one process, this module spawns entirely new Python processes, each with its own Python interpreter and its own GIL.

import multiprocessing
import time

# ... (is_prime function stays the same) ...

if __name__ == "__main__":
    numbers = [1000000007, 1000000009, 1000000021, 1000000033] * 10
    
    start = time.time()
    with multiprocessing.Pool() as pool:
        pool.map(is_prime, numbers)
    
    print(f"Time taken: {time.time() - start:.2f} seconds")

Now, the OS distributes these processes across your actual CPU cores. You'll see a massive drop in execution time. However, there's a trade-off: processes are "heavier" than threads. They take more memory and communicating between them is slower because data has to be serialized (pickled) to move from one process to another.

Choosing Between Threading, Multiprocessing, and Asyncio

So, how do you decide which one to use? I usually break it down by what the program is "waiting" for.

  • Multiprocessing: Use this when you are CPU-bound. If your code is doing heavy math, image processing, or data crunching, you need separate processes to bypass the GIL.
  • Threading: Use this when you are I/O-bound but the libraries you're using are synchronous. If you're downloading five files at once using requests, threading is great. While one thread is waiting for a server to respond, the GIL is released, allowing another thread to start its request.
  • Asyncio: Use this when you have massive amounts of I/O (thousands of concurrent connections). Asyncio isn't about parallelism (running things at the same time); it's about concurrency (efficiently managing many tasks). It uses a single-threaded event loop. It's significantly more memory-efficient than threading, but it requires you to use async/await compatible libraries (like httpx or aiohttp instead of requests).

A quick rule of thumb I use: If you're fighting the CPU, go multiprocessing. If you're fighting the network or the hard drive, go asyncio. If you're stuck with a library that doesn't support async but you still need to do I/O, go threading.




📋 Practical Task

Exercise: Optimizing a Hybrid Log Processor

You have been given a script that processes a list of large log files. The current script is incredibly slow because it does two things for every file:

  1. Downloads the log file from a remote server (I/O-bound).
  2. Performs a complex Regex-based analysis to count errors (CPU-bound).
The current implementation uses threading for both, which is causing a bottleneck during the analysis phase.

Your Task: Refactor the provided skeleton code to use the correct concurrency primitive for each task. Use threading (or asyncio) for the download_log function and multiprocessing for the analyze_log function.

import time
import multiprocessing
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

# Simulated I/O bound task
def download_log(file_id):
    print(f"Downloading file {file_id}...")
    time.sleep(1) # Simulate network delay
    return f"Log data for {file_id}"

# Simulated CPU bound task
def analyze_log(data):
    print(f"Analyzing {data}...")
    # Simulate heavy CPU work
    count = 0
    for i in range(10**7):
        count += 1
    return count

def main():
    files = [1, 2, 3, 4, 5, 6, 7, 8]
    
    # TODO: 
    # 1. Use a ThreadPoolExecutor to download all files concurrently.
    # 2. Take the results from the downloads and use a ProcessPoolExecutor 
    #    to analyze the data in parallel across CPU cores.
    # 3. Print the total time taken.
    pass

if __name__ == "__main__":
    main()
Rating
0 0

There are no comments for now.

to be the first to leave a comment.