Although both aim to run tasks in parallel, they differ fundamentally in how they utilize system resources, especially CPU and memory.
Multithreading
Multithreading allows multiple threads to run within the same process. Threads share the same memory space, which makes communication between them fast and efficient.This is particularly useful for tasks that are I/O-bound, such as reading files, making network requests, or waiting for database responses.
In Python, multithreading is implemented using the built-in threading module.
import threading
import time
def task(name):
print(f"Thread {name} starting")
time.sleep(2)
print(f"Thread {name} finished")
t1 = threading.Thread(target=task, args=("A",))
t2 = threading.Thread(target=task, args=("B",))
t1.start()
t2.start()
t1.join()
t2.join()
print("All threads completed")
Output:
Thread A starting
Thread B starting
Thread A finished
Thread B finished
All threads completed
In this example, two threads execute concurrently. Since they share memory, threads are lightweight and quick to create.
The Global Interpreter Lock (GIL)
The GIL ensures that only one thread executes Python bytecode at a time, even on multi-core systems.This means that for CPU-bound tasks (tasks that require heavy computation), multithreading does not provide true parallelism. However, for I/O-bound tasks, threads are still highly effective because they release the GIL while waiting for external operations.
Threads are faster to create and communicate efficiently because they share memory. However, this shared memory can lead to issues like race conditions and requires synchronization mechanisms such as locks.
A simple example that clearly demonstrates the impact of the GIL is to compare a CPU-bound task with an I/O-bound task.
CPU-bound Task
The following example performs a computationally intensive task. Even though two threads are created, only one thread executes Python bytecode at a time because of the GIL, so you will typically see little or no performance improvement.import threading
import time
def cpu_task():
total = 0
for i in range(50_000_000):
total += i
start = time.time()
t1 = threading.Thread(target=cpu_task)
t2 = threading.Thread(target=cpu_task)
t1.start()
t2.start()
t1.join()
t2.join()
print(f"Time taken: {time.time() - start:.2f} seconds")
In this example, both threads compete for the GIL. Although they appear to run concurrently, only one thread executes Python bytecode at any given time, so the computation is not truly parallel.
I/O-bound Task
Now consider an I/O-bound task where each thread spends most of its time waiting.import threading
import time
def io_task(name):
print(f"{name} started")
time.sleep(3) # Simulates an I/O operation
print(f"{name} finished")
start = time.time()
t1 = threading.Thread(target=io_task, args=("Thread-1",))
t2 = threading.Thread(target=io_task, args=("Thread-2",))
t1.start()
t2.start()
t1.join()
t2.join()
print(f"Time taken: {time.time() - start:.2f} seconds")
Output
Thread-1 started
Thread-2 started
Thread-1 finished
Thread-2 finished
Time taken: 3.00 seconds
Here, each thread releases the GIL while waiting for the simulated I/O operation (time.sleep()). As a result, both threads make progress concurrently, and the total execution time is approximately 3 seconds instead of 6 seconds.
This is why multithreading is highly effective for I/O-bound workloads such as file operations, database queries, and network requests.
Although the GIL prevents multiple threads from executing Python bytecode simultaneously, it does not eliminate race conditions or make shared data automatically thread-safe.
Synchronization mechanisms such as Lock are still required when multiple threads access or modify shared state.
Understanding Multiprocessing
Multiprocessing involves running multiple processes, each with its own memory space and Python interpreter. Unlike threads, processes do not share memory, which eliminates the limitations imposed by the GIL.Python provides the multiprocessing module to create and manage processes.
from multiprocessing import Process
import time
def task(name):
print(f"Process {name} starting")
time.sleep(2)
print(f"Process {name} finished")
if __name__ == "__main__":
p1 = Process(target=task, args=("A",))
p2 = Process(target=task, args=("B",))
p1.start()
p2.start()
p1.join()
p2.join()
print("All processes completed")
Output
Process A starting
Process B starting
Process A finished
Process B finished
All processes completed
Each process runs independently, allowing true parallel execution on multiple CPU cores. Processes, while more resource-intensive, provide better isolation and are ideal for CPU-intensive workloads.
When you create threads using the threading module, you are creating multiple threads within the same Python process.
All threads share the same memory space, Python interpreter, and Global Interpreter Lock (GIL).
As a result, only one thread can execute Python bytecode at a time. However, multiple threads can efficiently handle I/O-bound tasks because they release the GIL while waiting for external operations.
On the other hand, when you create processes using the multiprocessing module:the operating system creates two separate Python processes, not threads. Each process has its own Python interpreter, memory space, main thread, and Global Interpreter Lock (GIL).p1 = Process(target=task, args=("A",)) p2 = Process(target=task, args=("B",))
Since each process has its own GIL, they can execute Python code simultaneously on different CPU cores, enabling true parallelism.
Inter-Process Communication
Since processes do not share memory, communication between them requires special mechanisms such as queues, pipes, or shared memory constructs.from multiprocessing import Process, Queue
def worker(q):
q.put("Hello from process")
q = Queue()
if __name__ == '__main__':
p = Process(target=worker, args=(q,))
p.start()
p.join()
print(q.get())
This adds complexity compared to threads, where shared memory is directly accessible.
In real-world applications, multithreading is commonly used in web servers, where multiple requests need to be handled simultaneously.
Multiprocessing is often used in data processing pipelines, scientific computing, and machine learning tasks where heavy computations are involved.