Python Threading

Now that we’ve set the baseline, we can try to improve the speed of our code by using the Python threading library. As you’ll soon find out, threading doesn’t have much use when your software is CPU-bound, at least not on the regular Python build. That’s why I’ll also demonstrate how IO-bound software can benefit tremendously from threading, and how the newer free-threaded Python build changes the picture for CPU-bound code. If you need a reminder on the difference between IO-bound and CPU-bound, head over to my article on Python concurrency first.

A CPU-Bound Python threading example

In the following example, we use multiple threads to run our heavy function, again 80 times. Each invocation gets its own thread:

import threading
import time

# A CPU heavy calculation, just
# as an example. This can be
# anything you like
def heavy(n, myid):
    for x in range(1, n):
        for y in range(1, n):
            x**y
    print(myid, "is done")

def threaded(n):
    threads = []

    for i in range(n):
        t = threading.Thread(target=heavy, args=(500, i))
        threads.append(t)
        t.start()

    for t in threads:
        t.join()

if __name__ == "__main__":
    start = time.time()
    threaded(80)
    end = time.time()
    print("Took: ", end - start)Code language: Python (python)

This threaded version takes about 31 seconds to run on my system, with Python 3.15. The single-threaded baseline takes about 32 seconds on the same machine. If the heavy function had a lot of blocking IO, like network calls or filesystem operations, this version would be a big optimization. The reason this is not an optimization for CPU-bound functions is the Python GIL!

Despite having 80 threads, this runs roughly as fast as the baseline. The GIL lets only one thread run Python code at a time, so the threads take turns on a single CPU core. If Python didn’t have the GIL, this would be much faster. And as it happens, there is a Python without a GIL now. I’ll get to that in a moment.

An IO-Bound Python threading example

This is the GIL at work. Each thread takes turns instead of running all at once. To demonstrate that, if heavy would have been an I/O bound function, this would have given us a tremendous speed increase, we’ll create a little test! We can simulate I/O bound software by using time.sleep(). Sleep has the same effect as blocking IO: it allows the CPU to do other stuff, and return once a certain time has passed.

I modified the heavy function in the next code fragment:

import threading
import time

# An I/O intensive calculation.
# We simulate it with sleep.
def heavy(myid):
    time.sleep(2)
    print(myid, "is done")

def threaded(n):
    threads = []

    for i in range(n):
        t = threading.Thread(target=heavy, args=(i,))
        threads.append(t)
        t.start()

    for t in threads:
        t.join()

if __name__ == "__main__":
    start = time.time()
    threaded(80)
    end = time.time()
    print("Took: ", end - start)Code language: Python (python)

Even though we have 80 Python threads all sleeping for two seconds, this code still finishes in a little over two seconds (2.01 seconds on my system). A thread that sleeps or waits for I/O releases the GIL, so the other threads can run in the meantime. Sweet!

The easier way: ThreadPoolExecutor

Creating, starting and joining threads by hand works, but there’s a higher-level API in the standard library that I recommend for most cases: ThreadPoolExecutor from the concurrent.futures module. It manages a pool of worker threads for you, and it hands you the return values of your function, which plain threads don’t do:

from concurrent.futures import ThreadPoolExecutor
import time

def heavy(myid):
    time.sleep(2)
    return f"{myid} is done"

if __name__ == "__main__":
    start = time.time()
    with ThreadPoolExecutor(max_workers=20) as executor:
        results = list(executor.map(heavy, range(80)))
    end = time.time()
    print(results[:3])
    print("Took: ", end - start)Code language: Python (python)

The output:

['0 is done', '1 is done', '2 is done']
Took:  8.020226955413818Code language: Python (python)

This time, 20 threads handle the 80 tasks, so it takes four rounds of two seconds. Limiting the number of workers is often what you want in practice. If each task makes a request to a web server, you don’t want to fire off thousands of requests at once. The with block waits until all tasks are done, and executor.map() returns the results in the same order as the input.

Shared data and locks

Threads share the same memory, which makes it easy to pass data around, but it’s also dangerous. When two threads change the same variable, one can overwrite the other’s work. This is called a race condition, and the GIL does not protect you against it. In this example, four threads each add 1 to a shared balance a million times:

import threading

balance = 0
lock = threading.Lock()

def add_one(value):
    return value + 1

def deposit_many():
    global balance
    for _ in range(1_000_000):
        with lock:
            balance = add_one(balance)

threads = [threading.Thread(target=deposit_many) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(balance)  # 4000000Code language: Python (python)

With the with lock: line, only one thread at a time can read and update balance, and the result is always 4000000. Without it, my runs printed numbers between 1.9 and 2.3 million on the regular build, and around 1.1 million on the free-threaded build. You can read more about this in my article on the Python GIL.

The safest approach is to share as little as possible between threads. With ThreadPoolExecutor, let each task return its result instead of writing it to a shared variable. If threads need to pass work to each other, use a queue.Queue, which is thread-safe.

CPU-bound threads on free-threaded Python

Since Python 3.13, there’s a second build of Python without a GIL, called the free-threaded build. It started out as experimental, and it’s officially supported since Python 3.14. It’s still optional: the Python you normally download has a GIL. You can recognize the free-threaded interpreter by the t in its name, like python3.15t. The easiest way to install it is with uv:

uv python install 3.15t
uv run --python 3.15t python cpu_threads.pyCode language: Shell Session (shell)

I ran the CPU-bound example from the top of this page, and the single-threaded baseline, on both builds. This is on a laptop with a 12-core Apple M2 Max:

Regular build (python3.15)Free-threaded (python3.15t)
Baseline, one thread31.6 s29.7 s
80 threads31.0 s3.7 s

Same code, more than eight times faster. Without a GIL, the threads really run in parallel on all cores. There are some trade-offs, like slightly slower single-threaded code on some systems and C extensions that may not support it yet. I explain these in the article about the Python GIL.

Keep learning

If you’d like to learn more about Python threading, make sure to read the official documentation as well. If you can’t use the free-threaded build, there’s another way to speed up CPU-bound code on the regular build. That’s exactly what we’ll find out in the next article about Python multiprocessing.

Related articles

2 thoughts on “Python Threading”

Leave a Comment