Привет, Хабр!
В 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. Это поможет увидеть, какие темы оказались понятными и полезными, и какие форматы занятий вызывают наибольший отклик.
ссылка на оригинал статьи https://habr.com/ru/articles/938292/
Добавить комментарий