Skip to content
← All Python articles

Python explainer

What Subinterpreters Cost and When They Help

About 4 min to read. Allow extra time to work through examples.

Run CPU-heavy Python on several cores with an interpreter pool, and learn what each interpreter costs, what it copies and which libraries it cannot load.

This article uses Python. You can make it your practice mode to browse related content across the site.

In the last exercise, each exec ran on your own thread, one interpreter after another. But each interpreter has its own GIL. Give each one its own thread, and they run Python on several CPU cores at the same time. InterpreterPoolExecutor, new in Python 3.14, does that for you. It has the same methods as the thread and process pools. Save this script and run it:

import time
from concurrent.futures import Executor, InterpreterPoolExecutor, ThreadPoolExecutor


def count_primes(limit: int) -> int:
    count = 0
    for n in range(2, limit):
        if all(n % d for d in range(2, int(n**0.5) + 1)):
            count += 1
    return count


def timed(pool: Executor, jobs: list[int]) -> float:
    start = time.perf_counter()
    with pool:
        list(pool.map(count_primes, jobs))
    return time.perf_counter() - start


if __name__ == "__main__":
    jobs = [200_000] * 4
    print(f"threads:      {timed(ThreadPoolExecutor(max_workers=4), jobs):.1f}s")
    print(f"interpreters: {timed(InterpreterPoolExecutor(max_workers=4), jobs):.1f}s")

On a 10-core laptop, the thread pool took about 0.6 seconds and the interpreter pool about 0.2 seconds. Your numbers will differ.

Starting an interpreter costs time

A new interpreter starts with none of your modules loaded. It imports its own copy of each module it uses, which takes time and memory. A pool pays that cost once per worker, then reuses the worker for many jobs. Creating a fresh interpreter for every request pays it every time.

An initializer function runs once in each worker, so put slow setup there. If each job takes only a millisecond, sending it to a worker can take longer than running it. Send several jobs in one submission instead.

Check your understanding Parsing a request takes about 40 ms inside a worker, but requests got slower after you started creating a new interpreter for each one. What should you measure? Show answer

Time each request from arrival to response, not only the parse. Then split that time into creating the interpreter, importing modules, sending the input, parsing, sending the result back and closing. If creation and imports take most of it, a reused InterpreterPoolExecutor pays for them once per worker instead of once per request. Compare both designs with realistic inputs and load, and watch memory as well.

Data is copied, not shared

Two interpreters in one process still do not share objects. A queue sends a copy:

from concurrent import interpreters

original = [1, 2]
queue = interpreters.create_queue()
worker = interpreters.create()
try:
    queue.put(original)
    worker.prepare_main(queue=queue)
    worker.exec("items = queue.get(); items.append(3); queue.put(items)")
    print(queue.get(timeout=5), original)  # [1, 2, 3] [1, 2]
finally:
    worker.close()

The worker changed its copy of the list, and original stayed [1, 2]. prepare_main is stricter than a queue. It takes simple values such as numbers and strings, and raises NotShareableError for a list.

A memoryview is different. It shares its bytes with the other interpreter instead of copying them. If more than one interpreter writes to it, decide who writes and when the others may read, as you did for shared memory between processes.

Know the limits

  • Some extension modules fail to import. A C extension has to declare that it supports subinterpreters. Even the standard library's readline raises ImportError: module readline does not support loading in subinterpreters. Import your real dependencies in a worker, and run a real job, before you choose an interpreter pool.
  • A crash ends every interpreter. They share one process, so a segmentation fault in one stops them all. In a process pool, the parent survives a crashed worker, though the pool breaks.
  • They are not a sandbox. Code in a subinterpreter can still open files and call the operating system.
  • os.fork() raises RuntimeError. A library that forks to start processes fails inside an interpreter worker.

Choose by measuring

For CPU-heavy pure Python with small inputs and outputs, time an interpreter pool against a process pool and against plain sequential code. For I/O-bound work, a thread pool is simpler and may be as fast. A thread pool may also be as fast for libraries such as NumPy, which release the GIL during their own work. Time the whole job, not only the function.

Going further

The free-threaded build, the subject of the next article, is the other way to run Python threads on several cores. There, threads share one interpreter and all its objects, with no GIL to take turns on.

Read the concurrent.interpreters reference, the concurrent.futures reference and PEP 734.

step 22 of 24

Parallel Python with Process Pools

Up next Free-Threaded Python in Plain Words