Привет, Хабр!

В C/C++ давно принято встраивать Python в приложения для скриптовой логики и плагинов. Именно эта экосистема много лет давала повод развивать в CPython идею нескольких изолированных интерпретаторов в одном процессе. Долгое время это было только в C‑API: создаёшь новый интерпретатор через Py_NewInterpreter, живёшь с одним общим GIL и кучей глобального состояния. В Python 3.12 появилось ключевое изменение — GIL стал на‑интерпретатор (каждый subinterpreter со своим GIL), но доступ был только через C‑API. В 3.14 подвезли полноценный высокоуровневый Python‑API: модуль concurrent.interpreters и InterpreterPoolExecutor.

Теперь можно писать параллельный код без multiprocessing, но с изоляцией уровня «почти процесс».

Дальше разберёмся, что это такое, когда это уместно вместо multiprocessing.

Что такое subinterpreters в Python

Это несколько изолированных интерпретаторов Python внутри одного процесса. У каждого свой импорт, свои модули, свой сборщик мусора и свой GIL. Независимые GIL дают честный мультикор при использовании нескольких потоков, каждый из которых переключается в свой интерпретатор. В 3.14 появился модуль concurrent.interpreters с удобными примитивами: создание интерпретаторов, запуск функций в них, безопасная очередь для обмена данными и готовый InterpreterPoolExecutor. Ограничения очевидны: между интерпретаторами нельзя свободно шарить произвольные объекты, большинство передаётся через pickle, а реально делятся»только небольшие классы объектов (например, memoryview) и сами очереди. Не все внешние модули из PyPI корректно изолированы — часть C‑расширений нуждается в доработке.

В чём выигрыш по сравнению с multiprocessing? Межинтерпретаторная очередь заметно быстрее, чем IPC между процессами; накладные на создание «воркера» обычно ниже, чем spawn процесса (особенно на платформах без fork). В замерах наблюдается 5–10× выигрыш на старте против spawn и кратное ускорение пересылки мелких сообщений. При этом, да, есть случаи, когда запуск тысячи интерпретаторов может оказаться не быстрее форка, зависит от шаблона.

Когда брать subinterpreters, а не multiprocessing

  1. Когда бьёте большую CPU‑сборку задач на независимые под‑задачи, и хотите экономнее, чем процессы: меньше памяти, быстрый обмен через очередь, нет POSIX‑ограничений по количеству процессов.

  2. Когда Windows — основной хост, а spawn мешает жить медленным стартом и необходимостью пиклить всё подряд. Здесь subinterpreters приятно экономят время старта и объём IPC.

  3. Когда нужна изоляция окружения внутри одного процесса: разные версии библиотек, разные графы импортов, отсутствие случайного конфликта глобального состояния расширений. Для авторов C‑расширений есть отдельное how‑to —https://docs.python.org/3.14/howto/isolating‑extensions.html

  4. Когда модель — CSP/actors: изолированные воркеры с message‑passing упрощают reasoning об ошибках и гонках без shared‑mutable.

Быстрый старт

InterpreterPoolExecutor даёт знакомый интерфейс concurrent.futures, но внутри использует пул интерпретаторов, а не процессы. Шапка if name == "__main__" обязательна, как и в multiprocessing, чтобы адекватно импортировать модуль в другом интерпретаторе.

# Python 3.14+
from concurrent.futures import as_completed, InterpreterPoolExecutor
import os

def cpu_task(n: int) -> int:
    # чистая математика без сторонних модулей — идеальный кандидат
    # никакого глобального состояния; аргументы и результат — pickle-friendly
    s = 0
    for i in range(1, n + 1):
        s += (i * i) ^ (i << 3)
    return s

if __name__ == "__main__":
    items = [50_000 + i * 10_000 for i in range(os.cpu_count() or 4)]
    results = []
    # подбирайте pool_size ~= числу ядер; пул лучше не пересоздавать
    with InterpreterPoolExecutor(max_workers=len(items)) as pool:
        futs = [pool.submit(cpu_task, n) for n in items]
        for f in as_completed(futs):
            results.append(f.result())
    print(sum(results))

В отличие от ProcessPoolExecutor, ничего не форкается, нет внешних процессов, а вычисления идут в нескольких потоках — каждый поток закреплён за своим интерпретатором с собственным GIL. В итоге пайтон масштабируется по ядрам без плясок со multiprocessing IPC. Фича официально в 3.14.

Здесь и дальше подразумеваем, что вы на 3.14+.

Обмен данными: Queue между интерпретаторами

Для message‑passing есть кросс‑интерпретаторная очередь с интерфейсом queue.Queue. Она быстрее межпроцессной очереди и позволяет посылать базовые типы, а также разделять буферы через memoryview.

from concurrent import interpreters
from threading import Thread

def worker_main(q_in_id: int, q_out_id: int) -> None:
    # Эта функция выполняется в другом интерпретаторе
    from concurrent import interpreters as _i
    q_in = _i.Queue(q_in_id)
    q_out = _i.Queue(q_out_id)
    total = 0
    while True:
        item = q_in.get()
        if item is None:
            break
        # item может быть int/bytes/str/tuple, а может быть memoryview
        if isinstance(item, memoryview):
            total += sum(item)  # доступ к общему буферу без копий
        else:
            total += int(item)
    q_out.put(total)

if __name__ == "__main__":
    q_in = interpreters.create_queue()
    q_out = interpreters.create_queue()

    interp = interpreters.create()
    interp.prepare_main({"q_in_id": q_in.id, "q_out_id": q_out.id})

    # Запускаем воркера в отдельном ОС-потоке, привязанном к другому интерпретатору
    t = interp.call_in_thread(worker_main, q_in.id, q_out.id)

    # Шлём данные: ints и memoryview поверх общего байтового буфера
    import array
    buf = array.array("B", bytes(range(128)))
    q_in.put(10)
    q_in.put(20)
    q_in.put(memoryview(buf))
    q_in.put(None)  # сигнал завершения

    t.join()
    print("sum =", q_out.get())

prepare_main привязывает объекты к main в целевом интерпретаторе. Очереди и memoryview допускают псевдо‑шаринг: это не копия через pickle, а обмен. В документации прямо перечисляют типы, которые копируются/шарятся, и ограничивает это списком. Если вы попытаетесь сунуть туда толстый объект, получите NotShareableError.

Держите протокол сообщений примитивным — числа, байты, кортежи из простых типов. Меньше неожиданностей, меньше скрытых затрат на сериализацию.

Работа с кодом: call, call_in_thread, exec, preloading

concurrent.interpreters поддерживает несколько сценариев запуска: синхронный call в текущем потоке с переключением контекста, call_in_thread с созданием нового потока, а также exec для исполнения строки кода. В проде я бы ограничился вызовом заранее импортированных функций и явным prepare_main.

from concurrent import interpreters

def heavy_import() -> None:
    # пример явной инициализации в целевом интерпретаторе
    import math  # локальный импорт, попадёт только туда

def run_job(x: int) -> int:
    import math
    return math.isqrt(x) ** 2

if __name__ == "__main__":
    interp = interpreters.create()
    # прогреваем окружение — импортируем что нужно в __main__ целевого интерпретатора
    interp.call(heavy_import)
    # теперь запускаем задачу
    res = interp.call(run_job, 123456789)
    print(res)
    interp.close()

Почему не exec с фрагментами? Потому что контроль сложнее, а рисков больше. Тем не менее, exec бывает полезен, например, для загрузки одного файла как модуля без загрязнения main. Если нужен запуск из файла, используйте runpy.run_path под капотом целевого интерпретатора.

Типичные проблемы и как их обходить

  1. Не все C‑расширения безопасны в нескольких интерпретаторах. Модулям нужен per‑module state, без глобалей, и желательно heap‑types вместо статических типов. Есть подробный how‑to (ссылка выше). Если зависимость не готова, возможны крэши или странные эффекты при двойном импорте.

  2. Передача объектов. По дефолту — pickle. Большие графы объектов тормозят. Планируйте протокол обмена. Когда нужно быстро, используйте memoryview и блочные байтовые буферы, а не списки из миллионов чисел.

  3. Старт/останов. Создавайте интерпретаторы заранее и переиспользуйте. Эмпирика показывает, что многократный старт/стоп хуже пула; плюс запуск тысячами не самоцель и иногда проигрывает fork в микробенчмарках. Дёшево только в правильном режиме эксплуатации.

  4. Guard в main. Всё так же обязателен. Нарушите — отстрелите себе ногу странными импорт‑эффектами и «не найденной» функцией в другом интерпретаторе.

Сравнение с multiprocessing

multiprocessing удобен, когда нужен процессный барьер: отдельное адресное пространство, чёткая граница по ресурсам, полноценные IPC и совместимость с миром POSIX. Но за это платим:

  • Старт‑метод. На Linux часто быстро за счёт fork (copy‑on‑write). На macOS и Windows — spawn, это медленно и требует пиклить всё целиком. Существует forkserver как компромисс.

  • IPC. Очереди и пайпы — это сериализация, контекстные переключения и ядро. Для мелких сообщений дорого. Есть shared_memory с ручным менеджментом, но это уже другой класс задач.

  • Совместимость. Не пиклится — проблема. Плюс разные дефолты на разных платформах.

У subinterpreters обмен быстрее, старт дешевле, а код проще переносить между ОС, при этом изоляция на уровне интерпретатора закрывает большинство проблем с глобальным состоянием. Минусы — не все расширения готовы, есть ограничения на типы для обмена, а «безопасность как у процессов» это не про них: внутри одного процесса границы слабее.

Пул интерпретаторов + очереди

Скетч, который можно адаптировать под бэкенд обработки заданий — быстрый обмен, явная инициализация, корректное завершение.

from concurrent.futures import InterpreterPoolExecutor, wait, FIRST_EXCEPTION
from concurrent import interpreters
import os
import signal

def init_worker(env):
    # лёгкая инициализация под конкретное окружение интерпретатора
    import math
    globals().update(env)

def do_job(payload: bytes) -> bytes:
    # здесь полезная работа; аргументы и результат — байты
    # тяжёлые структуры — через memoryview/Queue
    return payload[::-1]

def runner(job_q_id: int, res_q_id: int, env: dict) -> None:
    from concurrent import interpreters as _i
    _i.get_current()  # просто чтобы явно подтянуть модуль
    init_worker(env)
    job_q = _i.Queue(job_q_id)
    res_q  = _i.Queue(res_q_id)
    while True:
        item = job_q.get()
        if item is None:
            break
        jid, payload = item
        res = do_job(payload)
        res_q.put((jid, res))

if __name__ == "__main__":
    job_q = interpreters.create_queue()
    res_q = interpreters.create_queue()

    # prewarm: отдельные интерпретаторы подготовит пул
    workers = os.cpu_count() or 4
    with InterpreterPoolExecutor(max_workers=workers) as pool:
        # поднимаем воркеров
        env = {"factor": 42}
        boots = [pool.submit(runner, job_q.id, res_q.id, env) for _ in range(workers)]

        # шлём задания
        for jid in range(100):
            job_q.put((jid, f"job-{jid}".encode()))

        # сигнал окончания каждому воркеру
        for _ in range(workers):
            job_q.put(None)

        # собираем результаты
        done = 0
        while done < 100:
            jid, data = res_q.get()
            # обработка результата...
            done += 1

        # ждём корректного выхода
        wait(boots, return_when=FIRST_EXCEPTION)

Здесь нет тяжёлого сериализатора поверх IPC, нет форков, нет зависимостей от внешних лимитов систем (типа ulimit по процессам), а протокол — строго байты/примитивы. Такой эскиз хорошо масштабируется по ядрам под CPU‑bound нагрузкой, если не лезть глубоко в расширения, которые не умеют жить в нескольких интерпретаторах.


Итог

Subinterpreters в 3.14 — это рабочая альтернатива multiprocessing для задач, где важны изоляция импортов, честный мультикор и быстрый обмен без межпроцессного IPC. Берём InterpreterPoolExecutor для знакомого интерфейса, используем create_queue и memoryview для дешёвого обмена, не надеемся на магию и проверяем сторонние C‑расширения на изоляцию. Если цель — скорость с контролируемой сложностью и без проблем spawn/fork, subinterpreters сейчас выглядят очень даже хорошим выбором.

Если после знакомства с продвинутыми возможностями Python, такими как subinterpreters, важно закрепить базовые навыки и построить прочный фундамент, стоит обратить внимание на курс Python Developer. Basic. Он охватывает ключевые элементы языка, работу с функциями, модулями и базовыми структурами данных.

Также для тех, кто хочет попробовать Python на практике, доступны бесплатные открытые уроки, где демонстрируются решения задач, подобных тем, что обсуждались в статье.

А если интересно, как другие участники осваивали Python с нуля, стоит ознакомиться с отзывами по курсу Python Developer. Basic. Это поможет увидеть, какие темы оказались понятными и полезными, и какие форматы занятий вызывают наибольший отклик.

Комментарии (0)