Python threads
single core execution
GIL limitations
threading in Python
concurrency in Python

Python threads all executing on a single core

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

Introduction

If you start several Python threads and your CPU usage still looks like one core, you are usually seeing the effect of the Global Interpreter Lock, or GIL, in standard CPython. Threads still help for many programs, but they do not automatically give CPU-bound Python code true parallel execution.

What the GIL Actually Does

In the default CPython runtime, only one thread can execute Python bytecode at a time inside a process. The interpreter switches between threads frequently, which creates concurrency, but not full parallelism for CPU-heavy pure Python work.

That means a program like this does not scale across cores the way people often expect:

python
1import threading
2
3def cpu_task():
4    total = 0
5    for i in range(20_000_000):
6        total += i * i
7    print(total)
8
9threads = [threading.Thread(target=cpu_task) for _ in range(4)]
10
11for thread in threads:
12    thread.start()
13
14for thread in threads:
15    thread.join()

All four threads run, but they mostly take turns holding the GIL. On a multi-core machine, this often looks like one saturated core rather than four.

When Threads Still Help

Threads are still useful for I/O-bound workloads because waiting on the network, disk, or a socket gives other threads a chance to run. For example:

python
1import threading
2import time
3
4def io_task(name):
5    print(f"{name} starting")
6    time.sleep(2)
7    print(f"{name} finished")
8
9threads = [threading.Thread(target=io_task, args=(f"job-{i}",)) for i in range(4)]
10
11start = time.perf_counter()
12for thread in threads:
13    thread.start()
14for thread in threads:
15    thread.join()
16
17print(f"elapsed={time.perf_counter() - start:.2f}s")

This finishes in about two seconds, not eight, because the threads overlap while sleeping. The same pattern applies to HTTP requests, file downloads, and database calls.

How To Use Multiple Cores for CPU Work

For CPU-bound tasks, use separate processes instead of threads. Each process has its own Python interpreter and its own GIL, so the operating system can schedule them on different cores.

python
1from multiprocessing import Pool
2
3def cpu_task(n):
4    total = 0
5    for i in range(n):
6        total += i * i
7    return total
8
9if __name__ == "__main__":
10    with Pool(processes=4) as pool:
11        results = pool.map(cpu_task, [20_000_000] * 4)
12    print(results)

This is the standard fix when you want actual core-level parallelism in Python.

Cases Where Threads Can Run Better Than Expected

Some extension modules release the GIL while doing heavy native work. Numeric libraries are a common example. If a C or C++ extension drops the GIL, multiple threads may make progress at the same time even though the surrounding Python program is threaded.

That is why the right answer depends on where the real work happens:

  • pure Python loops: threads usually do not scale across cores
  • blocking I/O: threads are often fine
  • native extensions that release the GIL: threads may scale better

So the question is not only "am I using threads," but also "what code is running inside those threads."

A Practical Rule of Thumb

Pick the concurrency tool based on the bottleneck:

  • use threading for I/O-bound work
  • use multiprocessing or process pools for CPU-bound pure Python work
  • use asyncio when you have many I/O tasks and want structured async flow

You can also use concurrent.futures for a cleaner interface:

python
1from concurrent.futures import ProcessPoolExecutor
2
3def cpu_task(n):
4    return sum(i * i for i in range(n))
5
6if __name__ == "__main__":
7    with ProcessPoolExecutor() as executor:
8        futures = [executor.submit(cpu_task, 10_000_000) for _ in range(4)]
9        print([future.result() for future in futures])

This gives you process-based parallelism without manually managing worker lifecycles.

Common Pitfalls

The most common mistake is benchmarking CPU work with threading and assuming Python is ignoring available cores. In standard CPython, that result is expected.

Another mistake is using threads to speed up a tight numeric loop written in Python. If the code does not release the GIL, you may add context-switch overhead without gaining throughput.

It is also easy to confuse concurrency with parallelism. Threads can improve latency and responsiveness even when only one thread executes Python bytecode at a time.

Finally, remember that process-based solutions have costs too: data must be serialized between processes, startup is slower, and shared mutable state becomes harder to manage.

Summary

  • In standard CPython, the GIL prevents multiple threads from executing Python bytecode in parallel inside one process.
  • Threads are still effective for I/O-bound workloads because they overlap waiting time.
  • For CPU-bound pure Python code, use multiple processes to take advantage of multiple cores.
  • Some native extensions release the GIL, so threaded performance depends on where the work actually happens.
  • Choose concurrency tools based on the real bottleneck, not on threads alone.

Related reading
Free course
Beginner
7 lessons
2 hours
Tackling System Design Interview Problems

A short course that equips you with the skills to approach system design interviews methodically.

Start the free course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

All Rights Reserved.