Привет! Меня зовут Никита Соболев, я 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()

>>> 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

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

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


  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. funca
    17.07.2025 07:52

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


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

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