Использование нескольких интерпретаторов в одном процессе
Использование нескольких интерпретаторов в одном процессе

Как все знают, GIL (Global Interpreter Lock) не позволяет нескольким потокам CPython выполнять CPU-bound задачи параллельно. Глобальная блокировка интерпретатора предоставляет каждому потоку лишь небольшой интервал времени для работы. При этом планирование работы потоков (какому именно потоку из ожидающих предоставить разрешение на выполнение) осуществляется планировщиком операционной системы. Интерпретатор не является полноценным планировщиком работы потоков, он делегирует эту функцию операционной системе. GIL использует мьютексы ОС для блокировки работы потоков так, чтобы в один момент времени мог выполняться только один поток из нескольких.

Таким образом, вместо эффективного параллельного выполнения потоков на разных ядрах процессора:

Параллельное выполнение двух потоков
Параллельное выполнение двух потоков

мы получаем конкурентное выполнение (в парадигме вытесняющей многозадачности), которое, в лучшем случае, может выглядеть вот так:

Конкурентное выполнение двух потоков
Конкурентное выполнение двух потоков

Расчетная задача, которая могла бы быть выполнена двумя потоками за две секунды (при параллельном выполнении на двух ядрах процессора), будет выполнена за четыре секунды (даже больше, учитывая накладные расходы операционной системы на переключение между потоками). Эта же задача, фрагментированная на четыре обособленные части, где каждую часть будет выполнять отдельный поток, могла бы быть выполнена за одну секунду (при параллельном выполнении на четырех ядрах процессора). Чем больше вычислителей — тем больше быстродействие расчетных задач, если бы не GIL. Блокировка интерпретатора не дает нам эффективно масштабироваться. Конечно, для тяжелых расчетных задач можно использовать различные специализированные библиотеки (numpy (SciPy),  PyTorch, TensorFlow), которые задействуют оптимизированные C/C++ расширения и могут выполнять CPU-bound задачи вне контекста GIL (GIL интерпретатора ограничивает только выполнение байт-кода CPython).

Но что делать, если необходимо выполнить вычисления именно в Python? У нас есть решение. Если вся проблема заключается в том, что GIL интерпретатора не позволяет эффективно работать с двумя и более потоками, давайте не будем запускать больше одного потока в одном интерпретаторе. Разделим вычислительную задачу на фрагменты и будем выполнять каждую часть в отдельном процессе. Каждый процесс имеет свой собственный интерпретатор Python, и если в процессе используется только один поток, то GIL не будет блокировать выполнение. Вместо того чтобы использовать два потока в одном процессе с общим GIL, мы создадим два независимых процесса, в каждом из которых будет свой собственный GIL и единственный поток. Все, проблема решена:

Параллельное выполнение двух процессов
Параллельное выполнение двух процессов

Так действительно поступали и продолжают так делать. Создают отдельные процессы или используют пул процессов только для того, чтобы обойти ограничение GIL и выполнять блокирующие расчетные задачи параллельно, используя сразу несколько ядер процессора. Но это вынужденная мера, своего рода "костыль", который приводит к неэффективному использованию вычислительных ресурсов. Создание процессов — это более затратная операция для ОС, чем создание потоков. Инициализация процесса требует больше времени и это потребляет больше памяти, так как процессу выделяется собственное адресное пространство. Многопроцессные программы — это про надежность, безопасность, устойчивость и масштабирование. А мы создаем и используем несколько процессов там, где можно было бы обойтись несколькими потоками. Такой способ решения проблемы GIL хоть и работает, но совершенно точно является избыточным.

Но зачем нам нужен отдельный процесс на поток, если на самом деле нам нужен лишь отдельный интерпретатор (со своим GIL) на поток. Давайте выполнять потоки в отдельных интерпретаторах, вместо отдельных процессов:

Параллельное выполнение двух потоков, каждый в отдельном интерпретаторе
Параллельное выполнение двух потоков, каждый в отдельном интерпретаторе

Процесс — один, но потоков — несколько. И между потоками нет никакого "общего" GIL, который бы ограничивал их одновременное выполнение.

У этой простой идеи долгая предыстория. Еще в версии Python 1.5 (1997) пользователи CPython могли запускать несколько интерпретаторов в одном процессе (используя C-API), хотя это создавало множеств�� проблем из-за того, что интерпретаторы совместно использовали большое количество общих данных (глобального состояния). Однако лишь в версии 3.12 интерпретаторы стали значительно более изолированными друг от друга. Изоляция интерпретаторов была направлена на достижение множества целей, включая улучшение производительности и надежности программ на Python. Но, что особенно важно, она позволила каждому интерпретатору иметь свой собственный GIL, тогда как ранее интерпретаторы использовали один общий GIL. Подробнее о предложениях и целях изоляции интерпретаторов можно прочитать в PEP 684 – A Per-Interpreter GIL.

Тема использования нескольких интерпретаторов для реализации истинного параллелизма продолжила развиваться в PEP 554 – Multiple Interpreters in the Stdlib, а затем была доработана в PEP 734. В результате этих предложений в версии 3.13 появилась встроенная низкоуровневая библиотека работы с субинтерпретаторами  _interpreters   _interpqueues, реализующая очереди для связи между изолированными субинтерпретаторами). Позже, в Python 3.14 в библиотеке concurrent.futures появился высокоуровневый пул интерпретаторов InterpreterPoolExecutor, позволяющий довольно просто запускать потоки (каждый в отдельном интерпретаторе) для параллельных вычислений.

Класс InterpreterPoolExecutor наследует пул потоков ThreadPoolExecutor, поэтому методы пула интерпретаторов будут хорошо знакомы тем, кто уже использовал пул потоков. Ключевое различие между двумя пулами в том, что InterpreterPoolExecutor для каждого рабочего потока пула создает свой собственный интерпретатор. Каждый вызов целевой задачи выполняется рабочим потоком в отдельном интерпретаторе.

Важно помнить, что каждый интерпретатор в пуле изолирован от других. Под "изоляцией" понимается, что каждый интерпретатор имеет свое собственное состояние среды выполнения и работает полностью независимо. Например:

  • Если Вы перенаправляете стандартный поток вывода sys.stdout в одном интерпретаторе, это не повлияет на другие интерпретаторы.

  • Если Вы импортируете модуль в одном интерпретаторе, он не будет автоматически доступен в других. Каждый интерпретатор должен импортировать модуль отдельно.

  • Даже встроенные модули, такие как sysbuiltins и даже наш главный модуль main, являются отдельными объектами в каждом интерпретаторе.

Изоляция также означает, что изменяемые объекты (например, списки или словари) не могут использоваться одновременно несколькими интерпретаторами. Интерпретаторы не могут напрямую делиться такими объектами или данными. Вместо этого каждый интерпретатор должен иметь свою собственную копию данных, а любые изменения между копиями нужно синхронизировать вручную. Неизменяемые объекты, такие как строки, кортежи из неизменяемых объектов и встроенные синглтоны (например, None, True, False), не имеют этих ограничений. С одной стороны, изоляция интерпретаторов усложняет пользовательский код, ведь приходится учитывать все эти ограничения. С другой стороны, это позволяет избегать многих проблем многопоточности, таких как гонки данных (race conditions).

С точки зрения изоляции, интерпретаторы похожи на процессы. Также как и для межпроцессного взаимодействия, для обмена данными между интерпретаторами необходимо использовать сериализацию данных с помощью pickle и их передачу через общий сокет или канал. Кроме этого PEP 734 предлагает высокоэффективные средства для взаимодействия и синхронизации между интерпретаторами. Одно из таких средств — специальная очередь для обмена данными между интерпретаторами, как раз используется в реализации InterpreterPoolExecutor.

Класс concurrent.futures.InterpreterPoolExecutor(max_workers=None, thread_name_prefix='', initializer=None, initargs=(), shared=None) создает пул интерпретаторов. Все аргументы, кроме shared, известны по пулу потоков:

  • max_workers, int — максимальное количество рабочих потоков в пуле.

  • thread_name_prefix, str — префикс имени рабочих потоков, который будет использоваться для именования всех потоков, создаваемых пулом.

  • initializer — вызываемый объект, который будет вызываться при инициализации каждого рабочего потока пула, перед выполнением целевой задачи.

  • initargs — кортеж аргументов, которые будут переданы в initializer при его вызове: initializer(*initargs).

  • shared — как и остальные аргументы не является обязательным, но может быть использован для совместного использования каких-то данных. Задается в виде словаря объектов, которые все интерпретаторы пула могут использовать совместно. Эти объекты добавляются в модуль main каждого интерпретатора. Помните об ограничениях изоляции, не все объекты могут быть использованы совместно. Разрешается указывать неизменяемые типы данных: встроенные синглтоны (None, True, False), строки (str), байты (bytes) и объекты  memoryview.

Хватит теории, переходим к примеру. Давайте кратно повысим скорость выполнения расчетных задач, используя несколько ядер процессора для параллельных вычислений. В качестве задачи будем использовать функцию вычисления числа Пи, используя метод Монте-Карло.

А чтобы наглядно показать эффект организации параллельных вычислений на нескольких ядрах процессора, выполним эту функцию, используя пул потоков, пул интерпретаторов и пул процессов. API всех пулов очень похожи, поэтому не должно быть проблем с пониманием примера:

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


def calculate_pi_monte_carlo(i):
    """
    Вычисляет число Пи методом Монте-Карло.

    Параметры:
    i (int): Номер вычисления.

    Возвращает:
    tuple: Номер вычисления, число Пи.
    """
    series = 10**6  # Количество случайных точек для генерации
    inside_circle = 0  # Счетчик точек, попавших внутрь единичного круга

    # Генерация случайных точек и подсчет попавших в круг
    for _ in range(series):
        x = random.uniform(-1, 1)  # Генерация случайной координаты x
        y = random.uniform(-1, 1)  # Генерация случайной координаты y
        if x**2 + y**2 <= 1:  # Проверка, находится ли точка внутри круга
            inside_circle += 1  # Увеличиваем счетчик, если точка внутри круга

    # Расчет числа Пи
    pi_estimate = (inside_circle / series) * 4
    return i, pi_estimate


def main(executor: Executor):
    start_time = time.perf_counter()
    with executor() as pool:
        results = pool.map(calculate_pi_monte_carlo, range(1, 5))
        for i, result in results:
            print(f"Вычиcление №{i}. Число Пи={result:.6f}")
    print(f"Пул {pool.__class__.__name__} выполнил расчет за {time.perf_counter()-start_time:.3f}c.")


if __name__ == '__main__':
    for executor in (ThreadPoolExecutor, InterpreterPoolExecutor, ProcessPoolExecutor):
        main(executor)

Вычиcление №1. Число Пи=3.142780
Вычиcление №2. Число Пи=3.141248
Вычиcление №3. Число Пи=3.139616
Вычиcление №4. Число Пи=3.143576
Пул ThreadPoolExecutor выполнил расчет за 2.794c.

Вычиcление №1. Число Пи=3.139680
Вычиcление №2. Число Пи=3.145072
Вычиcление №3. Число Пи=3.141972
Вычиcление №4. Число Пи=3.141936
Пул InterpreterPoolExecutor выполнил расчет за 0.924c.

Вычиcление №1. Число Пи=3.143868
Вычиcление №2. Число Пи=3.142716
Вычиcление №3. Число Пи=3.139792
Вычиcление №4. Число Пи=3.140708
Пул ProcessPoolExecutor выполнил расчет за 1.097c.

В этом примере я использовал четыре запуска целевой задачи. На моем домашнем ноутбуке восемь логических ядер, так что четыре потока в независимых интерпретаторах точно смогли выполняться параллельно (насколько это позволила операционная система). Результаты сравнительных тестов перед вами:

Сравнение работы пулов
Сравнение работы пулов

Обратите внимание, что выигрыш во времени выполнения пула интерпретаторов относительно пула процессов хоть и есть, но он не очень большой и определяется в первую очередь тем, насколько быстро ОС может создавать новые процессы. Конечно, даже улучшение на 10% — это заметный результат, но основное преимущество здесь — это существенная экономия памяти. На создание каждого нового интерпретатора тратится существенно меньше памяти, чем на создание нового процесса. Оверхед при создании интерпретатора есть, но он гораздо меньше, чем у процесса. К тому же оптимизация и улучшения суб-интерпретаторов продолжаются, в будущих версиях CPython обещают уменьшить использование памяти.

Надо ли прямо сейчас использовать новый пул и так ли он нужен? Решать вам. Одно известно точно — очень хорошо, что язык развивается и появился еще один интересный инструмент, который уже можно попробовать применить.