Python > Advanced Python Concepts > Concurrency and Parallelism > Choosing Between Threads and Processes

Thread Pool vs Process Pool Example

This snippet shows how to use `ThreadPoolExecutor` for I/O-bound operations and `ProcessPoolExecutor` for CPU-bound operations effectively.

Concepts Behind the Snippet

This example contrasts `ThreadPoolExecutor` and `ProcessPoolExecutor`. `ThreadPoolExecutor` is ideal for I/O-bound tasks where threads spend a significant amount of time waiting for external operations. `ProcessPoolExecutor`, on the other hand, is designed for CPU-bound tasks that benefit from true parallelism across multiple CPU cores.

Code: I/O-Bound Task with ThreadPoolExecutor

This code uses `ThreadPoolExecutor` to fetch the content length of several websites. Since fetching web pages is an I/O-bound task, threads can efficiently handle multiple requests concurrently. The `executor.map()` function applies the `fetch_url` function to each URL in the `urls` list, and the results are collected and printed. The `with` statement ensures that the thread pool is properly shut down after use.

import concurrent.futures
import time
import requests

urls = ['https://www.google.com', 'https://www.yahoo.com', 'https://www.bing.com', 'https://www.duckduckgo.com', 'https://www.wikipedia.org']

def fetch_url(url):
    print(f'Fetching {url} in thread {threading.current_thread().name}')
    response = requests.get(url)
    return len(response.content)

if __name__ == '__main__':
    start_time = time.time()
    with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
        results = executor.map(fetch_url, urls)

    end_time = time.time()
    print(f'ThreadPoolExecutor Execution Time: {end_time - start_time:.4f} seconds')
    for url, length in zip(urls, results):
        print(f'{url}: {length}')

Code: CPU-Bound Task with ProcessPoolExecutor

This code uses `ProcessPoolExecutor` to perform a CPU-bound calculation (summing squares) on a list of numbers. Each number is processed in a separate process, leveraging multiple CPU cores for parallel execution. `multiprocessing.cpu_count()` determines the number of CPU cores available, and the `max_workers` parameter of `ProcessPoolExecutor` is set accordingly. The `executor.map()` function applies the `cpu_bound_calculation` function to each number in the `numbers` list, and the results are collected and printed. The `with` statement ensures that the process pool is properly shut down after use.

import concurrent.futures
import time
import multiprocessing

def cpu_bound_calculation(n):
    result = 0
    for i in range(n):
        result += i*i
    return result

if __name__ == '__main__':
    numbers = [10000000, 12000000, 11000000, 9000000, 13000000]
    start_time = time.time()

    with concurrent.futures.ProcessPoolExecutor(max_workers=multiprocessing.cpu_count()) as executor:
        results = executor.map(cpu_bound_calculation, numbers)

    end_time = time.time()
    print(f'ProcessPoolExecutor Execution Time: {end_time - start_time:.4f} seconds')
    for num, result in zip(numbers, results):
        print(f'Calculation for {num}: {result}')

Real-Life Use Case

  • ThreadPoolExecutor: Web scraping, downloading files, making API calls.
  • ProcessPoolExecutor: Image processing, video encoding, scientific simulations, any task involving heavy computations.

Best Practices

  • Choose the right executor: Select `ThreadPoolExecutor` for I/O-bound tasks and `ProcessPoolExecutor` for CPU-bound tasks.
  • Use `max_workers` appropriately: For `ThreadPoolExecutor`, a larger number of workers might be beneficial if you have many I/O-bound tasks. For `ProcessPoolExecutor`, setting `max_workers` to the number of CPU cores is often a good starting point.
  • Handle exceptions: Use try-except blocks to handle potential exceptions within the tasks submitted to the executor.

Interview Tip

Be prepared to discuss the use cases for `ThreadPoolExecutor` and `ProcessPoolExecutor`. Explain how the GIL affects the choice of executor for CPU-bound tasks. Also, be familiar with the `executor.map()` and `executor.submit()` methods.

When to Use Them

  • ThreadPoolExecutor: When tasks spend most of their time waiting for external operations (I/O).
  • ProcessPoolExecutor: When tasks involve heavy computations that can be parallelized across multiple CPU cores.

Memory Footprint

`ProcessPoolExecutor` generally has a higher memory footprint than `ThreadPoolExecutor` because each process has its own memory space.

Alternatives

  • asyncio: An alternative for I/O-bound tasks that can be more efficient than `ThreadPoolExecutor` in certain scenarios.
  • Dask: A library for parallel computing in Python, suitable for larger-than-memory datasets and complex computations.

Pros and Cons: ThreadPoolExecutor

Pros:

  • Lower overhead compared to `ProcessPoolExecutor`.
  • Suitable for I/O-bound tasks.
Cons:
  • Limited by the GIL for CPU-bound tasks.

Pros and Cons: ProcessPoolExecutor

Pros:

  • Bypass the GIL, allowing true parallelism for CPU-bound tasks.
Cons:
  • Higher overhead compared to `ThreadPoolExecutor`.

FAQ

  • What is the difference between `executor.map()` and `executor.submit()`?

    `executor.map()` applies a function to each item in an iterable and returns the results in the same order as the input. It blocks until all tasks are complete. `executor.submit()` submits a callable to the executor and returns a `Future` object, which represents the result of the asynchronous computation. You can then use the `Future` object to retrieve the result or check the status of the task. `executor.submit` is more flexible if you need to handle exceptions, manage dependencies, or retrieve results as they become available.
  • How to choose the right `max_workers` for `ThreadPoolExecutor`?

    The optimal `max_workers` value depends on the nature of the I/O-bound tasks. A larger number of workers can be beneficial if tasks spend most of their time waiting for external operations. However, too many workers can lead to increased overhead and contention for resources. Experimentation and profiling are often necessary to determine the optimal value.