top of page
Gradient With Circle
Image by Nick Morrison

Insights Across Technology, Software, and AI

Discover articles across technology, software, and AI. From core concepts to modern tech and practical implementations.

Multiprocessing vs Multithreading in Python

10 minutes ago
9 min read

Python provides several ways to execute tasks concurrently, with multiprocessing and multithreading being two of the most commonly used approaches. Both can improve the responsiveness and throughput of Python applications, but they work in fundamentally different ways and are suited to different types of workloads.


Multithreading allows multiple threads to exist within the same process and share its memory space. Multiprocessing, in contrast, creates multiple independent processes, each with its own Python interpreter and memory space. This distinction becomes particularly important in CPython because of the Global Interpreter Lock (GIL), which prevents multiple threads from executing Python bytecode simultaneously within a single process.


The choice between multiprocessing and multithreading therefore depends largely on the nature of the task. I/O-bound workloads such as network requests, file operations, and database queries often benefit from multithreading, while CPU-bound workloads such as numerical computation, image processing, and computationally intensive data processing can benefit from multiprocessing.


In this article, we will examine how multiprocessing and multithreading work in Python, implement both approaches with practical examples, and compare their performance, memory usage, communication mechanisms, and typical use cases.


multiprocessing vs multi-threading in python

Understanding Multithreading in Python

A thread is a lightweight unit of execution that operates inside a process. A Python program can create multiple threads that execute different functions concurrently while sharing resources such as memory, variables, and open files.

Python's threading module provides a high-level interface for creating and managing threads. A basic threaded program can create several threads, start them, and then wait for them to complete using the join() method.

The following example creates two threads that execute the same function with different arguments. Each thread prints a message and simulates a task using time.sleep().

import threading
import time

def task(name):
    print(f"Starting {name}")
    time.sleep(2)
    print(f"Finished {name}")

thread1 = threading.Thread(target=task, args=("Task 1",))
thread2 = threading.Thread(target=task, args=("Task 2",))

thread1.start()
thread2.start()

thread1.join()
thread2.join()

print("All tasks completed")

Output:
Starting Task 1
Starting Task 2
Finished Task 1
Finished Task 2
All tasks completed

Both threads are started before the program waits for either one to finish. Since sleep() represents an I/O-style wait, the operating system can allow another thread to run while one thread is waiting. Consequently, the two two-second waits can overlap instead of being performed strictly one after another. This behavior illustrates an important characteristic of multithreading. Threads can be particularly useful when a program spends significant time waiting for external operations. Examples include HTTP requests, database queries, reading files, waiting for user input, and communicating with external services.


However, the Global Interpreter Lock changes the picture for CPU-bound Python code. In the standard CPython implementation, the GIL allows only one thread at a time to execute Python bytecode within a process. Threads can still be useful for I/O-bound work because the interpreter can release the GIL while waiting for many blocking operations, but simply creating more threads does not generally provide true parallel execution of Python bytecode for CPU-heavy tasks.


Consider a CPU-intensive function that repeatedly performs calculations. Running several instances of this function using threads does not normally provide the same type of CPU parallelism that independent processes can provide. Threading also has relatively low creation and switching overhead compared with processes. Because threads belong to the same process, they naturally have access to shared memory. This makes communication between threads comparatively straightforward, although shared mutable state introduces synchronization problems.


For example, two threads modifying the same variable can produce race conditions if access is not properly synchronized. Python provides synchronization primitives such as Lock, RLock, Semaphore, Event, and Condition through the threading module.

A thread pool is another common approach. Instead of creating a new thread for every individual task, a fixed number of worker threads can be maintained and reused. Python provides ThreadPoolExecutor through the concurrent.futures module.

The following example uses a thread pool to execute several simulated I/O tasks concurrently.

from concurrent.futures import ThreadPoolExecutor
import time

def download_file(file_id):
    print(f"Downloading file {file_id}")
    time.sleep(2)
    return f"File {file_id} downloaded"

with ThreadPoolExecutor(max_workers=3) as executor:
    results = executor.map(download_file, range(1, 7))

for result in results:
    print(result)

Output:
Downloading file 1
Downloading file 2
Downloading file 3
Downloading file 4
Downloading file 5
Downloading file 6
File 1 downloaded
File 2 downloaded
File 3 downloaded
File 4 downloaded
File 5 downloaded
File 6 downloaded

Here, the executor maintains three worker threads and distributes the six tasks among them. The tasks are intentionally represented by time.sleep() to simulate operations that spend time waiting rather than continuously consuming CPU resources. This pattern is useful for workloads involving multiple independent I/O operations.

Multithreading is therefore generally most useful when concurrency is needed around waiting operations rather than when the objective is to execute large amounts of Python computation simultaneously.


Understanding Multiprocessing in Python

Multiprocessing takes a different approach. Instead of creating multiple threads inside one process, Python creates multiple operating-system processes. Each process has its own Python interpreter and memory space.

The multiprocessing module provides tools for creating processes, communicating between them, and managing process pools. Since processes are independent, each process can execute Python code independently of the GIL. This makes multiprocessing particularly relevant for CPU-bound workloads.

A simple multiprocessing program can create two processes and assign a function to each one.

import multiprocessing
import time

def task(name):
    print(f"Starting {name}")
    time.sleep(2)
    print(f"Finished {name}")

process1 = multiprocessing.Process(target=task, args=("Task 1",))
process2 = multiprocessing.Process(target=task, args=("Task 2",))

process1.start()
process2.start()

process1.join()
process2.join()

print("All processes completed")

Output:
Starting Task 1
Starting Task 2
Finished Task 1
Finished Task 2
All processes completed

The syntax looks similar to the threading example, but the execution model is different. Each Process represents a separate operating-system process with its own interpreter and memory space.

Because these processes are independent, CPU-intensive operations can run on different CPU cores when the operating system schedules them accordingly. This is the major reason multiprocessing is often preferred for CPU-bound Python workloads.

For example, suppose a program needs to perform a computationally expensive operation on thousands of independent data items. A process pool can distribute those calculations among several worker processes.

Python's ProcessPoolExecutor provides a convenient high-level interface for this pattern.

from concurrent.futures import ProcessPoolExecutor

def calculate_square(number):
    total = 0

    for _ in range(10_000_000):
        total += number * number

    return total

numbers = [1, 2, 3, 4]

with ProcessPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(calculate_square, numbers))

print(results)

Output:
[10000000, 40000000, 90000000, 160000000]

The important point here is not the calculation itself but how the work is distributed. The executor can assign different input values to different worker processes, allowing computational work to be performed independently.

Multiprocessing comes with a higher cost than multithreading. Creating a process generally requires more system resources, and processes do not automatically share normal Python objects. Data often needs to be serialized and transferred between processes, which introduces communication overhead.


Python's multiprocessing system provides several mechanisms for inter-process communication, including Queue, Pipe, Value, Array, and managed objects. The multiprocessing.Queue class is especially useful when processes need to exchange results or messages.


For larger workloads, process pools are usually preferable to manually creating a process for every task. A pool can maintain a fixed number of worker processes and reuse them across many tasks, reducing repeated process-creation overhead. One important consideration is platform behavior. On some platforms, particularly Windows and macOS configurations using the spawn start method, multiprocessing code should be protected by an if name == "__main__": block.


This prevents child processes from unintentionally executing the module's process-creation code again. A safe version of the previous pattern looks like this:

from multiprocessing import Pool

def square(number):
    return number * number

if __name__ == "__main__":
    numbers = [1, 2, 3, 4, 5]

    with Pool(processes=4) as pool:
        results = pool.map(square, numbers)

    print(results)

Output:
[1, 4, 9, 16, 25]

The main guard is an important part of writing portable multiprocessing programs. It clearly separates the code that defines functions from the code responsible for starting worker processes.


Multiprocessing vs Multithreading: Key Differences

Although both approaches provide concurrency, multiprocessing and multithreading differ in several fundamental ways. The most important difference is the unit of execution. Multithreading uses multiple threads within a single process, while multiprocessing uses multiple independent processes. Threads share the same process memory, whereas processes normally have separate memory spaces.


This difference affects both performance and communication. Threads can access shared objects directly, but shared data must be synchronized carefully. Processes provide stronger isolation, but transferring data between processes generally requires an explicit communication mechanism and often serialization.


The Global Interpreter Lock is another major distinction. In standard CPython, the GIL prevents multiple threads from executing Python bytecode simultaneously within one process. Multiprocessing avoids this limitation because each process has its own interpreter and GIL. A practical comparison can be summarized as follows:

Feature

Multithreading

Multiprocessing

Basic unit

Thread

Process

Memory

Shared within process

Separate between processes

GIL impact

Important for CPU-bound Python code

Each process has its own interpreter

CPU-bound workloads

Usually limited

Suitable for parallel CPU work

I/O-bound workloads

Often highly useful

Useful, but can add unnecessary overhead

Startup overhead

Lower

Higher

Memory usage

Generally lower

Generally higher

Communication

Shared objects, queues, locks

Queues, pipes, shared memory, managers

Isolation

Lower

Higher

Synchronization

Often required for shared state

Less shared-state contention, but IPC is required

Best suited to

I/O-bound concurrency

CPU-bound parallelism

The distinction between concurrency and parallelism is also useful here. Concurrency means that multiple tasks can make progress during overlapping periods, while parallelism means that multiple tasks are actually executing at the same time on different processing resources. Multithreading can provide effective concurrency for I/O-bound workloads even when Python threads are not executing Python bytecode simultaneously. Multiprocessing, on the other hand, can provide actual parallel execution across CPU cores.


Performance should not be judged simply by counting threads or processes. Creating too many workers can increase scheduling, memory, synchronization, and communication overhead. The optimal number depends on the workload and the hardware. For I/O-heavy applications, a moderate number of threads can keep the application busy while individual operations wait for external systems. For CPU-heavy applications, a process pool sized around the available CPU resources can distribute computational work more effectively.

There are also situations where neither approach is the best first choice.


Python's asyncio framework provides asynchronous concurrency and can be highly effective for applications performing large numbers of I/O operations, particularly network services. It uses an event-driven model rather than relying on a large collection of operating-system threads. The appropriate model therefore depends on the workload rather than on the general idea that one concurrency technique is universally faster.


Choosing Between Multiprocessing and Multithreading in Python

The most useful way to choose between multiprocessing and multithreading is to first identify where the application spends its time. If the application spends most of its time waiting for network responses, database operations, file access, APIs, or other external resources, multithreading is often a practical choice. Threads can overlap these waiting periods while keeping implementation relatively straightforward.

For example, a web scraper downloading hundreds of independent pages can use a thread pool to initiate multiple requests without waiting for each request to finish before starting the next one. The same principle applies to applications that need to communicate with multiple external services.


CPU-bound workloads present a different situation. If the program spends most of its time executing computationally expensive Python code, multiprocessing can distribute that work across independent processes. Examples include CPU-intensive data transformations, certain machine learning preprocessing tasks, simulations, mathematical calculations, and image or video processing workloads. The following example illustrates a simple CPU-bound workload using multiprocessing.

from concurrent.futures import ProcessPoolExecutor

def compute(value):
    result = 0

    for i in range(5_000_000):
        result += (i * value) % 97

    return result

if __name__ == "__main__":
    values = [10, 20, 30, 40]

    with ProcessPoolExecutor(max_workers=4) as executor:
        results = list(executor.map(compute, values))

    print(results)

Output:
[239999968, 239999917, 239999963, 239999912]

Each input can be processed independently, making the workload suitable for distribution across multiple processes. In a real application, the actual performance improvement should be measured because process startup, task serialization, memory usage, and the amount of computation can all influence the final result. A common mistake is to use multiprocessing simply because a task appears slow. If most of that time is spent waiting for an external resource, additional processes may increase complexity without addressing the underlying bottleneck. Similarly, adding threads to a CPU-bound Python function does not automatically make that function execute in parallel across multiple cores.


Profiling should therefore come before optimization. Tools such as timeit, cProfile, and application-specific monitoring can help identify the actual bottleneck. It is also possible to combine approaches. A program might use multiple processes for CPU-intensive work while each process handles its own I/O concurrently with threads. Such designs can be effective for complex workloads, but they also introduce additional architectural complexity and should be used when the workload genuinely benefits from the combination.


Conclusion

Multiprocessing and multithreading are ultimately different tools for different execution problems. Multithreading provides lightweight concurrency inside a shared process and is particularly useful for I/O-bound applications. Multiprocessing creates independent processes and is particularly useful when computational work needs to run in parallel across CPU resources. Understanding Python's execution model, especially the role of the GIL, makes this distinction much clearer. Instead of choosing a concurrency model based only on the number of available cores or the apparent simplicity of an API, the better approach is to identify the workload, measure its bottlenecks, and then select the model that matches those characteristics.

For Python developers, this distinction becomes increasingly important as applications grow from simple scripts into systems that perform network operations, data processing, background jobs, and computational workloads at scale. Once the difference between threads and processes is clear, tools such as concurrent.futures, multiprocessing, threading, and asyncio can be selected more deliberately as part of a broader Python concurrency strategy.

Get in touch for customized mentorship, research and freelance solutions tailored to your needs.

bottom of page