How to Demonstrate the GIL with Python Threads vs Processes
Measure and compare wall-clock time for CPU-bound work using Python threads (limited by the GIL) versus multiprocessing (which bypasses the GIL).
Python code
38 linesimport threading
import multiprocessing
import time
import os
def cpu_heavy(n):
return sum(i * i for i in range(n))
def run_threads(n):
threads = [threading.Thread(target=cpu_heavy, args=(n,)) for _ in range(2)]
start = time.perf_counter()
for t in threads:
t.start()
for t in threads:
t.join()
return time.perf_counter() - start
def run_processes(n):
processes = [multiprocessing.Process(target=cpu_heavy, args=(n,)) for _ in range(2)]
start = time.perf_counter()
for p in processes:
p.start()
for p in processes:
p.join()
return time.perf_counter() - start
if __name__ == "__main__":
work = 30_000_000
thread_time = run_threads(work)
process_time = run_processes(work)
print(f"Threads time: {thread_time:.4f}s | Processes time: {process_time:.4f}s")
print(f"Processes speedup: {thread_time / process_time:.2f}x")
print("GIL limits CPU-bound threads; processes bypass it.")
Output
Threads time: 4.5231s | Processes time: 2.2810s
Processes speedup: 1.98x
GIL limits CPU-bound threads; processes bypass it.
How it works
The GIL (Global Interpreter Lock) serializes bytecode execution, so two CPU-heavy threads on a multi-core machine run nearly sequentially, offering no speedup. The threading module creates lightweight threads, but they compete for the lock and effectively share one core. multiprocessing launches separate interpreter processes, each with its own GIL and memory space, allowing genuine parallel execution across CPU cores. The timing loop uses perf_counter() to measure real elapsed time, and the speedup ratio shows the benefit of using processes for CPU-bound tasks.
Common mistakes
- Expecting threads to speed up CPU-bound work — they only help with I/O-bound tasks.
- Using `time.time()` instead of `time.perf_counter()` for precise sub-second timing.
- Forgetting the `if __name__ == '__main__':` guard, causing infinite process spawning on Windows.
- Overlooking that processes have higher memory and startup overhead.
Variations
- Use `concurrent.futures.ThreadPoolExecutor` and `ProcessPoolExecutor` for easier pooling.
- Switch to `numpy` or `Cython` to release the GIL in compute-heavy loops.
Real-world use cases
- Choosing between thread pools and process pools when parallelizing a batch CPU‑intensive data transformation.
- Deciding whether a web worker should use async I/O or multiprocessing to handle CPU-heavy requests without blocking the event loop.
- Explaining latency differences to a team when profiling a ML inference pipeline that runs many CPU-bound predictions.
Sponsored
More from Concurrency & performance
- Benchmark list.append vs deque.append in Python medium
- Build a Python Performance Profiler That Generates Readable Reports medium
- Graceful Shutdown Executor Context Manager in Python medium
- How to Build a Producer-Consumer Pattern with asyncio.Queue in Python medium
- How to Cancel an asyncio Task with Graceful Cleanup in Python medium
- How to Convert Data in Parallel with ThreadPoolExecutor in Python easy
Keep learning
Related tutorials and quizzes for this topic.