Python tutorials > Advanced Python Concepts > Concurrency and Parallelism > What is concurrency/parallelism?

What is concurrency/parallelism?

Concurrency and parallelism are essential concepts in modern programming, especially when dealing with tasks that can be broken down into smaller, independent units. Understanding the difference between them and when to use each approach can significantly improve the performance and responsiveness of your Python applications. This tutorial explains concurrency and parallelism, highlighting their distinctions and use cases with practical examples.

Concurrency vs. Parallelism: The Key Difference

Concurrency is about dealing with multiple tasks at the same time. A concurrent system can switch between tasks, giving the illusion that they are running simultaneously. This is often achieved through techniques like threading or asynchronous programming.

Parallelism, on the other hand, is about executing multiple tasks simultaneously. This requires multiple processing units (cores) and allows for true simultaneous execution. Parallelism relies on concurrency.

Think of it this way: Concurrency is like a single chef juggling multiple orders, switching between them to keep everything moving. Parallelism is like having multiple chefs, each working on a separate order simultaneously.

Concurrency with Threads (Illustrative Example)

This example demonstrates concurrency using threads. Two threads, 'A' and 'B', are created and started. Each thread executes the `task` function, which simulates a time-consuming operation using `time.sleep()`. While the threads appear to run simultaneously, they are actually sharing a single CPU core and switching between each other.

Important note: Due to the Global Interpreter Lock (GIL) in standard Python (CPython), true parallelism is limited when using threads for CPU-bound tasks. The GIL allows only one thread to hold control of the Python interpreter at any time. For CPU-bound tasks, consider using multiprocessing for true parallelism.

import threading
import time

def task(name):
    print(f'Task {name}: Starting')
    time.sleep(2)
    print(f'Task {name}: Finishing')

# Create threads
t1 = threading.Thread(target=task, args=('A',))
t2 = threading.Thread(target=task, args=('B',))

# Start threads
t1.start()
t2.start()

# Wait for threads to finish
t1.join()
t2.join()

print('All tasks complete.')

Parallelism with Multiprocessing

This example demonstrates parallelism using the `multiprocessing` module. Two processes, 'A' and 'B', are created and started. Each process executes the `task` function, similar to the threading example. However, since each process has its own Python interpreter, they can truly run in parallel on multiple CPU cores, bypassing the GIL limitation. The `if __name__ == '__main__':` guard is crucial, especially on Windows, to prevent recursive process creation.

import multiprocessing
import time

def task(name):
    print(f'Task {name}: Starting')
    time.sleep(2)
    print(f'Task {name}: Finishing')

if __name__ == '__main__':
    # Create processes
    p1 = multiprocessing.Process(target=task, args=('A',))
    p2 = multiprocessing.Process(target=task, args=('B',))

    # Start processes
    p1.start()
    p2.start()

    # Wait for processes to finish
    p1.join()
    p2.join()

    print('All tasks complete.')

Asynchronous Programming (Concurrency)

This example shows concurrency using `asyncio`. The `task` function is defined as an asynchronous coroutine. `asyncio.sleep()` allows the event loop to switch to other tasks while waiting, effectively achieving concurrency. The `asyncio.gather()` function runs multiple tasks concurrently. Asynchronous programming is well-suited for I/O-bound tasks like network requests, where the program spends much of its time waiting for external resources.

import asyncio
import time

async def task(name):
    print(f'Task {name}: Starting')
    await asyncio.sleep(2)
    print(f'Task {name}: Finishing')

async def main():
    await asyncio.gather(task('A'), task('B'))

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

Real-Life Use Case: Web Scraping

Web scraping often involves making multiple HTTP requests, which can be slow. Using concurrency (e.g., with threads or `asyncio`) can significantly speed up the scraping process by making requests concurrently. This example uses threads to scrape multiple URLs simultaneously. Each thread fetches a different URL, reducing the overall time taken to scrape all the websites.

import requests
import threading
import time

def scrape_url(url):
    try:
        response = requests.get(url)
        response.raise_for_status()  # Raise HTTPError for bad responses (4xx or 5xx)
        print(f'Successfully scraped {url}')
        # Process the content of the page here (e.g., extract data)
    except requests.exceptions.RequestException as e:
        print(f'Error scraping {url}: {e}')

urls = [
    'https://www.example.com',
    'https://www.google.com',
    'https://www.wikipedia.org'
]

threads = []
start_time = time.time()
for url in urls:
    t = threading.Thread(target=scrape_url, args=(url,))
    threads.append(t)
    t.start()

for t in threads:
    t.join()

end_time = time.time()
print(f'Total time taken: {end_time - start_time:.2f} seconds')

When to Use Concurrency vs. Parallelism

  • Concurrency: Ideal for I/O-bound tasks (e.g., network requests, file I/O) where the program spends a lot of time waiting for external resources. Also suitable when true parallelism is not required or is limited by factors like the GIL.
  • Parallelism: Best for CPU-bound tasks (e.g., computationally intensive calculations, image processing) where the program spends a lot of time performing calculations. Requires multiple CPU cores to achieve true speedup.

Best Practices

  • Identify Bottlenecks: Profile your code to identify whether it is I/O-bound or CPU-bound before deciding on a concurrency or parallelism strategy.
  • Avoid Shared State: Minimize shared state between threads or processes to avoid race conditions and synchronization issues. Use appropriate locking mechanisms (e.g., `threading.Lock`) if shared state is unavoidable.
  • Consider Asynchronous Programming: For I/O-bound tasks, `asyncio` often provides a more efficient and elegant solution than threading.
  • Use Process Pools: For CPU-bound tasks using multiprocessing, consider using a process pool (`multiprocessing.Pool`) to manage a pool of worker processes.
  • Error Handling: Implement robust error handling to catch exceptions in threads or processes and prevent the entire application from crashing.

Memory Footprint

Threads: Threads typically have a smaller memory footprint than processes because they share the same memory space. However, this shared memory space requires careful synchronization to avoid data corruption.

Processes: Processes have a larger memory footprint because each process has its own memory space. This isolation can be beneficial for stability but increases memory usage.

Alternatives

Greenlets: Greenlets are lightweight, user-space threads that provide concurrency without the overhead of operating system threads. They are often used in conjunction with asynchronous frameworks like Gevent.

Celery: Celery is a distributed task queue that allows you to distribute tasks across multiple machines, providing both concurrency and parallelism at scale.

Dask: Dask is a library for parallel computing in Python that provides a high-level interface for parallelizing NumPy, Pandas, and other data science workloads.

Pros and Cons of using threads

  • Pros
    • Lightweight compared to processes
    • Shared memory space simplifies data sharing
    • Faster context switching than processes
  • Cons
    • Limited by the GIL in CPython for CPU-bound tasks
    • Requires careful synchronization to avoid race conditions
    • Debugging can be challenging

Pros and Cons of using multiprocessing

  • Pros
    • Bypasses the GIL for CPU-bound tasks
    • True parallelism on multi-core systems
    • Increased stability due to process isolation
  • Cons
    • Higher memory footprint than threads
    • More complex data sharing (requires inter-process communication)
    • Slower context switching than threads

Interview Tip

When discussing concurrency and parallelism in interviews, be sure to emphasize the difference between them, their respective use cases, and the limitations of threads due to the GIL in Python. Also, be prepared to discuss strategies for avoiding race conditions and handling errors in concurrent and parallel programs. Familiarity with `asyncio` and `multiprocessing` is highly valued.

FAQ

  • What is the Global Interpreter Lock (GIL)?

    The Global Interpreter Lock (GIL) is a mutex that allows only one thread to hold control of the Python interpreter at any given time. This prevents multiple native threads from executing Python bytecode in parallel within a single process. The GIL primarily affects CPU-bound tasks. For I/O bound tasks, the GIL is often released while waiting for I/O, allowing other threads to run.

  • How can I avoid race conditions in concurrent programs?

    Race conditions can be avoided by minimizing shared state between threads or processes and using appropriate synchronization mechanisms like locks (e.g., `threading.Lock`) to protect critical sections of code that access shared resources. Also, consider using thread-safe data structures and queues for communication between threads.

  • Is `asyncio` truly parallel?

    No, `asyncio` is not truly parallel. It is a form of concurrency that allows multiple tasks to make progress without blocking each other. It achieves this through cooperative multitasking, where tasks voluntarily yield control to the event loop, allowing other tasks to run. `asyncio` is well-suited for I/O-bound tasks but does not provide true parallelism for CPU-bound tasks.