5.02. Архитектура выполнения и управление памятью
Архитектура выполнения и управление памятью
Python — это высокоуровневый язык, но его реализация основана на сложной внутренней архитектуре. Наиболее распространённая реализация — CPython — представляет собой интерпретатор, написанный на C, который преобразует исходный код в байткод и выполняет его на виртуальной машине (PVM — Python Virtual Machine). Основные компоненты CPython:
- Лексер (Lexer) — разбивает исходный код на токены.
- Парсер (Parser) — строит абстрактное синтаксическое дерево (AST).
- Компилятор — преобразует AST в байткод.
- Интерпретатор байткода (PVM) — выполняет инструкции из байткода.
- Менеджер памяти — выделяет и освобождает память, управляя объектами.
- Сборщик мусора (GC) — отслеживает и удаляет недостижимые объекты.
Вся эта система работает поверх стандартной C-библиотеки и операционной системы, используя динамические библиотеки, системные вызовы и механизмы управления процессами.
Выполнение Python-скрипта происходит по следующей цепочке:
- Чтение исходного файла — интерпретатор загружает
.pyфайл. - Лексический анализ — строка разбивается на лексемы (идентификаторы, операторы, ключевые слова и т.д.).
- Синтаксический анализ — из лексем строится AST (Abstract Syntax Tree), представляющее структуру программы.
- Компиляция в байткод — AST компилируется в последовательность инструкций для виртуальной машины. Байткод сохраняется в файлах
.pyc(в каталоге__pycache__). - Загрузка и выполнение байткода — PVM читает байткод и выполняет его пошагово, используя стек вызовов и кучу объектов.
- Уничтожение объектов и завершение — после завершения выполнения активируются финализаторы, запускается сборка мусора, освобождается память.
Примечание: если .pyc уже существует и не устарел (по метке времени), CPython пропускает этапы 2–4.
CPython — это стековая виртуальная машина, которая интерпретирует байткод. Он не компилирует код в машинный (в отличие от Go или Rust), но может использовать JIT-оптимизации через сторонние инструменты (например, PyPy).
Архитектура выполнения:
- Каждый поток имеет свой стек вызовов (call stack).
- Внутри стека хранятся фреймы (frames) — контексты выполнения функций.
- Каждый фрейм содержит cсылку на код (co_code), локальные переменные, операционный стек (для вычислений), текущую позицию в байткоде.
def add(a, b):
return a + b
Байткод (через dis.dis(add)):
LOAD_FAST a
LOAD_FAST b
BINARY_ADD
RETURN_VALUE
PVM последовательно выполняет эти инструкции, манипулируя стеком данных.
Global Interpreter Lock (GIL) — это мьютекс, который защищает доступ к данным интерпретатора, гарантируя, что только один поток выполняет Python-байткод в каждый момент времени.
GIL упрощает управление памятью (счётчик ссылок не требует синхронизации), предотвращает повреждение внутренних структур данных, обеспечивает предсказуемость в многопоточной среде.
Невозможно параллельное выполнение CPU-нагруженных Python-потоков на нескольких ядрах. Многопоточность полезна только при ожидании I/O (сетевые запросы, файлы, базы данных). Для параллелизации CPU-задач используют процессы (multiprocessing) или внешние расширения на C (NumPy, Numba и т.д.), которые могут временно освобождать GIL.
Можно ли обойти GIL? Да, если C-расширение явно освобождает GIL во время длительных операций. Альтернативные реализации (Jython, IronPython) не имеют GIL, но несовместимы с C-расширениями. PyPy с GIL, но предлагает STM (Software Transactional Memory) как экспериментальную альтернативу.
Python полностью управляет памятью за разработчика. Нет прямого доступа к указателям (в общем случае), но механизм управления памятью глубоко интегрирован в модель объектов.
Две основные области памяти:
- Стек (stack):
- Хранит локальные переменные, параметры функций, адреса возврата.
- Выделяется и освобождается автоматически при входе/выходе из функции.
- Размер ограничен (обычно ~8 МБ). Переполнение стека — частая причина RecursionError.
- Куча (heap)
- Хранит все объекты: строки, списки, классы, функции.
- Управление — через менеджер памяти CPython.
Все объекты создаются в куче, даже «простые» вроде целых чисел (за редкими исключениями — малые целые кэшируются).
Важно: переменные в Python — это ссылки на объекты в куче. Сама ссылка может быть в стеке (локальная переменная), но объект — всегда в куче.
CPython использует подсчёт ссылок как основной механизм управления временем жизни объектов:
- Каждый объект содержит счётчик количества ссылок на него.
- При создании ссылки (присваивание, передача в функцию) счётчик увеличивается.
- При удалении ссылки (выход из области видимости, переназначение) — уменьшается.
- Когда счётчик достигает нуля — объект немедленно уничтожается, память освобождается.
a = [1, 2, 3] # refcount = 1
b = a # refcount = 2
del a # refcount = 1
b = None # refcount = 0 → объект удаляется
И несмотря на мгновенное освобождение памяти и предсказуемость, не справляется с циклическими ссылками:
a = []
b = [a]
a.append(b) # a ссылается на b, b ссылается на a → цикл
# После del a, del b: refcount не становится 0!
Для решения этой проблемы используется сборщик мусора.
Модуль gc в CPython реализует дополнительный механизм для обнаружения и удаления объектов с циклическими ссылками.
Как работает:
- GC периодически просматривает объекты, способные участвовать в циклах (контейнеры: списки, словари, пользовательские классы).
- Использует алгоритм mark-and-sweep:
- Помечает все достижимые объекты из корней (глобальные переменные, стек).
- Удаляет непомеченные (недостижимые).
- Поддерживает три поколения (generations):
- 0 — новые объекты.
- 1, 2 — более старые.
- Часто молодые объекты быстро становятся недостижимыми. Поэтому GC чаще проверяет 0-е поколение, реже — старшие.
Настройка:
import gc
print(gc.get_threshold()) # (700, 10, 10) — пороги для поколений
gc.collect() # принудительный запуск GC
GC не нужен для простых типов (int, str, tuple) без ссылок на контейнеры — они управляются только счётчиком. Функция hash(obj) возвращает целое число, используемое в хеш-таблицах (например, для dict, set).
Хеш должен быть:
- Стабильным: одинаков для одного и того же объекта в течение его жизни.
- Одинаковым для равных объектов: если a == b, то hash(a) == hash(b).
Требования к объектам:
- Объект должен быть хешируемым.
- Хешируемыми являются неизменяемые типы: int, str, tuple (если элементы хешируемы), frozenset.
- Изменяемые объекты (list, dict, set) — не хешируемы.
hash(42) # OK
hash("hello") # OK
hash((1, 2)) # OK
hash([1, 2]) # TypeError: unhashable type
Если объект изменится после помещения в dict или set, его хеш может измениться, что нарушит целостность хеш-таблицы.
Пользовательские классы по умолчанию хешируемы (хеш — по id()), если не переопределён __eq__. Если __eq__ определён, __hash__ устанавливается в None, и объект становится нехешируемым, пока __hash__ не будет явно задан.
class Point:
def __init__(self, x, y):
self.x, self.y = x, y
def __eq__(self, other):
return self.x == other.x and self.y == other.y
# p = Point(1, 2)
# {p: "value"} # TypeError: unhashable
class HashablePoint(Point):
def __hash__(self):
return hash((self.x, self.y))
Модуль dis позволяет анализировать байткод, генерируемый компилятором Python. Это мощный инструмент для понимания внутреннего устройства языка.
import dis
def square(x):
return x * x
dis.dis(square)
Ключевые инструкции:
- LOAD_FAST — загрузить локальную переменную в стек.
- BINARY_MULTIPLY — умножить два верхних значения в стеке.
- RETURN_VALUE — вернуть значение из стека.
Другие возможности dis:
- dis.code_info(func) — подробная информация о коде.
- dis.Bytecode(obj) — итерация по инструкциям.
- dis.show_code(func) — компактное представление.
Зачем это нужно?
Анализ производительности (например, количество операций). Понимание разницы между конструкциями:
[x*2 for x in range(10)] # генератор списков — быстрее
list(map(lambda x: x*2, range(10))) # map + lambda — медленнее из-за вызова
Отладка оптимизаций (например, константное свёртывание).
В Python нет прямой работы с указателями, но можно получить адрес объекта в памяти с помощью id():
a = [1, 2, 3]
print(id(a)) # например, 140234567890123
Функция id() возвращает уникальный идентификатор объекта — обычно его адрес в памяти (зависит от реализации).
Что такое блок памяти?
- Объект в куче занимает блок фиксированного размера (для данного типа).
- Менеджер памяти CPython использует аллокаторы уровня объекта:
- Для мелких объектов — специализированные пулы (obmalloc).
- Для крупных — стандартный malloc.
import sys
a = []
print(sys.getsizeof(a)) # 56 байт (пустой список — служебные поля)
a.append(1)
print(sys.getsizeof(a)) # 88 байт — выделена дополнительная память
Python предварительно выделяет память для списков, чтобы append был амортизированно O(1).
Обратиться к памяти по адресу напрямую нельзя, но модуль ctypes позволяет работать с памятью на низком уровне:
import ctypes
addr = id(a)
obj = ctypes.cast(addr, ctypes.py_object).value
print(obj is a) # True
Такой подход опасен и используется только в крайних случаях (отладка, взаимодействие с C).