backend / async concurrency / 08_cpu_io_tasks.md

Python Concurrency Models: Processes, Threads, and Asyncio

4 interview angles 8 min read source

Python Concurrency Models: Processes, Threads, and Asyncio

Concurrency Models Overview

Python offers three primary mechanisms for concurrent programming:

  1. Processes (multiprocessing)
  2. Threads (threading)
  3. Asyncio (coroutines)

Each model has distinct characteristics that make it better suited for specific types of tasks.

Quick Comparison Table

Feature Processes Threads Asyncio
Memory Model Separate memory space Shared memory space Shared memory space
Parallelism True parallelism across CPU cores Limited by GIL No parallelism (single-threaded)
Context Switch Cost High Medium Very low
Memory Overhead High (~100MB per process) Medium (~8MB per thread) Very low (~1KB per coroutine)
Communication IPC (pipes, queues, etc.) Direct through shared memory Direct through shared memory
Startup Time Slow Medium Fast
CPU Utilization Can use multiple cores Limited by GIL Single core
I/O Efficiency Good Better Best
Best For CPU-bound tasks Mixed workloads I/O-bound tasks

Detailed Analysis: CPU-Bound Tasks

CPU-bound tasks involve heavy computation with minimal waiting for external resources. Examples include:

  • Mathematical calculations
  • Image processing
  • Machine learning
  • Data analysis and transformations

Processes for CPU-Bound Tasks

Processes are the best choice for CPU-bound tasks because:

  1. True Parallelism: Each process runs in its own Python interpreter with its own Global Interpreter Lock (GIL), allowing true parallel execution across multiple CPU cores.

  2. Performance Isolation: A CPU-intensive operation in one process doesn’t affect other processes.

  3. Full CPU Utilization: Can effectively utilize all available cores for maximum throughput.

import multiprocessing as mp
import time

def cpu_intensive_task(n):
    """A CPU-bound task: calculate sum of squares."""
    result = 0
    for i in range(n):
        result += i * i
    return result

if __name__ == "__main__":
    start_time = time.time()

    # Create a pool of worker processes
    with mp.Pool(processes=mp.cpu_count()) as pool:
        results = pool.map(cpu_intensive_task, [10000000] * 8)

    end_time = time.time()
    print(f"Multiprocessing time: {end_time - start_time:.2f} seconds")

Threads for CPU-Bound Tasks

Threads are not effective for CPU-bound tasks in Python due to:

  1. Global Interpreter Lock (GIL): The GIL allows only one thread to execute Python bytecode at a time, preventing true parallel execution.

  2. Threading Overhead: Threads add overhead without providing parallel execution benefits for CPU-bound work.

import threading
import time

def cpu_intensive_task(n):
    """A CPU-bound task: calculate sum of squares."""
    result = 0
    for i in range(n):
        result += i * i
    return result

start_time = time.time()

threads = []
for _ in range(8):
    thread = threading.Thread(target=cpu_intensive_task, args=(10000000,))
    threads.append(thread)
    thread.start()

for thread in threads:
    thread.join()

end_time = time.time()
print(f"Threading time: {end_time - start_time:.2f} seconds")

This will likely be slower than a sequential solution due to thread management overhead.

Asyncio for CPU-Bound Tasks

asyncio is poorly suited for CPU-bound tasks because:

  1. Single-Threaded: asyncio runs on a single thread and doesn’t provide parallelism.

  2. Cooperative Multitasking: CPU-bound tasks don’t naturally have yield points where other coroutines can run.

  3. Blocking the Event Loop: A CPU-intensive operation blocks the entire event loop, preventing all other coroutines from running.

import asyncio
import time

async def cpu_intensive_task(n):
    """
    A CPU-bound task: calculate sum of squares.
    This will block the event loop completely!
    """
    result = 0
    for i in range(n):
        result += i * i
    return result

async def main():
    start_time = time.time()

    # This won't run in parallel and will block the event loop
    tasks = [cpu_intensive_task(10000000) for _ in range(8)]
    await asyncio.gather(*tasks)

    end_time = time.time()
    print(f"Asyncio time: {end_time - start_time:.2f} seconds")

asyncio.run(main())

This will be very inefficient and no better than sequential execution.

Detailed Analysis: I/O-Bound Tasks

I/O-bound tasks spend most of their time waiting for input/output operations such as:

  • Network requests (HTTP, API calls, downloading files)
  • Database operations
  • File system operations
  • User input

Asyncio for I/O-Bound Tasks

asyncio is the best choice for I/O-bound tasks because:

  1. Non-Blocking I/O: Allows the program to continue execution while waiting for I/O operations.

  2. Low Overhead: Coroutines are very lightweight (1-2KB each) compared to threads or processes.

  3. High Concurrency: Can easily handle thousands of concurrent operations on a single thread.

  4. Explicit Yield Points: I/O operations have clear yield points (await) where other coroutines can run.

import asyncio
import aiohttp
import time

async def fetch_url(session, url):
    """An I/O-bound task: fetch a URL."""
    async with session.get(url) as response:
        return await response.text()

async def main():
    urls = ['http://example.com' for _ in range(100)]

    start_time = time.time()

    async with aiohttp.ClientSession() as session:
        tasks = [fetch_url(session, url) for url in urls]
        results = await asyncio.gather(*tasks)

    end_time = time.time()
    print(f"Asyncio time for 100 requests: {end_time - start_time:.2f} seconds")

asyncio.run(main())

Threads for I/O-Bound Tasks

Threads are good for I/O-bound tasks, though not as efficient as asyncio:

  1. GIL Release During I/O: The GIL is released during I/O operations, allowing other threads to run.

  2. Familiar Model: Easier to understand than asyncio for many developers.

  3. Wide Library Support: Works with synchronous libraries that don’t have async equivalents.

import threading
import requests
import time
from concurrent.futures import ThreadPoolExecutor

def fetch_url(url):
    """An I/O-bound task: fetch a URL."""
    response = requests.get(url)
    return response.text

def main():
    urls = ['http://example.com' for _ in range(100)]

    start_time = time.time()

    with ThreadPoolExecutor(max_workers=25) as executor:
        results = list(executor.map(fetch_url, urls))

    end_time = time.time()
    print(f"Threading time for 100 requests: {end_time - start_time:.2f} seconds")

if __name__ == "__main__":
    main()

Processes for I/O-Bound Tasks

Processes are least efficient for I/O-bound tasks due to:

  1. High Overhead: Each process requires separate memory space and resources.

  2. Communication Cost: Inter-process communication is more expensive than thread/coroutine communication.

  3. Resource Duplication: Each process duplicates resources like connection pools.

import multiprocessing as mp
import requests
import time

def fetch_url(url):
    """An I/O-bound task: fetch a URL."""
    response = requests.get(url)
    return response.text

def main():
    urls = ['http://example.com' for _ in range(100)]

    start_time = time.time()

    with mp.Pool(processes=25) as pool:
        results = pool.map(fetch_url, urls)

    end_time = time.time()
    print(f"Multiprocessing time for 100 requests: {end_time - start_time:.2f} seconds")

if __name__ == "__main__":
    main()

Mixed Workloads: Combining Approaches

For applications with both CPU-bound and I/O-bound components, a hybrid approach is often best:

Asyncio with Process Pool

import asyncio
import concurrent.futures
import time

def cpu_bound_task(n):
    """CPU-bound task."""
    result = 0
    for i in range(n):
        result += i * i
    return result

async def io_bound_task():
    """I/O-bound task (simulated)."""
    await asyncio.sleep(1)  # Simulate I/O operation
    return "IO task completed"

async def main():
    # Create a process pool for CPU-bound tasks
    process_pool = concurrent.futures.ProcessPoolExecutor()

    # Run CPU-bound tasks in the process pool
    loop = asyncio.get_running_loop()
    cpu_tasks = [
        loop.run_in_executor(process_pool, cpu_bound_task, 10000000)
        for _ in range(4)
    ]

    # Run I/O-bound tasks with asyncio
    io_tasks = [io_bound_task() for _ in range(20)]

    # Gather all results
    all_results = await asyncio.gather(*(cpu_tasks + io_tasks))

    process_pool.shutdown()
    return all_results

if __name__ == "__main__":
    start_time = time.time()
    results = asyncio.run(main())
    end_time = time.time()
    print(f"Hybrid approach time: {end_time - start_time:.2f} seconds")

Practical Decision Guide

Choose Processes When:

  1. CPU-Intensive Tasks: Your application needs to perform heavy computational work
  2. Maximum Performance: You need to utilize all available CPU cores
  3. Isolation Requirements: You need memory isolation between parallel units
  4. Fault Tolerance: You want one process’s crash not to affect others

Choose Threads When:

  1. Mixed Workloads: Your application has both I/O and moderate CPU work
  2. Shared Memory: You need efficient data sharing between concurrent units
  3. Working with Blocking Libraries: You’re using libraries without async support
  4. Simplicity: You want a more straightforward programming model than asyncio

Choose Asyncio When:

  1. I/O-Dominated Workloads: Your application spends most time waiting for I/O
  2. High Concurrency Needs: You need to handle thousands of concurrent operations
  3. Resource Efficiency: You need to minimize memory usage and CPU overhead
  4. Modern Libraries: You’re working with libraries that support asyncio

Performance Comparison

The following chart illustrates typical relative performance for different types of tasks:

Task Type Processes Threads Asyncio
CPU-bound 5/5 2/5 1/5
I/O-bound 2/5 4/5 5/5
Mixed 3/5 4/5 3/5

Resource Utilization

Memory Usage per Unit:

  • Process: ~100MB+
  • Thread: ~8MB
  • Coroutine: ~1KB

Maximum Practical Concurrency:

  • Processes: Tens to hundreds (limited by available memory and CPU cores)
  • Threads: Hundreds to low thousands
  • Coroutines: Tens of thousands to millions

Conclusion

For CPU-Bound Tasks:

Processes are the clear winner due to true parallelism across multiple cores. Neither threads nor asyncio can effectively parallelize CPU-intensive operations in Python due to the GIL.

For I/O-Bound Tasks:

Asyncio provides the best performance and resource efficiency for I/O-bound tasks, allowing for extremely high concurrency with minimal overhead. Threads are a good alternative when working with synchronous libraries, while processes are rarely the best choice for purely I/O-bound work.

For Real-World Applications:

Most real applications have a mix of requirements. Consider:

  • Using processes for CPU-intensive components
  • Using asyncio for I/O-intensive components
  • Using hybrid approaches for complex applications
  • Using threads when simplicity is more important than maximum performance

Remember that the best concurrency model depends on your specific application’s needs, resource constraints, and performance requirements. Sometimes the simplest approach that meets your requirements is the right choice, even if it’s not theoretically the most efficient.

Interview angle

  • “How do you tell CPU-bound from I/O-bound?” - profile. If the process sits near 100% of one core, it’s CPU-bound; if CPU is low while wall-clock time is high, it’s waiting on I/O. Guessing here sends you down the wrong optimisation path entirely.
  • “Which model for which workload?” - I/O-bound with async libraries: asyncio. I/O-bound with blocking libraries: threads. CPU-bound: processes, or a free-threaded build on 3.14. Mixed: asyncio for the I/O with CPU work offloaded to a process pool.
  • “What about a mixed workload inside one request?” - keep the loop for I/O and push the CPU segment to an executor so the loop stays responsive. Doing the CPU work inline stalls every concurrent request.
  • “Is NumPy work affected by the GIL?” - largely not; it releases the GIL around native computation, so threaded NumPy does parallelise. Check whether your hot library already does this before adding processes.