Concurrency & Parallelism

When writing programs that perform multiple tasks, understanding how your code handles multiple operations simultaneously is crucial. Two foundational concepts in this domain are concurrency and parallelism. While often used interchangeably, they represent different ways to manage and execute multiple tasks — and knowing the difference can help you write faster, more efficient Python code.

In this lesson, we’ll explore what concurrency and parallelism mean, how they’re implemented in Python, and practical examples to get you started with both.

What is Concurrency?

Concurrency is about dealing with multiple tasks at the same time. It means your program can start a task, switch to another before the first finishes, and manage several tasks by interleaving their execution. This is especially useful when tasks spend time waiting (e.g., for user input or network responses).

Think of concurrency like a single chef juggling several cooking orders: chopping vegetables for one dish, then stirring a sauce for another, switching back and forth. Although the chef works on one task at a time, the switching happens so fast it feels as if multiple tasks progress simultaneously.

What is Parallelism?

Parallelism means literally doing multiple tasks at the exact same time. This generally requires multiple processors or cores. Here, the chef analogy shifts to a kitchen with several chefs, each working on different dishes simultaneously.

Parallelism can significantly speed up compute-heavy tasks because they truly run together rather than being interleaved.

💡 Key Insight:

Concurrency is about structure — organizing tasks to be managed together, while parallelism is about execution — running tasks simultaneously.

Concurrency vs Parallelism
AspectConcurrencyParallelism
DefinitionManaging multiple tasks by interleaving themExecuting multiple tasks simultaneously
Requires multiple CPU cores?No (can run on one core)Yes (multiple cores)
Best forI/O-bound tasks (e.g., network, file I/O)CPU-bound tasks (e.g., heavy computation)
ExampleAsync web server handling many clientsImage processing on multiple cores

Python’s Approach to Concurrency

Python offers several tools to handle concurrency, especially for I/O-bound tasks:

  • Threading: Allows multiple threads within a process to run seemingly simultaneously by interleaving their execution.
  • Asyncio: Uses async/await syntax to write asynchronous code that cooperatively yields control to the event loop.

However, due to Python’s Global Interpreter Lock (GIL), multiple threads in the same process cannot execute Python bytecode simultaneously. This limits threading for CPU-bound tasks but is still helpful for I/O-bound tasks.

Threading Example: Downloading Multiple URLs

Let’s see a simple example using threading to fetch several web pages concurrently. The program can start fetching one URL, then switch to another while waiting for the network response.

📌 Deep Dive: Threading for I/O-bound Concurrency

PYTHON
import threading
import requests

urls = [
    'https://www.python.org',
    'https://www.github.com',
    'https://www.stackoverflow.com',
]

def fetch(url):
    print(f"Starting {url}")
    response = requests.get(url)
    print(f"Completed {url} with status: {response.status_code}")

threads = []
for url in urls:
    thread = threading.Thread(target=fetch, args=(url,))
    threads.append(thread)
    thread.start()

for thread in threads:
    thread.join()

print("All downloads completed.")
Output
Starting https://www.python.org Starting https://www.github.com Starting https://www.stackoverflow.com Completed https://www.python.org with status: 200 Completed https://www.github.com with status: 200 Completed https://www.stackoverflow.com with status: 200 All downloads completed.

Python’s Approach to Parallelism

To truly run Python code in parallel on multiple CPU cores, Python offers the multiprocessing module, which spawns separate processes each with its own Python interpreter and memory space. This bypasses the GIL and allows CPU-bound operations to fully utilize multiple cores.

However, inter-process communication is more expensive and complex compared to threads, so it’s best suited for tasks that benefit greatly from parallel execution.

Multiprocessing Example: Computing Fibonacci Numbers in Parallel

Here’s a simple example using multiprocessing to compute Fibonacci numbers in parallel processes.

📌 Deep Dive: Multiprocessing for CPU-bound Parallelism

PYTHON
from multiprocessing import Pool

def fib(n):
    if n <= 1:
        return n
    else:
        return fib(n-1) + fib(n-2)

numbers = [30, 32, 34, 36]

with Pool() as pool:
    results = pool.map(fib, numbers)

print(results)
Output
[832040, 2178309, 5702887, 14930352]

When to Use What?

Choosing concurrency or parallelism depends on the problem you want to solve:

  • Use concurrency (threads or asyncio): When your program waits for external events like network responses, file I/O, or user input. This keeps your program responsive and efficient without needing multiple CPU cores.
  • Use parallelism (multiprocessing): When your program performs CPU-intensive calculations that can be split across cores to reduce total compute time.

⚠️ Beware of GIL Limitations

Python’s Global Interpreter Lock means that only one thread runs Python bytecode at a time, limiting true parallel execution in threads. Multiprocessing avoids this by using separate processes, but it comes with its own complexity and overhead.

Architecture of Concurrency & Parallelism
Architecture of Concurrency & Parallelism

Advanced Concurrency with Asyncio

Beyond threading, Python’s asyncio library provides a powerful way to write asynchronous code using async and await. This model cooperatively schedules tasks in a single thread, optimized for I/O-bound workloads.

Example: Fetching web pages asynchronously with asyncio and aiohttp:

📌 Deep Dive: Asyncio for Asynchronous I/O

PYTHON
import asyncio
import aiohttp

urls = [
    'https://www.python.org',
    'https://www.github.com',
    'https://www.stackoverflow.com',
]

async def fetch(session, url):
    print(f"Starting {url}")
    async with session.get(url) as response:
        text = await response.text()
        print(f"Completed {url} with status: {response.status}")
        return text

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

asyncio.run(main())
Output
Starting https://www.python.org Starting https://www.github.com Starting https://www.stackoverflow.com Completed https://www.python.org with status: 200 Completed https://www.github.com with status: 200 Completed https://www.stackoverflow.com with status: 200

This asynchronous style allows your program to efficiently handle thousands of connections or I/O operations concurrently without the overhead of threads or processes.

Summary

Concurrency and parallelism are key concepts for writing efficient Python programs that handle multiple tasks. Understanding their differences helps you select the right tool:

  • Concurrency manages multiple tasks in overlapping time frames (good for I/O-bound tasks).
  • Parallelism performs multiple tasks simultaneously on multiple CPU cores (ideal for CPU-bound tasks).

Python provides various modules and approaches for each, including threading, asyncio, and multiprocessing. Experiment with these tools to understand their behavior and benefits.

With these foundations, you’re ready to explore more complex concurrency patterns, optimize performance, and build responsive applications.