Находки в опенсорсе
10.7K subscribers
11 photos
1 video
3 files
819 links
Привет!

Меня зовут Никита Соболев. Я занимаюсь опенсорс разработкой полный рабочий день.

Тут я рассказываю про #python, #c, опенсорс и тд.
Поддержать: https://boosty.to/sobolevn
РКН: https://vk.cc/cOzn36

Связь: @sobolev_nikita
Download Telegram
Что такое GIL в Python?

Кажется, один из золотых вопросов для всех питонистов на собеседованиях.
Обычно, на встречный вопрос "а что конкретно в питоне является GIL?" не может ответить ни один спрашивающий.

Сегодня мы закроем данный пробел в знаниях питонистов.

Global Interpreter Lock не позволяет более чем одному треду работать с Python API за раз. Его можно отключить через --disable-gil в 3.13+, но сегодня мы про такое не будем.

Обратите внимание на ключевую фразу "c Python API". С системными треды могут и должны работать в режиме настоящей параллельности, без GIL. Что и позволяет получить ускорение при использовании threading, когда C код поддерживает такой способ.

Знакомьтесь – вот структура GIL _gil_runtime_state и поведение в ceval_gil.c.

Как можно отпустить GIL?

На уровне C есть макросы: Py_BEGIN_ALLOW_THREADS и Py_END_ALLOW_THREADS, которые отпускают GIL в нужных местах. Пример из модуля mmap:


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


Или time.sleep, который тоже дает работать другим тредам, пока ждет.

Что происходит, когда мы используем данный макрос? Они разворачиваются в:


{
PyThreadState *_save;
_save = PyEval_SaveThread();
// your code here
PyEval_RestoreThread(_save);
}


PyThreadState является текущим состоянием треда в CPython. Внутри хранится много контекста. Нас особо сильно интересует часть с полями про GIL:


struct PyThreadState {
struct {
unsigned int initialized:1;
/* Has been bound to an OS thread. */
unsigned int bound:1;
/* Has been unbound from its OS thread. */
unsigned int unbound:1;
/* Has been bound aa current for the GILState API. */
unsigned int bound_gilstate:1;
/* Currently in use (maybe holds the GIL). */
unsigned int active:1;
/* Currently holds the GIL. */
unsigned int holds_gil:1;
} _status;

// Thread state (_Py_THREAD_ATTACHED, _Py_THREAD_DETACHED, _Py_THREAD_SUSPENDED).
int state;

// ...
}


Когда вызывается PyEval_SaveThread и GIL отпускается, то на самом деле мы просто помечаем текущий PyThreadState как:


tstate->_status.active = 0;
tstate->_status.unbound = 1;
tstate->_status.holds_gil = 0;
tstate->state = detached_state;


И вызываем _PyEval_ReleaseLock, который уже правильно изменит _gil_runtime_state.
Как итог – текущий стейт теряет возможность вызывать какие-либо Python АПИ. Даже, например Py_DECREF, и в тредах есть свой refcount, который работает локально, чтобы можно было его вызывать без GIL.

Как треды берут GIL?

Смотрим на thread_run из _threadmodule.c.


_PyThreadState_Bind(tstate);
PyEval_AcquireThread(tstate);
_Py_atomic_add_ssize(&tstate->interp->threads.count, 1);


Там используется PyEval_AcquireThread, который берет GIL в конкретном треде для работы с Python API.
И дальше – отпускаем.

В следующих сериях поговорим про переключение тредов, ParkingLot API, Mutex'ы и прочее.
Обсуждение: сталкивались ли вы на собесах с вопросами про GIL? Стало ли теперь понятнее?

| Поддержать | YouTube | GitHub | Чат |
63👍93🔥3916🤯8👌2🥰1🤡1
unraisable exceptions в питоне

Мы все с вами привыкли, что в питоне можно "зарайзить" исключение в любой момент: raise Exception
Но, что если в какой-то момент времени мы не можем вызывать исключение?

Простейший пример: что произойдет при запуске такого скрипта?


# ex.py
class BrokenDel:
def __del__(self):
raise ValueError('del is broken')

obj = BrokenDel()
del obj

print('done!') # будет ли выведено?


Тут может быть два варианта:
1. Или del вызовет ValueError и программа завершится
2. Или случится какая-то магия, ошибка будет вызвана, напечатается, но программа продолжится

Ну и так как мы с вами на том канале, где мы с вами, то конечно же будет второй вариант.


» ./python.exe ex.py
Exception ignored while calling deallocator <function BrokenDel.__del__ at 0x10303c1d0>:
Traceback (most recent call last):
File "/Users/sobolev/Desktop/cpython/ex.py", line 3, in __del__
raise ValueError('del is broken')
ValueError: del is broken
done!


Знакомьтесь – unraisable exceptions 🤝

Как оно работает?

В некоторых местах C кода у нас есть необходимость вызывать исключения, но нет технической возможности. Пример, как выглядит упрощенный dealloc для list?


static void
list_dealloc(PyListObject *op)
{
Py_ssize_t i;
PyObject_GC_UnTrack(op); // убираем объект из отслеживания gc
if (op->ob_item != NULL) {
i = Py_SIZE(op);
while (--i >= 0) {
Py_XDECREF(op->ob_item[i]); // уменьшаем счетчик ссылок каждого объекта в списке
}
op->ob_item = NULL;
}
PyObject_GC_Del(op);
}


А, как вы можете знать, чтобы в C коде вызвать ошибку, нужно сделать две вещи:
- Взывать специальное АПИ вроде PyErr_SetString(PyExc_ValueError, "some text")
- И вернуть NULL как PyObject * из соответствующих АПИ, показывая, что у нас ошибка. Если вернуть NULL нельзя, то мы не можем поставить ошибку. А тут у нас void и вернуть вообще ничего нельзя. Потому приходится использовать вот такой подход с unraisable exception

Ошибку мы "вызываем" через специальные АПИ:
- PyErr_WriteUnraisable
- PyErr_FormatUnraisable

Они создают ошибку, но не выкидывают её обычным способом, а сразу отправляют в специальный хук-обработчик.
В питоне оно используется где-то 150 раз. То есть – прям часто. Примеры:

- Ошибки при завершении интерпретатора, попробуйте сами:


import atexit
def foo():
raise Exception('foo')
atexit.register(foo)


- Ошибки внутри sys.excepthook
- Ошибки внутри gc
- Ошибки внутри логики установки ошибок (вдруг память кончилась, например) 🌚️️️️
- И многое другое

Пользовательское АПИ

Ну и конечно же, есть специальный хук для обработки таких ошибок: sys.unraisablehook

Например, pytest использует кастомный хук, чтобы валить тесты при возникновении такой ситуации. Что логично.

Обсуждение: знали ли вы про такую особенность? Приходилось ли где-то в мониторинге особо настраивать?

| Поддержать | YouTube | GitHub | Чат |
7🤯111🔥36👍284👎1🤡1
Три типа объектов в Питоне

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

PyObject

Все мы знаем, что в питоне все объект или PyObject *, который упрощенно выглядит так (в FT сборке он посложнее):


struct _object {
Py_ssize_t ob_refcnt;
PyTypeObject *ob_type;
}


То есть: у нас есть только счетчик ссылок на объект и указатель на его тип. Первое меняется очень часто, если объект не immortal. Второе тоже можно менять в некоторых случаях:


>>> class A:
... __slots__ = ()

>>> class B:
... __slots__ = ()

>>> a = A()
>>> type(a)
<class '__main__.A'>
>>> a.__class__ = B
>>> type(a)
<class '__main__.B'>


Получается, что большинство объектов мутабельные уже на данном уровне.

Но, в целом есть три типа объектов, разные по уровню мутабельности:
1. Такие как None: ob_refcnt не меняется (immortal), тип менять нельзя, ведь Py_TPFLAGS_IMMUTABLETYPE установлен (static type), размер неизменный 0 для всех потенциальных значений
2. Такие как int: ob_refcnt может меняться для больших чисел (маленькие инты - immortal), тип менять нельзя, размер нельзя менять, но он будет разный для разных чисел:


>>> sys.getsizeof(1)
28
>>> sys.getsizeof(10000000000000)
32


3. Такие как list: ob_refcnt всегда меняется, тип менять нельзя, размер меняется

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

C-API

В C-API питона есть два полезных макроса: PyObject_HEAD для объектов фиксированного размера и PyObject_VAR_HEAD для объектов, которые могут менять размер.


struct PyVarObject {
PyObject ob_base;
Py_ssize_t ob_size; /* Number of items in variable part */
};

#define PyObject_HEAD PyObject ob_base;
#define PyObject_VAR_HEAD PyVarObject ob_base;


Хотим поменять размер объекта? Увеличиваем ob_size, аллоцируем новую память (если нужно) под новые объекты внутри.

Итоговые объекты используют примерно такую логику:


typedef struct {
PyObject_VAR_HEAD
/* Vector of pointers to list elements. list[0] is ob_item[0], etc. */
PyObject **ob_item;

/* ob_item contains space for 'allocated' elements. The number
* currently in use is ob_size.
*/
Py_ssize_t allocated;
} PyListObject;


То есть: на самом деле все объекты list (и многие другие) не просто PyObject, они:
- Имеют свой собственный тип: PyListObject
- Имеют общую абстракцию для размерности: PyVarObject
- Имеют общую абстракцию для типа и счетчика ссылок: PyObject

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

Итог

1. None не имеет внутреннего состояния вообще (не использует ничего)
2. int может иметь разный размер, но не может изменяться, потому использует PyObject_HEAD (раньше был PyObject_VAR_HEAD, там сложная история):


typedef struct _PyLongValue {
uintptr_t lv_tag; /* Number of digits, sign and flags */
digit ob_digit[1];
} _PyLongValue;

struct _longobject {
PyObject_HEAD
_PyLongValue long_value;
};


3. list может иметь разный размер и может изменятся, потому использует PyObject_VAR_HEAD, как я показывал выше

Обсуждение: как вы думаете, как работает len() для list?

| Поддержать | YouTube | GitHub | Чат |
6👍7522🔥4💩4🕊1
Находки в опенсорсе
Аллокаторы в СPython: PyArena Один из самых простых аллокаторов в питоне. Исходники. По сути данный аллокатор является небольшой оберткой поверх PyMem_Malloc, но с интересной особенностью. Если PyMem_Malloc имеет PyMem_Free для освобождения памяти каждого…
Аллокаторы в СPython: база

Тема аллокаторов иногда питонистам кажется сложной, потому что в питоне мы их не вызываем явно. Оттого с ними не очень знакомы, так давайте исправлять и знакомиться!

Зачем вообще нужно много разных аллокаторов? Все они делают одно и то же: выделяют память в куче (heap). В зависимости от наших вариантов использования данной памяти - выделять и освобождать её нужно очень по-разному.

Где-то множество мелких объектов, которые часто создаются и очищаются. Где-то несколько больших, которые должны умирать все вместе. Где-то мы работаем в рамках одного потока, где-то несколько потоков будут запрашивать / высвобождать память параллельно.

Например: при парсинге AST мы используем PyArena аллокатор. Он выделяет сразу много памяти, сразу вычищает все за один раз. Что идеально подходит для парсинга.

Но, для рантайма - задачи, конечно же другие. Там есть долгоживущие объекты, есть много мелких краткоживущих, есть довольно большие, есть маленькие. Для таких задач используют "general purpose allocators". Которые в среднем хороши во всем.

Дизайн аллокаторов в CPython

Питон знает, как его будут использовать. Потому поверх базовых GPA есть собственные надстройки.

Документация:
- https://docs.python.org/3/c-api/allocation.html
- https://docs.python.org/3/c-api/memory.html

В CPython есть: malloc, pymalloc, mimalloc и некоторые их варианты для дебага.

Они разделены на три "домена" для аллокаторов, то с чем они работают, какие задачи решают:
- Raw: для выделения памяти для общих задач, например под сишные буферы или IO. Может работать без PyThreadState
- Mem: для выделения памяти для общих задач, но уже с PyThreadState, например под Python буферы, подходит для мелких объектов
- Object: для выделения памяти под конкретные мелкие объекты

Разработчики C-extensions должны понимать, когда какой использовать и под какие задачи.
К счастью, разработчикам на питоне - такое нужно только для любопытства.

А вот таблица, какие реальные аллокаторы используют те или иные C-API функции в разных режимах:


PyMem_RawMalloc -> malloc
PyMem_Malloc -> pymalloc
PyObject_Malloc -> pymalloc


Она правда немного устарела и не отражает Free-Threading сборки, которые требуют mimalloc 🌚
Кто первый успеет сделать PR с исправлением - тот молодец!
О mimalloc мы как-нибудь отдельно поговорим, там нужно рассказывать сильно глубже, в том числе про GC и PyGC_Head.

Зачем питону свой аллокатор?

В CPython есть (был? для free-threading он не используется и не будет) свой аллокатор: pymalloc, основная задача которого – работа с маленькими Python объектами.
Про него полностью тоже нужно писать большой отдельный пост.

Что вообще важно в аллокаторе?
- Стратегия выделения памяти под новый запрос
- Работа с округлениями размера памяти и выравнивание
- Дефрагментация памяти
- Стратегия очистки памяти


struct arena_object {
uintptr_t address;
pymem_block* pool_address;
uint nfreepools;
uint ntotalpools;
struct pool_header* freepools;
struct arena_object* nextarena;
struct arena_object* prevarena;
};


Но кратко про pymalloc можно сказать следующее:
- Он создает арены по 1MB
- Внутри арены разделены на пулы по 16KB
- Внутри пулы поделены на блоки фиксированного размера

Зачем? Чтобы не аллоцировать часто маленькие кусочки памяти. Что дорого.

Можно ли управлять аллокаторами?

Да! Есть опции для сборки: --without-mimalloc, --without-pymalloc
И даже переменная окружения PYTHONMALLOC, которая позволяет указать, какой аллокатор использовать для всех случаев. Зачем? Прежде всего для дебага. Но можно потестить, вдруг будет давать буст по скорости или потреблению памяти в ваших вариантах использования.

Обсуждение: какой ваш любимый аллокатор? И почему jemalloc?

| Поддержать | YouTube | GitHub | Чат |
🔥45👍207🕊1