Привет! Меня зовут Никита Соболев, я core-разработчик языка программирования CPython, а так же автор серии видео про его устройство.

Я продолжаю свой цикл статей на хабре про детали реализации питона. Сегодня поговорим про субинтерпертаторы, их устройство, прошлое и, надеюсь, светлое будущее.

Под катом будет про: новые питоновские API для ускорения и паралеллизации ваших програм, про управление памятью, про дублирование данных. Ну и много C кода!

Чтобы разобраться в вопросе и рассказать вам, я сделал несколько важных шагов: прочитал почти весь код данной фичи, начал коммить в субинтерпретаторы и взял интервью у автора данного проекта. Интервью доступно с русскими и английскими субтитрами. А еще я добавил кучу контекста прямо в видео. Ставьте на паузу и читайте код.

Если вам такое интересно или целиком незнакомо – добро пожаловать!


Что добавили в 3.14?

В Python добавили две важные новые части. Первая часть – concurrent.interpreters

>>> from concurrent import interpreters

>>> interp = interpreters.create()
>>> interpreters.list_all()
[Interpreter(0), Interpreter(1)]

>>> def worker(arg: int) -> None:
...     print('do some work', arg)
...     
>>> interp.call(worker, 1)  # запускаем код в сабинтерпретаторе
do some work 1

Она являет собой Python АПИ для удобной работы с субинтерпретаторами. Его пока не так много, но уже можно делать полезные вещи. Скорее всего – вам оно не пригодится прямо в таком виде. Данное АПИ скорее для разработчиков библиотек. Аналогия: как часто вы используете модуль threading напрямую?

Вторая часть – concurrent.futures.InterpreterPoolExecutor, который является аналогом concurrent.futures.ThreadPoolExecutor. Можно запускать какую-то работу полностью паралелльно:

>>> from concurrent.futures import InterpreterPoolExecutor

>>> CPUS = 4
... with InterpreterPoolExecutor(CPUS) as executor:
...     list(executor.map(worker, [1, 2, 3, 4, 5, 6]))
...     
do some work 1
do some work 2
do some work 4
do some work 3
do some work 5
do some work 6
[None, None, None, None, None, None]

Тут уже интереснее. Данный АПИ можно и нужно использовать, если хочется распараллелить что-то на много субинтерпретаторов.

А теперь поговорим – как оно работает?

Сравнение субинтерпретаторов с threading / multiprocessing / free-threading / asyncio

Многозадачность (точнее её отсутсвие) – долгое время была слабой стороной CPython. Было предпринято много разных попыток обойти ограничения GIL и решить проблему для разных случаев.

Первый способ – threading (в режиме с GIL по-умолчанию), который замечательно работает, если вы вызывали какой-то бинарный код, который через C-API отпускал GIL. Как можно отпустить GIL?

Например, модуль mmap делает так:

Py_BEGIN_ALLOW_THREADS
m_obj->data = mmap(NULL, map_size, prot, flags, fd, offset);
Py_END_ALLOW_THREADS

Так как у нас есть специальное C-API для управления PyThreadState, любой может вызывать необходимые функции и сделать программу быстрее. Но так делают не все. А еще так нельзя делать в Python коде. Оттого threading в CPython имел ограниченную полезность. Более того: треды требуют примитивов синхронизации для контроля доступа к данным.

Потому появился второй способ – multiprocessing. Который уже создавал новые полноценные Python процессы со своей собственной памятью. Да, можно кое-что шарить. Но все равно затраты на создания нового процесса и *N потребление памяти – существенная проблема. Например, если ваш датасет и так весит 2ГБ, его очень сложно взять и продублировать в соседний процесс на еще +2ГБ.

Следующий способ – asyncio. Который потребовал от нас переписать весь питон, получить проблему цвета функций, но не решил фундаментальных проблем. Он все еще работал в рамках одного GIL, в рамках одного потока, в рамках одного процесса. Никак не помогал с CPU-bound задачами. Финальным гвоздем в крышку гроба asyncio стало то, что не было продумано, как оно на самом деле должно работать. Отсутствие понятных примитивов, явных cancelation-scopes, интроспекции (добавят в 3.14). И еще неоптимальная производительность, много плохих АПИ, досягаемые пределы масштабирования, сложная ментальная модель, плохая интеграция с потоками, и сложности с примитивами синхронизации.

Появление free-threading не стало неожиданностью. Потому что треды дешевы в создании, не требуют копирования данных (что, с другой стороны, может приводить к мутабельному доступу, гонкам, дедлокам и прочему веселью многопоточности). Теперь можно выключать GIL и получать накладные расходы на каждый объект при использовании free-threading. А так же нужно полностью обмазывать свой код threading.Lock и прочими мьютексами. Но все равно будут места, даже прямо в builtins, которые нельзя физически спрятать под одну критическую секцию (мьютекс). Что означает, что гонки будут даже в билтинах. Пример: обычные итераторы в режиме --disable-gil:

import concurrent.futures

N = 10000
for _ in range(100):
    it = iter(range(N))
    with concurrent.futures.ThreadPoolExecutor() as executor:
        data = set(executor.map(lambda _: next(it), range(N)))
        assert len(data) == N, f"Expected {N} distinct elements, got {len(data)}"

# Traceback (most recent call last):
# File "<python-input-0>", line 8, in <module>
#   assert len(data) == N, f"Expected {N} distinct elements, got {len(data)}"
#          ^^^^^^^^^^^^^^
# AssertionError: Expected 10000 distinct elements, got 9999

Стреляем себе в ноги с N рук!

Так что – субинтерпретаторы выглядят очень хорошим решением. Они достаточно дешевы в запуске (но можно сделать еще быстрее и лучше в будущем, о чем есть в видео), каждый из них работает со своим GIL, внутри пользовательского кода нет локов и мутации данных, подходит для CPU и IO bound задач, есть шаринг данных без копирования (как просто переиспользование иммутабельных и immortal типов, таких как int, так и специальная магия вокруг использования memoryview), и будет еще больше способов в ближайших релизах.

Пример магии с memoryview и любыми другими баферами:

>>> from concurrent import interpreters
>>> interp = interpreters.create()
>>> queue = interpreters.create_queue()

>>> b = bytearray(b'123')
>>> m = memoryview(b)
>>> queue.put_nowait(m)
>>> interp.exec('(m := queue.get_nowait()); print(m); m[:] = b"456"')  # changing memory directly
<memory at 0x103274940>

>>> b  # was changed in another interpreter!
bytearray(b'456')

Там один и тот же объект! Копирования не было. Что позволит нам шарить, например, np.array или любые другие баферы. Выглядит очень перспективно. Ну и, конечно, сверху можно накрутить и модель акторов (о чем тоже говорим в видео), и CSP как в Go.

Что такое субинтерпретаторы?

Чтобы понять, что такое субинтерпретаторы, сначала нужно понять – что такое интерпретатор в CPython :)

Когда мы запускаем новый python процесс, мы начинаем выполнять код из файла pylifecycle.c, который как раз и запускает интерпретатор, а так же обрабатывает все другие вопросы жизненного цикла. Там есть вот такая функция:

static PyStatus
pycore_create_interpreter(_PyRuntimeState *runtime,
                          const PyConfig *src_config,
                          PyThreadState **tstate_p)
{
    PyStatus status;
    PyInterpreterState *interp;
    status = _PyInterpreterState_New(NULL, &interp);
    if (_PyStatus_EXCEPTION(status)) {
        return status;
    }
    assert(interp != NULL);
    assert(_Py_IsMainInterpreter(interp));
    _PyInterpreterState_SetWhence(interp, _PyInterpreterState_WHENCE_RUNTIME);
    interp->_ready = 1;

    status = _PyConfig_Copy(&interp->config, src_config);
    if (_PyStatus_EXCEPTION(status)) {
        return status;
    }

    /* Auto-thread-state API */
    status = _PyGILState_Init(interp);
    if (_PyStatus_EXCEPTION(status)) {
        return status;
    }

    // ...
  }

Она создаст вам основной – главный – интерпретатор, его состояние, GIL и все другие необходимые для запуска вещи. Теперь давайте внимательнее посмотрим на те самые "состояния", которые она создает / использует. Их два главных: PyThreadState (состояние потока) и PyInterpreterState (состояние интерпретатора), которое привязано к состоянию потока. Ну что я рассказываю. Вот дефиниции:

typedef struct _ts PyThreadState;
typedef struct _is PyInterpreterState;

struct _ts {
    /* See Python/ceval.c for comments explaining most fields */

    PyThreadState *prev;
    PyThreadState *next;
    PyInterpreterState *interp;

    /* The global instrumentation version in high bits, plus flags indicating
       when to break out of the interpreter loop in lower bits. See details in
       pycore_ceval.h. */
    uintptr_t eval_breaker;

    /* Currently holds the GIL. Must be its own field to avoid data races */
    int holds_gil;

    // ...
};

Внутри можно увидеть много полезного и системного. В том числе указатель на PyInterpreterState, который уже в себе содержет больше прикладных вещей. Например: свои builtins и sys, свои импорты, свой GIL в _gil (или общий в ceval в режиме PyInterpreterConfig_SHARED_GIL, см _PyEval_InitGIL) и все остальное необходимое для работы:

struct _is {
    struct _ceval_state ceval;
  
    struct _gc_runtime_state gc;

    // Dictionary of the sys module
    PyObject *sysdict;

    // Dictionary of the builtins module
    PyObject *builtins;

    struct _import_state imports;

    /* The per-interpreter GIL, which might not be used. */
    struct _gil_runtime_state _gil;

    /* cross-interpreter data and utils */
    _PyXI_state_t xi;

    // ...
};

Разобрались: вот что на самом деле такое "интерпретатор". Теперь мы понимаем, что если таких состояний будет несколько, то мы сможем создать несколько независимых (почему они будут независимыми - мы поговорим чуть позже) интерпретаторов. C-API для такого доступно уже с Python 3.10. Смотрим: https://docs.python.org/dev/c-api/init.html#sub-interpreter-support

PyInterpreterConfig config = {
    .use_main_obmalloc = 0,
    .allow_fork = 0,
    .allow_exec = 0,
    .allow_threads = 1,
    .allow_daemon_threads = 0,
    .check_multi_interp_extensions = 1,
    .gil = PyInterpreterConfig_OWN_GIL,
};

PyThreadState *tstate = NULL;  // <- 
PyStatus status = Py_NewInterpreterFromConfig(&tstate, &config);
if (PyStatus_Exception(status)) {
    Py_ExitStatusException(status);
}

В примере выше мы так же создаем новые PyThreadState и PyInterpreterState. Что в переводе с CPython на русский означает: мы создаем новый поток и новый интерпретатор внутри данного потока. И вот тут ключевая разница с "просто Python потоком". Из-за того, что здесь мы вольны выбирать тип GIL (в примере - мы используем PEP-684 Per-Interpreter GIL), то потоки будут управляться шедулером OS, а не обычной логикой GIL питона. И работать полностью паралелльно и изолировано!

Теперь осталось понять: почему оно изолировано? Как интерпертаторы не мешают друг другу? Как они достигают изоляции?

Изоляция субинтерпретаторов

Самый простой, но и самый сложный момент. Чтобы изоляция работала, нам потребовалось переписать ВСЕ встроенные C-модули, ВСЕ встроенные C-классы питона. Гигантский объем работы, который затрагивал буквально всю стандартную библиотеку. Ну и если авторы других C-extensions хотят поддерживать SubInterpreters (или Free-Threading, кстати, тоже), им тоже нужно все переписать ?️️️️️️

Данная изоляция получилась благодаря нескольким факторам:

Разберем все на примерах. Начнем с PEP-489 – двухфазная инициализация модулей. Будем смотреть на mmap как на пример. Как он выглядел раньше?

static struct PyModuleDef mmapmodule = {  // !!!
    PyModuleDef_HEAD_INIT,
    "mmap",
    NULL,
    -1,
    NULL,
    NULL,
    NULL,
    NULL,
    NULL
};

PyMODINIT_FUNC
PyInit_mmap(void)
{
    PyObject *dict, *module;

    if (PyType_Ready(&mmap_object_type) < 0)
        return NULL;

    module = PyModule_Create(&mmapmodule);
    if (module == NULL)
        return NULL;
    dict = PyModule_GetDict(module);
    if (!dict)
        return NULL;
    PyDict_SetItemString(dict, "error", PyExc_OSError);
    PyDict_SetItemString(dict, "mmap", (PyObject*) &mmap_object_type);

    // ...

    return module;
}

Создание одного общего для всех модуля в PyInit_mmap? Нам такое не подходит!

Сейчас его дефиниция выглядит так:

static int
mmap_exec(PyObject *module)
{
    if (PyModule_AddObjectRef(module, "error", PyExc_OSError) < 0) {
        return -1;
    }

    PyObject *mmap_object_type = PyType_FromModuleAndSpec(module,
                                                  &mmap_object_spec, NULL);
    if (mmap_object_type == NULL) {
        return -1;
    }
    int rc = PyModule_AddType(module, (PyTypeObject *)mmap_object_type);
    Py_DECREF(mmap_object_type);
    if (rc < 0) {
        return -1;
    }

    // ...

    return 0;
}

static PyModuleDef_Slot mmap_slots[] = {
    {Py_mod_exec, mmap_exec},
    {Py_mod_multiple_interpreters, Py_MOD_PER_INTERPRETER_GIL_SUPPORTED},
    {Py_mod_gil, Py_MOD_GIL_NOT_USED},
    {0, NULL}
};

static struct PyModuleDef mmapmodule = {
    .m_base = PyModuleDef_HEAD_INIT,
    .m_name = "mmap",
    .m_size = 0,
    .m_slots = mmap_slots,
};

PyMODINIT_FUNC
PyInit_mmap(void)
{
    return PyModuleDef_Init(&mmapmodule);
}

Что изменилось?

  1. Несколько изменилась дефиниция static struct PyModuleDef mmapmodule, теперь мы указываем там С-слоты будущего модуля. Самый важный для нас Py_mod_exec

  2. Теперь в функцию mmap_exec, которая указана как специальный слот {Py_mod_exec, mmap_exec}, приходит уже кем-то созданный модуль, мы его просто там инициализируем. Ранее мы создавали PyObject * модуля прямо в функции PyInit_mmap из его статического глобального объекта module = PyModule_Create(&mmapmodule). Что было не повторяемо для новых копий. Именно новый АПИ с exec позволяет нам создавать новую собственную копию модуля по запросу.

  3. PyType_Ready в теле модуля больше не вызывается и создание mmap_object_spec тоже изменилось, что пригодится нам в секции про Heap Types

Функция PyInit_mmap(void) осталась для, скорее, вопросов обратной совместимости и некоторых компиляторов. Теперь там вызывается PyModuleDef_Init.

Коммит на изменения: https://github.com/python/cpython/commit/3ad52e366fea37b02a3f619e6b7cffa7dfbdfa2e

Обратите внимание, что мы явно указываем в слотах, что субинтерпретаторы поддерживаются: {Py_mod_multiple_interpreters, Py_MOD_PER_INTERPRETER_GIL_SUPPORTED}. Если бы мы попытались импортировать модуль, который не поддерживается явно, получили бы ошибку:

>>> from concurrent.interpreters import create

>>> interp = create()
>>> interp.exec('import _suggestions')
Traceback (most recent call last):
  File "<python-input-3>", line 1, in <module>
    interp.exec('import _suggestions')
    ~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/sobolev/Desktop/cpython/Lib/concurrent/interpreters/__init__.py", line 212, in exec
    raise ExecutionFailed(excinfo)
concurrent.interpreters.ExecutionFailed: ImportError: module _suggestions does not support loading in subinterpreters

Uncaught in the interpreter:

Traceback (most recent call last):
  File "<script>", line 1, in <module>
ImportError: module _suggestions does not support loading in subinterpreters

Вторая часть: изоляция стейта модуля. Убираем все глобальные переменные в специальное место. Посмотрим на примере модуля _csv. Вот так было раньше:

static PyObject *error_obj;     /* CSV exception */
static PyObject *dialects;      /* Dialect registry */
static long field_limit = 128 * 1024;   /* max parsed field size */
static PyTypeObject Dialect_Type;
// ...

Выглядит очень просто. Никаких тебе сложных АПИ. Но тут все использует глобальное состояние. А нам такое – нельзя!

Вот так стало сейчас, часть первая:

typedef struct {
    PyObject *error_obj;   /* CSV exception */
    PyObject *dialects;   /* Dialect registry */
    PyTypeObject *dialect_type;
    PyTypeObject *reader_type;
    PyTypeObject *writer_type;
    Py_ssize_t field_limit;   /* max parsed field size */
    PyObject *str_write;
} _csvstate;

static struct PyModuleDef _csvmodule;

static inline _csvstate*
get_csv_state(PyObject *module)
{
    void *state = PyModule_GetState(module);
    assert(state != NULL);
    return (_csvstate *)state;
}

static int
_csv_clear(PyObject *module)
{
    _csvstate *module_state = PyModule_GetState(module);
    Py_CLEAR(module_state->error_obj);
    Py_CLEAR(module_state->dialects);
    Py_CLEAR(module_state->dialect_type);
    Py_CLEAR(module_state->reader_type);
    Py_CLEAR(module_state->writer_type);
    Py_CLEAR(module_state->str_write);
    return 0;
}

static int
_csv_traverse(PyObject *module, visitproc visit, void *arg)
{
    _csvstate *module_state = PyModule_GetState(module);
    Py_VISIT(module_state->error_obj);
    Py_VISIT(module_state->dialects);
    Py_VISIT(module_state->dialect_type);
    Py_VISIT(module_state->reader_type);
    Py_VISIT(module_state->writer_type);
    return 0;
}

static void
_csv_free(void *module)
{
    (void)_csv_clear((PyObject *)module);
}

Тут мы просто определяем сам стейт _csvstate, описываем, как его можно получить get_csv_state, как его очистить _csv_clear, как его обходить в GC _csv_traverse. Вторая часть связана со слотами модуля, размерами стейта и его созданием:

static struct PyModuleDef _csvmodule = {
    PyModuleDef_HEAD_INIT,
    "_csv",
    csv_module_doc,
    sizeof(_csvstate), // !!! размер стейта
    csv_methods,
    csv_slots,
    _csv_traverse,  // Py_tp_traverse
    _csv_clear,  // Py_tp_clear
    _csv_free  // Py_tp_free
};

static int
csv_exec(PyObject *module) {
    PyObject *temp;
    _csvstate *module_state = get_csv_state(module);

    temp = PyType_FromModuleAndSpec(module, &Dialect_Type_spec, NULL);
    // Инициализация стейта тут:
    module_state->dialect_type = (PyTypeObject *)temp;
    if (PyModule_AddObjectRef(module, "Dialect", temp) < 0) {
        return -1;
    }

    // ...
}

Самая важная строчка тут: sizeof(_csvstate), которая показывает, сколько нужно памяти для хранения стейта модуля внутри самого объекта модуля.

Коммит на изменение: https://github.com/python/cpython/commit/6a02b384751dbc13979efc1185f0a7c1670dc349 и https://github.com/python/cpython/commit/e7672d38dc430036539a2b1a279757d1cc819af7

Как результат – каждый модуль имеет свое изолированное состояние. При создании новой копии модуля для субинтерпретатора – будет создаваться его новое внутреннее состояние.

Ну и последнее – Heap Types. Наша задача снова сделать типы не общими, а копируемыми. Убираем static типы и делаем их изолированными. Продолжим смотреть на модуль _csv, было:

static PyTypeObject Dialect_Type = {
    PyVarObject_HEAD_INIT(NULL, 0)
    "_csv.Dialect",                         /* tp_name */
    sizeof(DialectObj),                     /* tp_basicsize */
    0,                                      /* tp_itemsize */
    /*  methods  */
    (destructor)Dialect_dealloc,            /* tp_dealloc */
    0,                                      /* tp_vectorcall_offset */
    (getattrfunc)0,                         /* tp_getattr */
    (setattrfunc)0,                         /* tp_setattr */
    0,                                      /* tp_as_async */
    (reprfunc)0,                            /* tp_repr */
    0,                                      /* tp_as_number */
    0,                                      /* tp_as_sequence */
    0,                                      /* tp_as_mapping */
    (hashfunc)0,                            /* tp_hash */
    (ternaryfunc)0,                         /* tp_call */
    (reprfunc)0,                                /* tp_str */
    0,                                      /* tp_getattro */
    0,                                      /* tp_setattro */
    0,                                      /* tp_as_buffer */
    Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */
    Dialect_Type_doc,                       /* tp_doc */
    0,                                      /* tp_traverse */
    0,                                      /* tp_clear */
    0,                                      /* tp_richcompare */
    0,                                      /* tp_weaklistoffset */
    0,                                      /* tp_iter */
    0,                                      /* tp_iternext */
    0,                                          /* tp_methods */
    Dialect_memberlist,                     /* tp_members */
    Dialect_getsetlist,                     /* tp_getset */
    0,                                          /* tp_base */
    0,                                          /* tp_dict */
    0,                                          /* tp_descr_get */
    0,                                          /* tp_descr_set */
    0,                                          /* tp_dictoffset */
    0,                                          /* tp_init */
    0,                                          /* tp_alloc */
    dialect_new,                                /* tp_new */
    0,                                          /* tp_free */
};

Один большой статический глобальный объект! Ужас!

Стало:

static PyType_Slot Dialect_Type_slots[] = {
    {Py_tp_doc, (char*)Dialect_Type_doc},
    {Py_tp_members, Dialect_memberlist},
    {Py_tp_getset, Dialect_getsetlist},
    {Py_tp_new, dialect_new},
    {Py_tp_methods, dialect_methods},
    {Py_tp_dealloc, Dialect_dealloc},
    {Py_tp_clear, Dialect_clear},
    {Py_tp_traverse, Dialect_traverse},
    {0, NULL}
};

PyType_Spec Dialect_Type_spec = {
    .name = "_csv.Dialect",
    .basicsize = sizeof(DialectObj),
    .flags = (Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE | Py_TPFLAGS_HAVE_GC |
              Py_TPFLAGS_IMMUTABLETYPE),
    .slots = Dialect_Type_slots,
};

static int
csv_exec(PyObject *module) {
    PyObject *temp;
    _csvstate *module_state = get_csv_state(module);

    // Creating a real type from spec:
    temp = PyType_FromModuleAndSpec(module, &Dialect_Type_spec, NULL);
    module_state->dialect_type = (PyTypeObject *)temp;
    if (PyModule_AddObjectRef(module, "Dialect", temp) < 0) {
        return -1;
    }
    // ...
}

Теперь, вместо глобального состояния, мы создаем тип из спецификации внутри Py_mod_exec при помощи PyType_FromModuleAndSpec, сохраняем тип в стейт модуля в module_state->dialect_type. И используем уже его, где нужно. За счет чего получаем полную изоляцию – каждый субинтерпретатор будет иметь свои модули и свои типы. Удобно!

К замерам!

Какая же статья без синтентических бенчмарков с ошибками и неточностями? Вот и я так подумал. Присаживайтесь поудобнее, смотрите код бенчмарков, комментируйте. Вот тест CPU-bound задачи разными способами:

def worker_cpu(arg: tuple[int, int]):
    start, end = arg
    fact = 1
    for i in range(start, end + 1):
        fact *= i

Получаем:

Regular: Mean +- std dev: 163 ms +- 1 ms
Threading with GIL: Mean +- std dev: 168 ms +- 2 ms
Threading NoGIL: Mean +- std dev: 48.7 ms +- 0.6 ms
Multiprocessing: Mean +- std dev: 73.4 ms +- 1.5 ms
Subinterpreters: Mean +- std dev: 44.8 ms +- 0.5 ms

Субинтерпретаторы показывают лучшее время! Вот так мы их вызывали:

import os
from concurrent.futures import InterpreterPoolExecutor

WORKLOADS = [(1, 5), (6, 10), (11, 15), (16, 20)]

CPUS = os.cpu_count() or len(WORKLOADS)

def bench_subinterpreters():
    with InterpreterPoolExecutor(CPUS) as executor:
        list(executor.map(worker, WORKLOADS))

И для IO-bound задач:

def worker_io(arg: tuple[int, int]):
    start, end = arg
    with httpx.Client() as client:
        for i in range(start, end + 1):
            client.get(f'http://jsonplaceholder.typicode.com/posts/{i}')

Результаты:

Regular: Mean +- std dev: 1.45 sec +- 0.03 sec
Threading with GIL: Mean +- std dev: 384 ms +- 17 ms (~1/4 от 1.45s)
Threading NoGIL: Mean +- std dev: 373 ms +- 20 ms
Multiprocessing: Mean +- std dev: 687 ms +- 32 ms
Subinterpreters: Mean +- std dev: 547 ms +- 13 ms

Тут – free-threading значительно быстрее, но и субинтерпретаторы дают почти x3 ускорение от базового значения. Отдельно сравним с версией для asyncio:

async def bench_async():
    start, end = 1, 20
    async with httpx.AsyncClient() as client:
        await asyncio.gather(*[
            client.get(f'http://jsonplaceholder.typicode.com/posts/{i}')
            for i in range(start, end + 1)
        ])

Которая, конечно, будет победителем с 166 ms +- 13 ms.

Еще было бы интересно посмотреть замеры для работы с баферами. Код вот такой (ужасный, неоптимальный, но демонстрирующий потенциальную возможность использования шаринга таких данных):

import random
import numpy as np

data = np.array([  # PyBuffer
    random.randint(1, 1024)
    for _ in range(1_000_000_0)
], dtype=np.int32)
mv = memoryview(data)   # TODO: multiprocessing can't pickle it

def worker_numpy(arg: tuple[Any, int, int]):
    # VERY inefficient way of summing numpy array, just to illustate
    # the potential possibility:
    data, start, end = arg
    sum(data[start:end])

worker = worker_numpy
chunks_num = os.cpu_count() * 2 + 1
chunk_size = int(len(data) / num_workers)
WORKLOADS = [
    (mv, chunk_size * i, chunk_size * (i + 1))
    for i, cpu in enumerate(range(chunks_num))
]

Да, мы выбрали самый плохой способ сложения nparray, но нам важен не алгоритм сложения, а сама возможность совершать любые вычислительные операции: шеринг данных, параллелизация процесса. Мы создаем несколько чанков для сложения, удостовериваемся, что оно поддерживает PyBuffer протокол через memoryview, и отправляем считаться разными способами. Получаем:

.....................
Regular: Mean +- std dev: 109 ms +- 1 ms
.....................
Threading: Mean +- std dev: 112 ms +- 1 ms
Multiprocessing: DISQUALIFIED, my macbook exploded
.....................
Subinterpreters: Mean +- std dev: 58.4 ms +- 3.1 ms

Что показывает данный бенчмарк? Что мы можем довольно быстро пошарить бафер и сделать с ним какую-то логику. И даже без каких либо оптимизаций и со всеми накладными расходами – работает в два раза быстрее стока. Особо забавно сравнивать с multiprocessing, который чуть не взорвал мне ноут. И я не смог дождаться конца выполнения.

Есть ли способ улучшить производительность субинтерпретаторов? Конечно! Скоро увидим!

Завершение

Вот такая получилась фича. Многое мы не успели обсудить в данной статье. Например: нюансы пепо-принятия, Channels, Queues, concurrent.futures.InterpreterPool и его текущие проблемы. Однако, нельзя впихнуть в одну статью все, даже если очень хочется. Так что, остаемся на связи в следующих статьях!

Материалы, которые я использовал для написания данной статьи:

А еще можно глянуть статью одного из участников нашего чата на данную тему, с большим погружением в детали реализации: https://habr.com/ru/companies/timeweb/articles/922314

Всем большое спасибо за интерес к деталям питона, прочитать такую статью было не просто! Вы крутые!

Если вам хочется больше жести:

  • Можно подписать на мой ТГ канал, где такого очень много: https://t.me/opensource_findings

  • Посмотреть видео про глубокий питон на моем канале: https://www.youtube.com/@sobolevn

  • Поддержать мою работу над разработкой ядра CPython, видео и статьями. Если вы хотите больше хорошего технического контента: https://boosty.to/sobolevn

До новых встреч в кишках питона!

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


  1. tapeline
    17.07.2025 07:52

    Огонь статья, как всегда все очень подробно и интересно расписано! Будет еще интересно посмотреть на то, как поменяется инструментарий для веба на питоне в свете субинтерпретаторов


    1. sobolevn Автор
      17.07.2025 07:52

      спасибо! тоже очень жду actix на питоне!


  1. DrArgentum
    17.07.2025 07:52

    Отличная статья! Спасибо за упоминание)


  1. PyLounge
    17.07.2025 07:52

    Уважаемо, спасибо)


  1. m75315911
    17.07.2025 07:52

    Все написано доступным языком, спасибо!


  1. SystemSoft
    17.07.2025 07:52

    Как? Уже добавили что ли? Мне казалось то что возится с этим PEP'ом будут ещё долго...


  1. AcckiyGerman
    17.07.2025 07:52

    asyncio потребовал от нас переписать весь питон, получить проблему цвета функций, но не решил фундаментальных проблем

    Django Framework до сих пор переписывают:

    • 3.0 начали внедрять Async

    • 3.1 асинхронные представления

    • 4.1 асинхронный ORM

    • 4.2 асинхронные формы

    • 5.0 полностью асинхронные middleware

    • 5.1 асинхронные движки сессий с разноцветными модулями: get() - aget(), keys - akeys(), ... декоратор login_required может оборачивать асинхронные представления, асинхронные тесты (AsyncRequestFactory, AsyncClient)

    И всё это в добавок к поддержке и развитию старых добрых синхронных ORM, Middleware, View, шаблонов и так далее. Мейнтейнеры пять раз всё прокляли наверное.

    Воистину проще было бы с нуля написать асинхронный aDjango, да наверное и aPython (заодно и нумерацию сбросить :)


    1. sobolevn Автор
      17.07.2025 07:52

      асинхронные операции с файлами и aopen так и не добавили в сам питон. понятно почему, но все равно забавно.


      1. Megadeth77
        17.07.2025 07:52

        А почему собственно? Как с attrs почему бы не сделать что то типа aiofiles стандартом?


        1. sobolevn Автор
          17.07.2025 07:52

          ценности в том, чтобы затаскивать что-то в stdlib - не очень много. туда стоит затаскивать только штуки, которые точно не будут меняться. мы надеемся, что файловые системы когда-то смогу стать асинхронными. так что тащить в stdlib хак - не самое лучшее решение; пусть будет отдельным пакетом


          1. TimurZhoraev
            17.07.2025 07:52

            Лучше действительно такие новаторские эксперименты проводить, используя стандартные библиотеки и файловые системы как более низкоуровневый элемент или находящиеся в соседней песочнице и имитирующие то что нужно. Поэтому различные хранилища BLOB, базы данных и прочие облака фактически уже превратили всё в asyncio, вопрос только почему нет стандарта уровня posix/iso/ieee итд, пока что это не вышло за корпоративные рамки, хотя уже прошло достаточно времени.


  1. funca
    17.07.2025 07:52

    Как сабинтерпретаторы дружат с системными ресурсами, например дескрипторами файлов или мтютексами? Они утекают в сабинтрепретатор по аналогии с тредами и fork или есть магия, которая это предотвращает?


    1. sobolevn Автор
      17.07.2025 07:52

      Простите, я не понял вопрос. Мы же в рамках одного процесса работаем. Файловый дескриптор - просто int, его можно шарить между субинтерпретаторами легко. Про мьютексы - я не понимаю без примера :)


      1. funca
        17.07.2025 07:52

        Простите, я не понял вопрос. Мы же в рамках одного процесса работаем

        В примерах из статьи, пул сабинтерпретаторов создается по количеству CPU. Как происходит балансировка задач по процессорам если все это работает в рамках одного процесса?

        Файловый дескриптор - просто int, его можно шарить между субинтерпретаторами легко

        Задача в обратном - предотвратить утечку. Допустим мы открываем файл, читаем построчно и запускаем воркеры для обработки каждой строки. При этом хочется, чтобы воркеры не могли получить доступ нашему файлу, как это случается когда дескрипторы открытых файлов автоматически наследуются дочерними процессами.


        1. sobolevn Автор
          17.07.2025 07:52

          Как происходит балансировка задач по процессорам если все это работает в рамках одного процесса?

          каждый субинтерпретатор – отдельный OS тред. их работой управляет шедулер OS.

          При этом хочется, чтобы воркеры не могли получить доступ нашему файлу

          вы сами отправляете в субинтерпретатор данные. если вы что-то явно не пошарили – оно и не пошарится. не отправите файл – к нему не будет доступа


          1. funca
            17.07.2025 07:52

            каждый субинтерпретатор – отдельный OS тред

            Спасибо, теперь понятно


    1. zzzzzzerg
      17.07.2025 07:52

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