Привет, Хабр!
В 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
Когда бьёте большую CPU‑сборку задач на независимые под‑задачи, и хотите экономнее, чем процессы: меньше памяти, быстрый обмен через очередь, нет POSIX‑ограничений по количеству процессов.
Когда Windows — основной хост, а
spawn
мешает жить медленным стартом и необходимостью пиклить всё подряд. Здесь subinterpreters приятно экономят время старта и объём IPC.Когда нужна изоляция окружения внутри одного процесса: разные версии библиотек, разные графы импортов, отсутствие случайного конфликта глобального состояния расширений. Для авторов C‑расширений есть отдельное how‑to —https://docs.python.org/3.14/howto/isolating‑extensions.html
Когда модель — 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
под капотом целевого интерпретатора.
Типичные проблемы и как их обходить
Не все C‑расширения безопасны в нескольких интерпретаторах. Модулям нужен per‑module state, без глобалей, и желательно heap‑types вместо статических типов. Есть подробный how‑to (ссылка выше). Если зависимость не готова, возможны крэши или странные эффекты при двойном импорте.
Передача объектов. По дефолту — pickle. Большие графы объектов тормозят. Планируйте протокол обмена. Когда нужно быстро, используйте
memoryview
и блочные байтовые буферы, а не списки из миллионов чисел.Старт/останов. Создавайте интерпретаторы заранее и переиспользуйте. Эмпирика показывает, что многократный старт/стоп хуже пула; плюс запуск тысячами не самоцель и иногда проигрывает
fork
в микробенчмарках. Дёшево только в правильном режиме эксплуатации.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. Это поможет увидеть, какие темы оказались понятными и полезными, и какие форматы занятий вызывают наибольший отклик.