algocat.tech
Study notes
Mar 9, 2026 14 min read

Concurrency vs. Parallelism: Essential Concepts for System Design (Deep dive with Python)

Concurrency vs. parallelism explained with Python: how the GIL, threading, asyncio, and multiprocessing work, and when to choose each for system design.

Introduction

Concurrency and parallelism are fundamental concepts in system design, particularly in multi-threaded and distributed applications. Despite being related, they address different aspects of software execution. Many young engineers struggle to differentiate between them, leading to inefficient designs or incorrect expectations about performance improvements.

This article will explain Concurrency vs. Parallelism in detail, using Python as a practical demonstration language. We'll explore Python's Global Interpreter Lock (GIL), threading, asyncio, multiprocessing, and Joblib, focusing on when to use each approach effectively. We'll also cover common pitfalls, best practices, and real-world scenarios to solidify your understanding.


1. Understanding Concurrency and Parallelism: The Core Concepts

1.1 What is Concurrency? (Managing Multiple Tasks Simultaneously)

Concurrency refers to the ability of a system to handle multiple tasks at once, but not necessarily executing them simultaneously. Instead, tasks are interleaved, meaning the system switches between them efficiently. It focuses on the system's ability to switch between these tasks, making progress on each one without waiting for another to complete entirely. Think of it as an illusion of simultaneous execution.

  • Think of concurrency like a single chef in a restaurant kitchen. This chef has to prepare multiple orders (tasks): chopping vegetables, grilling meat, and preparing sauces. The chef switches between these tasks, working on a little bit of each before moving on to the next. The chef isn't doing all three tasks at the exact same moment, but manages to make progress on all of them seemingly simultaneously. Concurrency enables multitasking on a single core.

  • Analogy: The Single Web Server: Imagine a web server handling requests. It might receive a request for a webpage, start fetching data from a database, then switch to handling another request while waiting for the database to respond. It's not doing everything at once, but it's handling multiple requests concurrently.

  • In programming, concurrency is achieved through multithreading, coroutines (with asyncio in Python), callbacks, or event loops (like Node.js uses).

1.2 What is Parallelism? (Executing Multiple Tasks Simultaneously)

Parallelism refers to the simultaneous execution of multiple tasks, typically by leveraging multiple CPU cores. This requires having multiple processing units available – typically multiple CPU cores, but also distributed computing clusters or specialized hardware like GPUs.

  • Imagine multiple chefs working on different dishes at the same time. One chef is chopping vegetables, another is grilling meat, and a third is preparing sauces, all happening concurrently. Parallelism requires multiple cores to execute multiple processes.

  • In programming, parallelism is achieved through multiprocessing, distributed computing (e.g., using Apache Spark or Dask), or vectorized computations (using NumPy or libraries like TensorFlow/PyTorch on GPUs).

1.3 Concurrency ≠ Parallelism: The Key Distinction

This is where many people get confused. Here's the clarifying breakdown:

  • Concurrency is the task of managing multiple tasks, regardless of whether they execute simultaneously. Parallelism is the task of actually executing multiple tasks at the same time.

  • A concurrent program handles multiple tasks by context switching. A parallel program executes multiple tasks simultaneously.

  • Concurrency enables parallelism: If you have a concurrent program and multiple processing units, the system can potentially execute parts of that program in parallel. Concurrency is a precondition for parallelism, not a substitute for it.

  • Concurrency without parallelism (single-core): You can have concurrency on a single-core machine. The tasks are switched rapidly, creating an illusion of simultaneous execution. Think of the single chef managing multiple orders example.

  • Parallelism requires concurrency: Concurrency is important because the work that has to happen in Parallel has to be broken down into different pieces of work that can be distributed for parallelization

Okay, let's break down why "Concurrency is a precondition for parallelism, not a substitute for it" using clear examples and analogies.

1.3.1 Why Concurrency is a Precondition

  • The reason concurrency is a precondition for parallelism is that before you can execute tasks in parallel, you need a way to describe or structure your program so that it can be executed in parallel. Concurrency provides this structure.

  • Think of it this way: You need to decompose a large task into smaller, independent, manageable units before you can assign those units to different workers to execute at the same time. That decomposition and the structure for managing these units is what concurrency provides.

Example: The Restaurant Kitchen Analogy, Expanded

Let's go back to our chef and kitchen scenario, but with more detail:

  1. The Big Task: Prepare a multi-course meal for a large party.

  2. Concurrency Comes In: The first step is to figure out how to break down that big task into smaller, independent pieces that could potentially be done at the same time:

    • Chop vegetables.
    • Grill the meat.
    • Prepare the sauce.
    • Cook the pasta.
    • Set the table.
    • Prepare the drinks.

    This decomposition is concurrency.

  3. You're structuring the work so that the system can manage multiple tasks. You've identified that these tasks are largely independent and could potentially be done concurrently.

  4. Parallelism Only Happens After Concurrency: Only after you've identified these independent tasks can you then decide to assign them to different chefs (CPU cores) to execute simultaneously:

    • Chef 1 chops vegetables.
    • Chef 2 grills the meat.
    • Chef 3 prepares the sauce.
    • ...and so on.

    Parallelism is only possible because the work was structured concurrently first. You couldn't have parallelism if you didn't know how to break up the meal preparation process into smaller, independent tasks.

Without Concurrency, Parallelism is Impossible:

Imagine trying to force parallelism without first decomposing the task concurrently. You would have multiple chefs trying to do the exact same thing at the exact same time, leading to chaos and resource contention. "Who gets the knife?" "Who uses the stove?" There's no structure, no organization, just a free-for-all.

Code Example (Illustrative, High-Level):

Let's say you have a large array of data and you want to calculate the square of each element:

# 1. Concurrency: Define a function that squares a single element
def square(x):
    return x * x
    
#2.  Parallelization (using multiprocessing):
import multiprocessing

data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

if __name__ == "__main__":  # Required for multiprocessing on macOS/Windows
    with multiprocessing.Pool(processes=4) as pool:
        squared_data = pool.map(square, data)

    print(squared_data)

Explanation:

  1. Concurrency: Dividing the task. We first had to define how the task of squaring a single element can happen by dividing the work.

  2. Parallelism: after defining the independent task "square," you can then tell multiprocessing to execute that function on different cores simultaneously by distributing the square function to different cores.

Concurrency is About Structuring, Parallelism is About Execution:

In Simple Terms:

  • You need a plan (concurrency) before you can have multiple people working simultaneously (parallelism).
  • Concurrency describes the possibility of things running in parallel.
  • Parallelism describes the actual execution of things running at the same time.

Concurrency is not a substitute for parallelism because concurrency simply makes parallelism possible; it doesn't guarantee it or replace it.


2. The Python Global Interpreter Lock (GIL) and Its Profound Impact

Python's default implementation, CPython, has a notorious constraint called the Global Interpreter Lock (GIL). This is critical for understanding Python's concurrency and parallelism behavior.

2.1 What is the GIL? (The Gatekeeper)

  • The GIL is a mutex (lock) that allows only one thread to execute Python bytecode at a time, even on multi-core CPUs. Think of it as a single key to the Python interpreter. Only one thread can hold the key and access the interpreter at any given moment.

  • This means Python threads do not achieve true parallel execution for CPU-bound tasks.

2.2 Why Does Python Have the GIL? (Historical and Technical Reasons)

  • Python’s memory management uses reference counting, which requires synchronized access to prevent memory corruption.

  • Python’s memory management relies heavily on reference counting. Each object keeps track of how many times it's being referenced. Incrementing and decrementing these reference counts needs to be thread-safe. The GIL simplifies this process, preventing race conditions and memory corruption.

  • Legacy code: Many C extensions for Python (e.g., NumPy, SciPy) were written with the assumption of single-threaded execution. Introducing thread safety without the GIL would have required significant rewriting of these extensions.

  • Historical performance: In the early days of Python, with predominantly single-core processors, the GIL actually provided a performance benefit by reducing the overhead of locking and unlocking individual objects.

2.3 The Big Question: Does this Mean Python Cannot Use Multiple Cores?

  • No! You can use multiprocessing (not threading) to bypass the GIL and achieve parallelism.

3. Concurrency in Python: Multithreading and AsyncIO - Managing I/O-Bound Tasks

Both multithreading and asyncio are concurrency models used to handle I/O-bound tasks in Python. Let's delve into why each is useful and how they differ in performance.

3.1 Multithreading: When it Can be Useful for I/O-Bound Tasks (But Use With Caution)

The threading module allows multiple threads to execute concurrently. However, it's critical to remember the GIL (Global Interpreter Lock). So, why use threading at all if only one thread can execute Python bytecode at a time? The key lies in what happens when a thread is waiting for I/O.

  • The Benefit: Releasing the GIL During I/O: When a thread performs an I/O operation (e.g., reading from a file, making a network request, querying a database), it typically releases the GIL while waiting for the operation to complete. This allows another thread to acquire the GIL and start executing.

  • The Result: Improved Throughput: While one thread is blocked waiting for I/O, other threads can continue processing. This can improve the overall throughput of the application, meaning it can handle more tasks in a given period of time.

  • The Scenario: This is most effective when you have multiple independent I/O-bound tasks. Think of downloading multiple files from different servers. While one thread is waiting for data from server A, another thread can be downloading data from server B.

Example: Using Multithreading for I/O-bound Tasks

import threading
import time
import requests  # For making HTTP requests

def download_file(url):
    print(f"Thread {threading.current_thread().name}: Downloading {url}...")
    try:
        response = requests.get(url, stream=True)  # Use stream=True for large files
        if response.status_code == 200:
            filename = url.split("/")[-1] # Extract filename
            with open(filename, 'wb') as f:
                for chunk in response.iter_content(chunk_size=8192): # Chunk size for efficiency
                    f.write(chunk)
            print(f"Thread {threading.current_thread().name}: Finished downloading {filename}")
        else:
            print(f"Thread {threading.current_thread().name}: Error downloading {url}: {response.status_code}")
    except Exception as e:
        print(f"Thread {threading.current_thread().name}: Error downloading {url}: {e}")


urls = ["https://example.com/file1.txt", "https://example.org/file2.txt", "https://example.net/file3.txt"]  # Replace with real URLs
threads = []

start_time = time.time()

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

for thread in threads:
    thread.join()

end_time = time.time()

print(f"Total time using threads: {end_time - start_time:.2f} seconds")

Explanation:

  • requests.get(url, stream=True): This is a key improvement. The stream=True argument tells requests to download the file in chunks, which is more efficient for large files and prevents the entire file from being loaded into memory at once.
  • response.iter_content(chunk_size=8192): This iterates over the response content in chunks of 8192 bytes (8KB). The write operation can release the GIL for other threads to use.
  • Now, during response.iter_content, which is an I/O bound task, thread can release GIL and other threads can acquire GIL

Important Considerations When Using Threads for I/O:

  • Not Always the Best Choice: Even for I/O-bound tasks, threads are not always the best option. asyncio often provides better performance and scalability, especially for highly concurrent applications (see below).
  • Context Switching Overhead: Excessive threads can still lead to performance degradation due to the overhead of creating and switching between threads. The OS needs to manage the threads and context switch between them.
  • Race Conditions: Shared data between threads must be carefully synchronized using locks or other synchronization primitives to prevent race conditions and data corruption.
  • Global State: Threads share the same memory space, so you need to be careful about modifying global variables.

3.2 AsyncIO: The Modern and More Performant Way to Handle Concurrent I/O

The asyncio module provides a way to write concurrent code using a single thread and an event loop. This offers several advantages over multithreading, particularly for I/O-bound tasks:

Why AsyncIO is Generally More Performant than Threading for I/O:

  1. Reduced Overhead:

    • No Thread Context Switching: asyncio uses coroutines (lightweight functions) instead of threads. Switching between coroutines is much faster than switching between threads because it doesn't involve the operating system and saves CPU processing and memory. It's just a matter of switching functions in Python.
    • Lower Memory Footprint: Coroutines typically have a smaller memory footprint than threads.
    • There is no overhead of locking because it is single threaded
  2. Single-Threaded Simplicity:

    • No Locks Required: Because asyncio is single-threaded, you don't need to worry about locks, race conditions, or deadlocks. This simplifies your code and makes it less prone to bugs.
    • Easier Reasoning: It's easier to reason about the flow of execution in a single-threaded program.
  3. More Efficient I/O Handling:

    • Non-Blocking I/O: asyncio relies on non-blocking I/O, which means that I/O operations don't block the main thread. Instead, they register a callback to be executed when the operation completes.
    • Event Loop: The event loop efficiently manages these non-blocking I/O operations and schedules coroutines to run based on the events that occur.

Example: Using AsyncIO for Efficient Network Calls

import asyncio
import time
import aiohttp  # Asynchronous HTTP client

async def download_file(url):
    print(f"Coroutine: Downloading {url}...")
    try:
        async with aiohttp.ClientSession() as session: # Create a session
            async with session.get(url) as response: # Get the response
                if response.status == 200:
                    filename = url.split("/")[-1]
                    with open(filename, 'wb') as f:
                        while True:
                            chunk = await response.content.readany() # Asynchronously read a chunk
                            if not chunk:
                                break
                            f.write(chunk)
                    print(f"Coroutine: Finished downloading {filename}")
                else:
                    print(f"Coroutine: Error downloading {url}: {response.status}")
    except Exception as e:
        print(f"Coroutine: Error downloading {url}: {e}")

async def main():
    urls = ["https://example.com/file1.txt", "https://example.org/file2.txt", "https://example.net/file3.txt"] # Replace with real URLs
    tasks = [download_file(url) for url in urls]
    await asyncio.gather(*tasks)

start_time = time.time()
asyncio.run(main())
end_time = time.time()

print(f"Total time using asyncio: {end_time - start_time:.2f} seconds")

Explanation:

  • aiohttp.ClientSession(): This is an asynchronous HTTP client that allows you to make non-blocking HTTP requests.
  • await response.content.readany(): This asynchronously reads a chunk of data from the response. The await keyword allows the event loop to run other coroutines while waiting for the data. The key is that it is asynchronous, meaning it doesn't block.
  • All code using async / await keyword releases control to event loop. This is how the coroutine does not block other I/O bound tasks.

In Summary: When to Choose Between Threads and AsyncIO for I/O

  • Threads: Use when interacting with legacy code or libraries that are inherently blocking and don't have asyncio equivalents. Also, you can run CPU-Bound processes in threads using third-party libraries. However, you need to be cautious about the GIL and potential for race conditions. Often, for I/O, it's best to wrap with asyncio if possible.
  • AsyncIO: This is generally the preferred approach for modern I/O-bound applications in Python, especially when dealing with network programming, web servers, or highly concurrent tasks. It provides better performance, scalability, and a simpler programming model compared to threads.

By understanding these nuances, you can make informed decisions about which concurrency model to use for your specific I/O-bound tasks in Python.


4. Parallelism in Python: Multiprocessing and Joblib - Unleashing Multi-Core Power

4.1 Multiprocessing: Best for CPU-Bound Tasks

To achieve true parallel execution, Python provides the multiprocessing module, which spawns separate processes (not threads), each with its own memory space.

Example: Using Multiprocessing for Heavy Computation

import multiprocessing
import time

def compute(n):
    time.sleep(1)
    return n * n

if __name__ == "__main__":
    start_time = time.time()
    pool = multiprocessing.Pool(processes=4)  # Utilize 4 CPU cores
    results = pool.map(compute, range(10))
    end_time = time.time()

    print(f"Results: {results}")
    print(f"Total time using multiprocessing: {end_time - start_time:.2f} seconds")

Explanation:

  • Each process runs independently, bypassing the GIL.
  • Best suited for CPU-heavy tasks like image processing, machine learning, or mathematical computations.

4.2 Joblib: Simple Parallel Processing

Joblib provides an easier way to parallelize computations, commonly used in machine learning.

Example: Parallel Execution Using Joblib

from joblib import Parallel, delayed
import time

def compute(n):
    time.sleep(1)
    return n * n

start_time = time.time()
results = Parallel(n_jobs=4)(delayed(compute)(i) for i in range(10))
end_time = time.time()

print(f"Results: {results}")
print(f"Total time using joblib: {end_time - start_time:.2f} seconds")

Explanation:

  • n_jobs=4 uses 4 CPU cores.
  • Commonly used in Scikit-learn for training models faster.

5. When to Use Concurrency vs. Parallelism?

| Use Case                                                  | Best Approach                             |
|-----------------------------------------------------------|-------------------------------------------|
| I/O-bound tasks (e.g., Web Scraping, Network Requests)    | `asyncio`, `threading`                    |
| CPU-bound tasks (e.g., Image Processing, ML Training)     | `multiprocessing`, `joblib`               |
| Many independent calculations                             | `multiprocessing`                         |
| Running background tasks                                  | `threading`, `asyncio`                    |

Conclusion

  • Concurrency enables multiple tasks to be managed efficiently but not necessarily run at the same time.
  • Parallelism truly executes tasks simultaneously on multiple CPU cores.
  • Python's GIL restricts true parallelism for threads but can be bypassed using multiprocessing.
  • Choose threading for I/O-bound tasks and multiprocessing for CPU-bound tasks.

Understanding these concepts is crucial for designing efficient and scalable systems. 🚀

More like this