April 8

Управление памятью в Python

Введение

Python - удивительный язык, который скрывает от нас многие низкоуровневые детали, включая управление памятью. Но это не значит, что нам не нужно понимать, как это работает. Знание принципов работы с памятью поможет вам писать более эффективный код и избегать распространённых ошибок.

Как Python управляет памятью

Прежде чем погружаться в детали, важно понять базовую модель памяти в Python. В отличие от языков вроде C/C++, Python автоматически управляет памятью за нас. Но это не значит, что нам не нужно понимать, как это работает.

Базовая модель памяти

В Python всё является объектами. Когда мы создаём переменную, происходит следующее:

x = dict()
  1. Python выделяет память для объекта (в данном случае словаря)
  2. Создаёт объект
  3. Создаёт имя 'x' в текущей области видимости
  4. Связывает имя с объектом (создаёт ссылку)

Важно понимать: переменные в Python - это не контейнеры для значений, а именно ссылки на объекты в памяти. Давайте посмотрим на это подробнее:

x = [1, 2, 3]
y = x

В этом случае мы не копируем список, а создаём новую ссылку на тот же самый объект. Функция id() в Python возвращает уникальный идентификатор объекта в памяти (фактически, его адрес в памяти). Это позволяет нам проверить, ссылаются ли разные переменные на один и тот же объект:

x = [1, 2, 3]
y = x
print(id(x))  # например, 140712834927872
print(id(y))  # то же самое число - это тот же объект

# Если мы изменим список через одну ссылку, изменения видны через другую
x.append(4)
print(y)  # [1, 2, 3, 4]

# А вот если создать новый список - это будет другой объект
z = [1, 2, 3]
print(id(z))  # другой номер - это новый объект в памяти

Это важно понимать, особенно когда мы работаем с изменяемыми объектами (списками, словарями, множествами). Неправильное управление ссылками может привести к неожиданному поведению программы.

Оптимизации Python для экономии памяти

Python использует несколько интересных оптимизаций:

1. Integer caching

Небольшие целые числа (обычно от -5 до 256) кэшируются:

a = 42
b = 42
print(a is b)  # True - это один и тот же объект!

x = 257
y = 257
print(x is y)  # False - разные объекты

2. String interning

Python автоматически интернирует (переиспользует) некоторые строки. Это происходит во время компиляции для идентификаторов: имен переменных, функций, классов. Интернируются строки, которые соответствуют правилам именования идентификаторов (начинаются с подчеркивания или буквы и содержат только подчеркивания, буквы и цифры):

# Строки, соответствующие правилам идентификаторов
s1 = 'hello'
s2 = 'hello'
print(s1 is s2)  # True - строки интернированы

# Строки, не соответствующие правилам идентификаторов
s3 = 'hello world'  # содержит пробел
s4 = 'hello world'
print(s3 is s4)  # False - строки не интернированы

# Можно принудительно интернировать любые строки
import sys
s5 = sys.intern('hello world')
s6 = sys.intern('hello world')
print(s5 is s6)  # True - строки интернированы вручную

Ручное интернирование через sys.intern() полезно, когда в вашем коде часто встречаются одинаковые строки, не соответствующие правилам идентификаторов. Это позволяет экономить память за счет переиспользования одного и того же объекта строки.

Сборщик мусора в Python

Python использует две основные стратегии для очистки памяти:

  1. Reference counting (подсчёт ссылок)
  2. Generational garbage collection (поколенческий сборщик мусора)

Reference Counting

Каждый объект в Python имеет счётчик ссылок. Когда создаётся новая ссылка на объект, счётчик увеличивается. Когда ссылка удаляется - уменьшается:

import sys

# Создаём список
x = [1, 2, 3]
print(sys.getrefcount(x) - 1)  # 1 ссылка (минус 1, так как getrefcount создаёт временную ссылку)

# Создаём ещё одну ссылку
y = x
print(sys.getrefcount(x) - 1)  # 2 ссылки

# Удаляем одну ссылку
del y
print(sys.getrefcount(x) - 1)  # Снова 1 ссылка

Когда счётчик достигает нуля, объект немедленно удаляется из памяти.

Проблема циклических ссылок

Reference counting отлично работает в большинстве случаев, но у него есть одна серьёзная проблема - он не может обработать циклические ссылки. Рассмотрим простой пример:

class Node:
    def __init__(self, name):
        self.name = name
        self.ref = None

    def __repr__(self):
        return f"Node({self.name})"

# Создаём два объекта
node1 = Node("First")
node2 = Node("Second")

# Создаём цикл: node1 ссылается на node2, а node2 на node1
node1.ref = node2
node2.ref = node1

# Смотрим на ссылки
print(f"node1 указывает на: {node1.ref}")  # node2
print(f"node2 указывает на: {node2.ref}")  # node1

# Удаляем переменные
del node1
del node2

# В этот момент оба объекта Node имеют счётчик ссылок = 1
# (они ссылаются друг на друга), но до них больше нельзя
# добраться из кода! Это утечка памяти.

Именно для решения таких ситуаций в Python существует второй механизм сборки мусора - поколенческий сборщик мусора (generational garbage collector).

Как работает поколенческий сборщик мусора

Этот сборщик работает по следующим принципам:

  1. Все объекты в Python распределяются по трём поколениям:
    • Поколение 0: новые объекты
    • Поколение 1: объекты, пережившие одну сборку мусора
    • Поколение 2: старые объекты, пережившие несколько сборок
  2. Сборщик периодически просматривает объекты и ищет недостижимые циклы ссылок

Давайте посмотрим на это в действии:

import gc

# Смотрим статистику по поколениям
# Формат: (количество объектов в поколении 0, в поколении 1, в поколении 2)
print("Статистика по поколениям:", gc.get_count())

# Создаём цикл как в примере выше
node1 = Node("First")
node2 = Node("Second")
node1.ref = node2
node2.ref = node1

# Удаляем внешние ссылки
del node1
del node2

# Запускаем сборщик мусора принудительно
collected = gc.collect()
print(f"Собрано {collected} объектов")  # Увидим, что объекты были собраны

Когда запускается сборщик мусора?

Python использует систему счетчиков и порогов для определения момента запуска сборки мусора:

import gc
# Получаем текущие пороги для каждого поколения
print("Пороги для поколений:", gc.get_threshold())  # По умолчанию (700, 10, 10)

Как это работает:

  1. Каждый раз, когда создаётся или удаляется объект, увеличивается счётчик поколения 0
  2. Когда счётчик поколения 0 достигает порога (по умолчанию 700):
    • Запускается сборка мусора для поколения 0
    • Выжившие объекты перемещаются в поколение 1
    • Счётчик поколения 1 увеличивается
  3. Когда счётчик поколения 1 достигает своего порога (по умолчанию 10):
    • Запускается сборка для поколений 0 и 1
    • Выжившие объекты перемещаются в поколение 2
    • Счётчик поколения 2 увеличивается
  4. Когда счётчик поколения 2 достигает порога (по умолчанию 10):
    • Запускается полная сборка мусора для всех поколений

Можно изменить эти пороги:

# Установка новых порогов
gc.set_threshold(900, 15, 15)  # Более редкая сборка мусора
gc.set_threshold(500, 5, 5)    # Более частая сборка мусора

# Проверяем текущие счётчики для каждого поколения
print("Количество объектов в поколениях:", gc.get_count())

Частота сборки мусора влияет на производительность:

  • Слишком частая сборка тратит процессорное время
  • Слишком редкая может привести к большому потреблению памяти
  • Стандартные значения подходят для большинства приложений

Управление сборщиком мусора

Python предоставляет набор функций для работы со сборщиком мусора:

import gc

# Основные операции
gc.collect()    # Запустить сборку мусора прямо сейчас
gc.disable()    # Отключить автоматическую сборку (осторожно!)
gc.enable()     # Включить автоматическую сборку
gc.isenabled()  # Проверить, включен ли сборщик

# Более детальные настройки
gc.set_threshold(100, 10, 10)  # Установить пороги для каждого поколения
gc.get_threshold()             # Получить текущие пороги

В большинстве случаев вам не нужно вручную управлять сборщиком мусора - Python отлично справляется сам. Но знать об этих функциях полезно для отладки и профилирования.

Заключение

Понимание того, как Python управляет памятью, особенно важно для разработки эффективных программ. В этой статье мы рассмотрели основные принципы работы с памятью: базовую модель ссылок, оптимизации для экономии памяти, и два механизма сборки мусора.

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

Поддержать на Boosty
Посмотреть на Youtube
Почитать в TG