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).

Medium Python 3.9+ Aug 9, 2026 Concurrency & performance 12 views 0 copies

Python code

38 lines
Python 3.9+
import 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

stdout
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

  1. Use `concurrent.futures.ThreadPoolExecutor` and `ProcessPoolExecutor` for easier pooling.
  2. 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

Run this sample

Open the browser IDE to tweak the example and see results without installing anything.

Open editor

More from Concurrency & performance

Related tutorials and quizzes for this topic.