Skip to content

Evaluating Free-Threaded Python for CPU-Bound Threads

For fifteen years the answer to "can threads speed up CPU-bound Python?" was no, and every async service with a CPU-heavy step grew a process pool, pickled payloads and duplicated memory to work around it. The free-threaded build of CPython — experimental in 3.13 under PEP 703 and officially supported from 3.14 — removes the global interpreter lock, so threads in one process can execute Python bytecode on several cores at once. For an asyncio service that already offloads work with asyncio.to_thread(), that could turn a single-core bottleneck into a parallel one without changing the code. It can also expose data races that the GIL had been quietly serialising for years. This guide sets up a side-by-side evaluation: confirm the GIL is really off, measure scaling on your own workload, measure what it does to event loop lag, and hunt for the races before production finds them.

Prerequisites

  • A free-threaded interpreter, installed next to the regular one. The executable is named python3.14t (or python3.13t); with uv, uv python install 3.14t, or select the free-threaded option in the python.org installers.
  • Thread offloading basics from Threading vs Multiprocessing vs Asyncio and running blocking SDK calls with asyncio.to_thread.
  • Your real CPU-bound function and its dependency set. Synthetic loops tell you whether the interpreter scales; only your code tells you whether your service will.
  • Standard library only for the harness below.
Four CPU threads with and without the GIL 5 lanes over time. Four CPU threads with and without the GIL GIL: thread 1 run run GIL: thread 2 run run GIL: loop loop loop no GIL: threads 1-4 all four run in parallel no GIL: loop keeps serving callbacks time → With the GIL, offloaded CPU work still takes turns with the event loop thread.

1. Confirm the build and the runtime GIL state

There are two separate questions: was the interpreter built without the GIL, and is the GIL currently disabled? A free-threaded build re-enables the GIL at runtime if it imports an extension module that has not declared support for running without it, printing a RuntimeWarning. An evaluation that misses this measures the regular behaviour and concludes that free-threading "doesn't help".

import sys
import sysconfig
import warnings


def gil_report() -> dict[str, bool]:
    return {
        "free_threaded_build": bool(sysconfig.get_config_var("Py_GIL_DISABLED")),
        "gil_enabled_now": sys._is_gil_enabled(),
    }


if __name__ == "__main__":
    warnings.simplefilter("error", RuntimeWarning)   # fail loudly if an import re-enables it
    import json                                       # import your real dependencies here
    print(gil_report())

Run it with python3.14t check_gil.py and add every module your service imports. PYTHON_GIL=0 (or -X gil=0) forces the GIL to stay off even for undeclared extensions, which is useful for testing but means you are running extension code in a mode its authors have not validated.

Verify: the report prints free_threaded_build: True and gil_enabled_now: False after all imports. If an import raises the RuntimeWarning, note the module — it is either a blocker or a candidate for an upgrade.

2. Measure thread scaling on the same code

Run an identical benchmark under both interpreters, varying the number of worker threads. On the regular build the time should stay flat as threads are added; on the free-threaded build it should fall.

import sys
import time
from concurrent.futures import ThreadPoolExecutor


def burn(n: int) -> int:
    total = 0
    for i in range(n):
        total += i * i % 7
    return total


def timed(workers: int, jobs: int = 8, n: int = 3_000_000) -> float:
    with ThreadPoolExecutor(max_workers=workers) as pool:
        list(pool.map(burn, [1_000] * workers))      # warm-up: start the threads
        start = time.perf_counter()
        list(pool.map(burn, [n] * jobs))
        return time.perf_counter() - start


if __name__ == "__main__":
    print("GIL enabled:", sys._is_gil_enabled())
    for workers in (1, 2, 4, 8):
        print(f"threads={workers}: {timed(workers):.2f}s")
Measured time for eight CPU jobs A grid of 4 rows by 3 columns. Measured time for eight CPU jobs threads regular build free-threaded speed-up 1 0.88 s 0.86 s 1.0x 2 0.85 s 0.43 s 2.0x 4 0.98 s 0.24 s 4.1x 8 0.91 s 0.15 s 6.1x Measured with the step 2 benchmark on a 24-core Linux host; speed-up is regular / free-threaded.

Measured on a 24-core Linux host with CPython 3.14.6: the regular build took 0.88, 0.85, 0.98 and 0.91 seconds for one, two, four and eight threads; the free-threaded build took 0.86, 0.43, 0.24 and 0.15 seconds. Single-thread time was essentially identical between the builds on this workload. Forcing the GIL back on with PYTHON_GIL=1 on the free-threaded interpreter returned it to flat regular-build behaviour, which confirms the difference comes from the lock and not from the build.

Verify: your free-threaded times fall as threads are added, and your one-thread times on both builds are within the single-thread overhead you are willing to accept. Record both, because a workload that is mostly single-threaded pays that overhead with nothing to gain.

3. Measure what it does to the event loop

For an asyncio service the most important number is not throughput but loop responsiveness. With the GIL, CPU work in asyncio.to_thread() still competes with the loop thread for one lock, so the loop stalls while worker threads hold it. Without the GIL, the loop thread runs alongside them.

import asyncio
import time


async def main() -> None:
    loop = asyncio.get_running_loop()
    lag: list[float] = []

    async def heartbeat() -> None:
        while True:
            start = loop.time()
            await asyncio.sleep(0.01)
            lag.append(loop.time() - start - 0.01)

    hb = asyncio.create_task(heartbeat())
    start = time.perf_counter()
    await asyncio.gather(*(asyncio.to_thread(burn, 3_000_000) for _ in range(8)))
    hb.cancel()
    print(f"8 jobs via to_thread: {time.perf_counter() - start:.2f}s, "
          f"max loop lag {max(lag) * 1000:.1f} ms")


if __name__ == "__main__":
    asyncio.run(main())

On the same host, the regular build finished in 0.89 s with a maximum loop lag of 463 ms — the heartbeat, standing in for every other request, was frozen for almost half a second. The free-threaded build finished in 0.18 s with a maximum lag of 1.0 ms. That second number, not the throughput gain, is the headline for services where event loop lag drives tail latency.

Verify: export the heartbeat lag as a histogram in a staging deployment of each build under realistic traffic, and compare p99 and max rather than averages.

4. Find the races the GIL was hiding

The GIL never made Python code thread-safe, but it made many races so unlikely that they never showed up. Without it, compound operations such as self.value += 1 — a read, an add and a write — interleave freely across cores. The interpreter keeps its own structures consistent (a dict will not be corrupted), but your invariants are your problem.

import threading


class Counter:
    def __init__(self) -> None:
        self.value = 0

    def bump(self, n: int) -> None:
        for _ in range(n):
            self.value += 1                  # read, add, write: not atomic


class LockedCounter(Counter):
    def __init__(self) -> None:
        super().__init__()
        self._lock = threading.Lock()

    def bump(self, n: int) -> None:
        for _ in range(n):
            with self._lock:
                self.value += 1


def hammer(counter: Counter, threads: int = 8, n: int = 200_000) -> int:
    workers = [threading.Thread(target=counter.bump, args=(n,)) for _ in range(threads)]
    for t in workers:
        t.start()
    for t in workers:
        t.join()
    return counter.value


if __name__ == "__main__":
    print("unlocked:", hammer(Counter()), "locked:", hammer(LockedCounter()),
          "expected:", 8 * 200_000)

On the regular build both counters reached the expected 1,600,000. On the free-threaded build the unlocked counter reached 479,952 — more than two thirds of the updates were lost — while the locked counter was exact. Run the same kind of stress test against every piece of state your offloaded functions share: module-level caches, metrics counters, lazily initialised clients. The patterns in safely sharing state between async tasks and threads apply with more force once threads truly run in parallel.

Verify: your shared-state stress tests pass on the free-threaded build with many threads, and run the test suite under a thread sanitizer build if your team maintains C extensions.

Evaluating free-threading safely 5 ordered steps. Evaluating free-threading safely confirm GIL off after imports sys._is_gil_enabled() benchmark 1 to 8 threads both builds, real function measure loop lag p99 and max, not the mean stress-test shared state the GIL was hiding races deploy with a fallback processes if the GIL returns The race hunt is the step that decides whether the speed-up is safe to ship.

5. Decide per workload and keep a fallback

Free-threading is a runtime choice, so treat it like the executor choice in offloading CPU work to InterpreterPoolExecutor: make it configurable, measure it, and keep the path back open.

import os
import sys
from concurrent.futures import Executor, ProcessPoolExecutor, ThreadPoolExecutor


def make_cpu_executor(workers: int) -> Executor:
    """Threads when they can run Python in parallel, processes otherwise."""
    if not sys._is_gil_enabled() and os.environ.get("CPU_THREADS", "1") == "1":
        return ThreadPoolExecutor(max_workers=workers, thread_name_prefix="cpu")
    return ProcessPoolExecutor(max_workers=workers)

Checking sys._is_gil_enabled() at startup, after imports, means a dependency that silently re-enables the GIL falls back to the process pool instead of turning parallel work into serial work. The environment switch lets operations disable thread offloading without shipping new code if a race surfaces in production.

Verify: start the service on each interpreter and confirm the logged executor type matches the GIL state; then flip CPU_THREADS=0 on the free-threaded build and confirm it uses processes.

Verification

The evaluation is complete when you have:

  • Confirmed the GIL is off after all imports, with any extension that re-enables it listed.
  • Scaling numbers from both builds for one, two, four and eight threads on your real function, including single-thread overhead.
  • Loop lag under offloaded CPU work on both builds, measured as p99 and max.
  • Stress tests for shared state that pass on the free-threaded build at high thread counts.
  • A configurable executor that falls back to processes when the GIL is enabled or when operators turn thread offloading off.

Pitfalls & edge cases

  • Assuming the GIL is off. One import of an undeclared extension re-enables it for the whole process, and the only sign is a warning at import time. Check sys._is_gil_enabled() after startup and alert on it.
  • Measuring on a busy machine. Scaling tests on shared CI runners or laptops with frequency scaling produce noise larger than the effect. Pin cores and repeat runs, as for any concurrency benchmark.
  • Wheels that do not exist yet. Free-threaded builds use a distinct ABI tag (cp314t). A dependency without such a wheel falls back to a source build or fails to install; audit the lock file early.
  • Treating built-in atomicity as a lock. Individual operations on built-in containers are internally synchronised, but sequences of them are not. if key not in cache: cache[key] = build() still races.
  • Oversubscribing cores. With the GIL, 32 CPU threads cost little because only one ran; without it, they all compete for cores alongside the loop thread. Size CPU thread pools to the cores you intend to use.

Frequently Asked Questions

What is free-threaded Python?

It is a build of CPython compiled without the global interpreter lock, introduced experimentally in Python 3.13 by PEP 703 and officially supported from Python 3.14. Threads in one process can execute Python bytecode on multiple cores simultaneously. It ships as a separate interpreter, typically named python3.13t or python3.14t, alongside the regular build.

How do I check whether the GIL is disabled at runtime?

Call sys._is_gil_enabled(), which returns False when the GIL is off. sysconfig.get_config_var("Py_GIL_DISABLED") tells you whether the interpreter was built free-threaded. Check after importing all dependencies, because importing an extension module that does not declare free-threading support re-enables the GIL and emits a RuntimeWarning.

Does free-threaded Python make asyncio.to_thread run CPU work in parallel?

Yes, when the GIL is actually disabled. In our measurement eight CPU-bound jobs through asyncio.to_thread took 0.18 seconds on the free-threaded build against 0.89 on the regular build, and maximum event loop lag fell from 463 milliseconds to 1 millisecond because the loop thread no longer waited for the GIL.

Is my threaded code safe on free-threaded Python?

Not automatically. The interpreter keeps built-in objects internally consistent, but compound operations such as incrementing a shared attribute or check-then-set on a cache can interleave. In our stress test an unlocked counter lost more than two thirds of its updates. Protect shared state with locks and stress-test it on the free-threaded build.