Привет! Меня зовут Никита Соболев, я 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, кстати, тоже), им тоже нужно все переписать ?️️️️️️
Данная изоляция получилась благодаря нескольким факторам:
Двухфазной инициализации модулей: https://peps.python.org/pep-0489
Изоляции модулей при помощи ModuleState: https://peps.python.org/pep-0687
Рефакторингу статичных типов в Heap-Types: https://docs.python.org/3.11/howto/isolating-extensions.html#heap-types
Разберем все на примерах. Начнем с 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);
}
Что изменилось?
Несколько изменилась дефиниция
static struct PyModuleDef mmapmodule
, теперь мы указываем там С-слоты будущего модуля. Самый важный для насPy_mod_exec
Теперь в функцию
mmap_exec
, которая указана как специальный слот{Py_mod_exec, mmap_exec}
, приходит уже кем-то созданный модуль, мы его просто там инициализируем. Ранее мы создавалиPyObject *
модуля прямо в функцииPyInit_mmap
из его статического глобального объектаmodule = PyModule_Create(&mmapmodule)
. Что было не повторяемо для новых копий. Именно новый АПИ с exec позволяет нам создавать новую собственную копию модуля по запросу.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://t.me/opensource_findings/916
Мой пост про
concurrent.interpreters.Queue
: https://t.me/opensource_findings/918Multiple Interpreters in the Stdlib: https://peps.python.org/pep-0554
Older PEP Multiple Interpreters in the Stdlib: https://peps.python.org/pep-0734
A Per-Interpreter GIL: https://peps.python.org/pep-0684
Multi-phase extension module initialization: https://peps.python.org/pep-0489
A ModuleSpec Type for the Import System: https://peps.python.org/pep-0451
Isolating modules in the standard library: https://peps.python.org/pep-0687
Module State Access from C Extension Methods: https://peps.python.org/pep-0573
Immortal Objects, Using a Fixed Refcount: https://peps.python.org/pep-0683
Документация
concurrent.interpreters
: https://docs.python.org/3.14/library/concurrent.interpreters.html#module-concurrent.interpretersДокументация
InterpreterPoolExecutor
: https://docs.python.org/3.14/library/concurrent.futures.html#concurrent.futures.InterpreterPoolExecutor
А еще можно глянуть статью одного из участников нашего чата на данную тему, с большим погружением в детали реализации: https://habr.com/ru/companies/timeweb/articles/922314
Всем большое спасибо за интерес к деталям питона, прочитать такую статью было не просто! Вы крутые!
Если вам хочется больше жести:
Можно подписать на мой ТГ канал, где такого очень много: https://t.me/opensource_findings
Посмотреть видео про глубокий питон на моем канале: https://www.youtube.com/@sobolevn
Поддержать мою работу над разработкой ядра CPython, видео и статьями. Если вы хотите больше хорошего технического контента: https://boosty.to/sobolevn
До новых встреч в кишках питона!
Комментарии (10)
SystemSoft
17.07.2025 07:52Как? Уже добавили что ли? Мне казалось то что возится с этим PEP'ом будут ещё долго...
AcckiyGerman
17.07.2025 07:52asyncio потребовал от нас переписать весь питон, получить проблему цвета функций, но не решил фундаментальных проблем
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 (заодно и нумерацию сбросить :)
sobolevn Автор
17.07.2025 07:52асинхронные операции с файлами и
aopen
так и не добавили в сам питон. понятно почему, но все равно забавно.
funca
17.07.2025 07:52Как сабинтерпретаторы дружат с системными ресурсами, например дескрипторами файлов или мтютексами? Они утекают в сабинтрепретатор по аналогии с тредами и fork или есть магия, которая это предотвращает?
sobolevn Автор
17.07.2025 07:52Простите, я не понял вопрос. Мы же в рамках одного процесса работаем. Файловый дескриптор - просто
int
, его можно шарить между субинтерпретаторами легко. Про мьютексы - я не понимаю без примера :)
tapeline
Огонь статья, как всегда все очень подробно и интересно расписано! Будет еще интересно посмотреть на то, как поменяется инструментарий для веба на питоне в свете субинтерпретаторов
sobolevn Автор
спасибо! тоже очень жду actix на питоне!