Привет! Меня зовут Никита Соболев, я 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, кстати, тоже), им тоже нужно все переписать ?️️️️️️
Данная изоляция получилась благодаря нескольким факторам:
Двухфазной инициализации модулей: 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
До новых встреч в кишках питона!
Комментарии (29)
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
так и не добавили в сам питон. понятно почему, но все равно забавно.Megadeth77
17.07.2025 07:52А почему собственно? Как с attrs почему бы не сделать что то типа aiofiles стандартом?
sobolevn Автор
17.07.2025 07:52ценности в том, чтобы затаскивать что-то в stdlib - не очень много. туда стоит затаскивать только штуки, которые точно не будут меняться. мы надеемся, что файловые системы когда-то смогу стать асинхронными. так что тащить в stdlib хак - не самое лучшее решение; пусть будет отдельным пакетом
TimurZhoraev
17.07.2025 07:52Лучше действительно такие новаторские эксперименты проводить, используя стандартные библиотеки и файловые системы как более низкоуровневый элемент или находящиеся в соседней песочнице и имитирующие то что нужно. Поэтому различные хранилища BLOB, базы данных и прочие облака фактически уже превратили всё в asyncio, вопрос только почему нет стандарта уровня posix/iso/ieee итд, пока что это не вышло за корпоративные рамки, хотя уже прошло достаточно времени.
funca
17.07.2025 07:52Как сабинтерпретаторы дружат с системными ресурсами, например дескрипторами файлов или мтютексами? Они утекают в сабинтрепретатор по аналогии с тредами и fork или есть магия, которая это предотвращает?
sobolevn Автор
17.07.2025 07:52Простите, я не понял вопрос. Мы же в рамках одного процесса работаем. Файловый дескриптор - просто
int
, его можно шарить между субинтерпретаторами легко. Про мьютексы - я не понимаю без примера :)funca
17.07.2025 07:52Простите, я не понял вопрос. Мы же в рамках одного процесса работаем
В примерах из статьи, пул сабинтерпретаторов создается по количеству CPU. Как происходит балансировка задач по процессорам если все это работает в рамках одного процесса?
Файловый дескриптор - просто int, его можно шарить между субинтерпретаторами легко
Задача в обратном - предотвратить утечку. Допустим мы открываем файл, читаем построчно и запускаем воркеры для обработки каждой строки. При этом хочется, чтобы воркеры не могли получить доступ нашему файлу, как это случается когда дескрипторы открытых файлов автоматически наследуются дочерними процессами.
sobolevn Автор
17.07.2025 07:52Как происходит балансировка задач по процессорам если все это работает в рамках одного процесса?
каждый субинтерпретатор – отдельный OS тред. их работой управляет шедулер OS.
При этом хочется, чтобы воркеры не могли получить доступ нашему файлу
вы сами отправляете в субинтерпретатор данные. если вы что-то явно не пошарили – оно и не пошарится. не отправите файл – к нему не будет доступа
zzzzzzerg
17.07.2025 07:52Какой-то особой магии относительно системных ресурсов сейчас нет. Вы, грубо говоря, можете рассматривать приложение с субинтерпретаторами как обычное многопоточное приложение.
tapeline
Огонь статья, как всегда все очень подробно и интересно расписано! Будет еще интересно посмотреть на то, как поменяется инструментарий для веба на питоне в свете субинтерпретаторов
sobolevn Автор
спасибо! тоже очень жду actix на питоне!