This article is part of in the series
Published: Thursday 13th March 2025

multiprocessing

In today's data-intensive world, processing speed can be a significant bottleneck in Python applications. While Python offers simplicity and versatility, its Global Interpreter Lock (GIL) can limit performance in CPU-bound tasks. This is where Python's multiprocessing module shines, offering a robust solution to leverage multiple CPU cores and achieve true parallel execution. This comprehensive guide explores how multiprocessing works, when to use it, and practical implementation strategies to supercharge your Python applications.

Understanding Python's Multiprocessing Module

Python multiprocessing module was introduced to overcome the limitations imposed by the Global Interpreter Lock (GIL). The GIL prevents multiple native threads from executing Python bytecode simultaneously, which means that even on multi-core systems, threading in Python doesn't provide true parallelism for CPU-bound tasks.

Multiprocessing circumvents this limitation by creating separate Python processes rather than threads. Each process has its own Python interpreter and memory space, allowing multiple processes to execute code truly in parallel across different CPU cores.

Key Benefits of Multiprocessing

  • True Parallelism: Unlike threading, multiprocessing enables CPU-bound code to execute simultaneously across multiple cores.
  • Resource Isolation: Each process has its own memory space, preventing the sharing issues that can occur with threading.
  • Fault Tolerance: A crash in one process doesn't necessarily bring down other processes.
  • Scalability: Applications can be designed to scale across multiple cores and even multiple machines.

When to Use Multiprocessing

Multiprocessing isn't always the right solution. Here's when you should consider using it:

Ideal Use Cases

  • CPU-Intensive Tasks: Calculations, data processing, and simulations that require significant computation.
  • Batch Processing: Processing large datasets in chunks where operations on each chunk are independent.
  • Algorithm Parallelization: Algorithms that can be divided into independent parts (like certain machine learning training processes).
  • Image and Video Processing: Operations like filtering, transformations, or feature extraction that can be applied independently to different portions of the data.

When to Avoid Multiprocessing

  • I/O-Bound Tasks: For operations limited by input/output rather than CPU (like web scraping or database operations), asynchronous programming or threading may be more appropriate.
  • Small Datasets: The overhead of process creation and communication can outweigh the benefits for small tasks.
  • Highly Interdependent Tasks: Tasks requiring frequent synchronization between processes may see reduced performance due to communication overhead.

Getting Started with Multiprocessing

Let's explore the basic patterns for implementing multiprocessing in Python.

The Process Class

The most basic way to use multiprocessing is with the Process class:

import multiprocessing

def worker(num):
    """Worker function"""
    print(f'Worker: {num}')
    return

if __name__ == '__main__':
    processes = []
    
    # Create 5 processes
    for i in range(5):
        p = multiprocessing.Process(target=worker, args=(i,))
        processes.append(p)
        p.start()
    
    # Wait for all processes to complete
    for p in processes:
        p.join()

This example creates five separate processes, each executing the worker function with a different argument.

The Pool Class

For batch processing tasks, the Pool class provides a convenient way to distribute work across multiple processes:

from multiprocessing import Pool

def f(x):
    return x*x

if __name__ == '__main__':
    with Pool(processes=4) as pool:
        results = pool.map(f, range(10))
        print(results)  # Output: [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

The Pool class automatically divides the input data among available processes and manages them for you. This is often the simplest way to parallelize operations on large datasets.

Advanced Multiprocessing Techniques

Once you've mastered the basics, you can explore these more advanced techniques:

Process Communication

Processes in Python don't share memory by default, so multiprocessing provides several mechanisms for communication:

Queues

from multiprocessing import Process, Queue

def f(q):
    q.put('hello')

if __name__ == '__main__':
    q = Queue()
    p = Process(target=f, args=(q,))
    p.start()
    print(q.get())  # Output: 'hello'
    p.join()

Pipes

from multiprocessing import Process, Pipe

def f(conn):
    conn.send('hello')
    conn.close()

if __name__ == '__main__':
    parent_conn, child_conn = Pipe()
    p = Process(target=f, args=(child_conn,))
    p.start()
    print(parent_conn.recv())  # Output: 'hello'
    p.join()

Shared Memory

For cases where you need to share large amounts of data between processes:

from multiprocessing import Process, Value, Array

def f(n, a):
    n.value = 3.1415927
    for i in range(len(a)):
        a[i] = -a[i]

if __name__ == '__main__':
    num = Value('d', 0.0)
    arr = Array('i', range(10))
    
    p = Process(target=f, args=(num, arr))
    p.start()
    p.join()
    
    print(num.value)  # Output: 3.1415927
    print(arr[:])     # Output: [0, -1, -2, -3, -4, -5, -6, -7, -8, -9]

Process Pools with Callbacks

You can add callbacks to be executed when tasks complete:

from multiprocessing import Pool
import time

def f(x):
    return x*x

def callback_func(result):
    print(f"Task result: {result}")

if __name__ == '__main__':
    pool = Pool(processes=4)
    
    for i in range(10):
        pool.apply_async(f, args=(i,), callback=callback_func)
    
    pool.close()
    pool.join()

Performance Optimization Tips

To get the most out of multiprocessing, consider these optimization strategies:

Optimal Process Count

The ideal number of processes depends on your specific task and system capabilities:

import multiprocessing

# Use the number of CPU cores for CPU-bound tasks
num_processes = multiprocessing.cpu_count()

# For I/O-bound tasks, you might want to use more
# num_processes = multiprocessing.cpu_count() * 2

Chunking Data

When processing large datasets, using appropriate chunk sizes can significantly improve performance:


from multiprocessing import Pool

def process_chunk(chunk):
    return [x*x for x in chunk]

if __name__ == '__main__':
    data = list(range(10000))
    
    with Pool(processes=4) as pool:
        # Process data in chunks of 100
        results = pool.map(process_chunk, [data[i:i+100] for i in range(0, len(data), 100)])
        
        # Flatten the results
        flattened = [item for sublist in results for item in sublist]

Minimizing Communication

Excessive communication between processes can create bottlenecks:

  • Pass all necessary data to a process at initialization when possible.
  • Return results in larger batches rather than item-by-item.
  • Use shared memory for large datasets that need to be accessed by multiple processes.

Common Pitfalls and Solutions

The "if name == 'main'" Guard

Always protect your multiprocessing code with this guard to prevent infinite process spawning:

# This is crucial in multiprocessing code
if __name__ == '__main__':
    # Your multiprocessing code here
    pass

Pickling Limitations

Multiprocessing relies on pickling for inter-process communication, which has limitations:

  • Not all objects can be pickled (like file handles or database connections).
  • Methods of custom classes may not be picklable.

Solution: Use basic data types or ensure your objects are picklable.

Resource Leaks

Always properly close and join processes to prevent resource leaks:

from multiprocessing import Pool

if __name__ == '__main__':
    pool = Pool(processes=4)
    # Do work with the pool
    
    # Always close and join
    pool.close()  # No more tasks will be submitted
    pool.join()   # Wait for all worker processes to exit

Real-World Application Example

Let's look at a practical example: parallel image processing with multiprocessing.

from multiprocessing import Pool
from PIL import Image, ImageFilter
import os

def process_image(image_path):
    # Open the image
    img = Image.open(image_path)
    
    # Apply a filter
    filtered = img.filter(ImageFilter.BLUR)
    
    # Save with new name
    save_path = f"processed_{os.path.basename(image_path)}"
    filtered.save(save_path)
    
    return save_path

if __name__ == '__main__':
    # List of image paths to process
    image_paths = ["image1.jpg", "image2.jpg", "image3.jpg", "image4.jpg"]
    
    # Create a pool with 4 processes
    with Pool(processes=4) as pool:
        # Process images in parallel
        results = pool.map(process_image, image_paths)
    
    print(f"Processed images: {results}")

Summary

Python's multiprocessing module offers a powerful solution for achieving true parallelism in CPU-bound applications. By distributing work across multiple processes, you can fully leverage modern multi-core systems and significantly improve execution speed for suitable tasks.

 

More Articles from Unixmen

Python Threading for Concurrent Programming

Python Main Function: Understanding and Using if __name__ == “__main__”