Перейти к основному содержимому

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 полностью управляет памятью за разработчика. Нет прямого доступа к указателям (в общем случае), но механизм управления памятью глубоко интегрирован в модель объектов.

Две основные области памяти:

  1. Стек (stack):
    • Хранит локальные переменные, параметры функций, адреса возврата.
    • Выделяется и освобождается автоматически при входе/выходе из функции.
    • Размер ограничен (обычно ~8 МБ). Переполнение стека — частая причина RecursionError.
  2. Куча (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).