backend / async concurrency / 02_concurrency_vs_parallelism.md

Concurrency vs. Parallelism in Python Async Programming

3 interview angles 5 min read source

Concurrency vs. Parallelism in Python Async Programming

Introduction

Understanding the difference between concurrency and parallelism is crucial for effective Python async programming. While these terms are sometimes used interchangeably, they represent distinct programming paradigms with different implementations and use cases in Python.

Concurrency

Definition: Concurrency is about dealing with multiple tasks at the same time, but not necessarily executing them simultaneously.

Key Characteristics of Concurrency in Python

  • Single-threaded model: Python’s async model primarily operates in a single thread.
  • Task switching: Tasks yield control when waiting for I/O or other operations, allowing other tasks to run.
  • Cooperative multitasking: Tasks voluntarily give up control (cooperative rather than preemptive).
  • Event loop: Central to async Python - it manages and schedules tasks.

Python Implementation: asyncio

import asyncio

async def task1():
    print("Task 1 starting")
    await asyncio.sleep(1)  # Simulating I/O operation
    print("Task 1 completed")

async def task2():
    print("Task 2 starting")
    await asyncio.sleep(0.5)  # Simulating I/O operation
    print("Task 2 completed")

async def main():
    # Run tasks concurrently
    await asyncio.gather(task1(), task2())

asyncio.run(main())

Output:

Task 1 starting
Task 2 starting
Task 2 completed
Task 1 completed

When to Use Concurrency

  • I/O-bound tasks (network requests, file operations)
  • Many small tasks that spend time waiting
  • When you need to maintain many connections (web servers, chat applications)
  • When you want to avoid the overhead of multiple threads/processes

Parallelism

Definition: Parallelism is about executing multiple tasks simultaneously, truly at the same time.

Key Characteristics of Parallelism in Python

  • Multiple cores: Requires multiple CPU cores to achieve true parallelism.
  • Multiple processes: Due to Python’s Global Interpreter Lock (GIL), true parallelism typically requires multiple processes.
  • Independent execution: Tasks execute independently with their own resources.
  • Resource isolation: Each process has its own memory space.

Python Implementation: multiprocessing

from multiprocessing import Process
import time

def task1():
    print("Task 1 starting")
    time.sleep(1)  # CPU-bound simulation
    print("Task 1 completed")

def task2():
    print("Task 2 starting")
    time.sleep(1)  # CPU-bound simulation
    print("Task 2 completed")

if __name__ == "__main__":
    # Create processes
    p1 = Process(target=task1)
    p2 = Process(target=task2)
    
    # Start processes
    p1.start()
    p2.start()
    
    # Wait for processes to complete
    p1.join()
    p2.join()

Output:

Task 1 starting
Task 2 starting
Task 1 completed
Task 2 completed

When to Use Parallelism

  • CPU-bound tasks (calculations, data processing)
  • Tasks that benefit from utilizing multiple cores
  • When execution speed is critical
  • When tasks are independent and don’t need to share state

The Global Interpreter Lock (GIL)

The GIL is a mutex that protects access to Python objects, preventing multiple threads from executing Python bytecode simultaneously. This has important implications:

  • Threading in Python (threading module) provides concurrency but not parallelism for CPU-bound tasks
  • For true parallelism in CPU-bound tasks, you need to use multiprocessing or alternative Python implementations

Hybrid Approaches

Python allows combining both paradigms for optimal performance:

import asyncio
from concurrent.futures import ProcessPoolExecutor

# CPU-bound function to run in a separate process
def cpu_bound_task(n):
    # Simulating CPU-intensive calculation
    result = sum(i * i for i in range(n))
    return result

# Async function that delegates CPU-bound work to process pool
async def process_data(executor, data):
    print(f"Processing chunk: {data}")
    loop = asyncio.get_running_loop()
    result = await loop.run_in_executor(executor, cpu_bound_task, data)
    return result

async def main():
    # Create a process pool
    with ProcessPoolExecutor() as executor:
        # Concurrent execution of multiple CPU-bound tasks
        tasks = [process_data(executor, i * 1000000) for i in range(1, 5)]
        results = await asyncio.gather(*tasks)
        print(f"Results: {results}")

if __name__ == "__main__":
    asyncio.run(main())

Comparison Table

Feature Concurrency (asyncio) Parallelism (multiprocessing)
Execution Single-threaded, tasks take turns Multi-process, tasks run simultaneously
Best for I/O-bound tasks CPU-bound tasks
Memory Shared memory space Separate memory spaces
Communication Direct variable access Inter-process communication required
Overhead Low Higher (process creation)
Scalability Limited by single CPU Scales with available CPUs
Complexity Requires async/await syntax More straightforward imperative code

Common Mistakes and Best Practices

Mistakes to Avoid

  1. Blocking the event loop: Avoid CPU-bound operations in async code

    # BAD: Will block the event loop
    async def bad_practice():
        # This calculation blocks everything
        result = sum(i * i for i in range(10000000))
        return result
  2. Misusing asyncio with CPU-bound tasks

    # BAD: No benefit from asyncio here
    async def cpu_bound_tasks():
        await asyncio.gather(
            cpu_intensive_task1(),
            cpu_intensive_task2()
        )  # These don't run in parallel
  3. Using threads for CPU-bound parallelism

    # INEFFECTIVE: GIL prevents true parallelism
    from threading import Thread
    
    def cpu_intensive():
        # This won't benefit from threading
        result = sum(i * i for i in range(10000000))
    
    threads = [Thread(target=cpu_intensive) for _ in range(4)]
    for t in threads:
        t.start()

Best Practices

  1. Use asyncio for I/O-bound workloads

    # GOOD: Perfect for HTTP requests
    async def fetch_urls(urls):
        async with aiohttp.ClientSession() as session:
            tasks = [fetch_url(session, url) for url in urls]
            return await asyncio.gather(*tasks)
  2. Use ProcessPoolExecutor for CPU-bound tasks in async code

    # GOOD: Offloads CPU work to processes
    async def process_data_chunks(chunks):
        with ProcessPoolExecutor() as executor:
            loop = asyncio.get_running_loop()
            tasks = [
                loop.run_in_executor(executor, process_chunk, chunk)
                for chunk in chunks
            ]
            return await asyncio.gather(*tasks)
  3. Consider third-party libraries

    • Dask: For parallel computing
    • uvloop: Faster asyncio event loop implementation
    • trio: Alternative async framework with focus on usability

Conclusion

Understanding when to use concurrency (asyncio) versus parallelism (multiprocessing) in Python is essential for writing efficient code:

  • Use concurrency (asyncio) when your application is I/O-bound
  • Use parallelism (multiprocessing) when your application is CPU-bound
  • Consider hybrid approaches for complex applications with mixed workloads

Python provides powerful tools for both paradigms, and selecting the right approach depends on understanding your workload characteristics and performance requirements.

Interview angle

  • “Concurrency versus parallelism?” - concurrency is dealing with many things at once (interleaved progress, one core suffices); parallelism is doing many things at once (genuinely simultaneous, needs multiple cores). Async gives concurrency without parallelism; multiprocessing gives both.
  • “Can Python do both?” - yes, in one process. asyncio for concurrent I/O, and offload CPU work to a process pool via run_in_executor. As of 3.14 free-threaded builds add real thread parallelism, and subinterpreters are a fourth option. See 01_gil.md.
  • “Why isn’t concurrency enough for CPU work?” - interleaving doesn’t add compute. If the bottleneck is arithmetic rather than waiting, you need more cores actually executing, which means processes or a free-threaded build.