Как стать автором
Обновить
99.76
IBS
IBS – технологический партнер лидеров экономики

Управление памятью в Python: как язык заботится о ресурсах за вас и когда стоит вмешаться

Уровень сложностиПростой
Время на прочтение7 мин
Количество просмотров2.9K

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

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

Как Python выделяет память 

Когда вы пишете x = [1, 2, 3], Python не заставляет вас думать, сколько байт нужно выделить под список. Он сам находит «свободное место» в памяти, резервирует его и следит, чтобы объект жил ровно столько, сколько требуется. Это как строительная бригада, которая не только привозит материалы, но и убирает мусор после ремонта. 

В основе этого процесса лежит менеджер памяти, который работает с private heaps (приватными кучами). Каждый объект в Python — это структура, которая содержит: 

  • тип данных (например, int, list); 

  • счетчик ссылок; 

  • значение объекта. 

Например, для списка [1, 2, 3] выделяется память не только под элементы, но и под служебную информацию (размер, указатели). Это напоминает упаковку товара в коробку: сам товар, плюс этикетки и амортизация. 

Такой подход решает сразу несколько вопросов. Это безопасно: нет «висячих указателей», когда память освобождена, но вы случайно пытаетесь ее использовать. Это удобно, потому что не нужно помнить про malloc и free, как в C. И это хорошо для оптимизации, так как Python знает, как эффективнее распоряжаться ресурсами. Например, мелкие числа (от -5 до 256) кэшируются для экономии памяти. 

Счетчик ссылок: история о том, как Python считает ваши привязанности 

Объекты в Python можно сравнить с воздушными шарами, которые держат за ниточки. Пока кто-то держит нить (есть ссылка на объект), шарик на месте. Когда нити отпускают — он улетает (память освобождается). Именно так работает счетчик ссылок. 

В CPython (стандартной реализации Python) каждый объект содержит поле ob_refcnt, которое отслеживает количество ссылок. Когда вы создаете переменную, назначаете ее другой переменной или удаляете, это поле меняется. 

Пример: 

a = [1, 2, 3]  # ob_refcnt = 1 
b = a          # ob_refcnt = 2 
del a          # ob_refcnt = 1 
b.append(4)    # Счетчик не меняется -- меняется содержимое объекта 
b = None       # ob_refcnt = 0 → объект удален 

Но здесь есть некоторые нюансы, связанные со строками и интернированием (interning) и расширениями на С. Python кэширует некоторые строки (например, короткие идентификаторы), чтобы избежать дублирования. А счетчик ссылок вручную управляется через Py_INCREF и Py_DECREF. Ошибки здесь могут приводить к утечкам или крашам. 

Поэтому стоит использовать sys.getrefcount(), чтобы посмотреть текущий счетчик ссылок. Однако учтите, что сам вызов функции увеличит счетчик на 1. 

Сборщик мусора: детектив, который находит «забытые» объекты

Сборщик мусора (Garbage Collector, GC) — это как уборщик, который обходит «комнаты» памяти и ищет объекты без внешних ссылок. Он находит циклические зависимости с помощью алгоритма поколений (Generational GC). 

Python делит объекты на три поколения: 

  • Поколение 0: Новые объекты. Проверяются чаще всего. 

  • Поколение 1: Объекты, пережившие одну проверку. 

  • Поколение 2: «Долгожители». Проверяются реже. 

Так сделано, потому что исследования показывают, что большинство объектов «умирают» молодыми. Проверяя молодое поколение чаще, Python экономит ресурсы.

В «Поколение 2» в основном попадают долгоживущие объекты, например глобальные переменные и данные, используемые на протяжении всей работы программы. Поскольку сборщик мусора реже проверяет это поколение, важно контролировать количество и объем таких переменных. Их стоит использовать осознанно, избегая хранения в них больших или редко используемых данных.

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

import gc 
 
gc.set_threshold(700, 10, 10)  # Пороги для поколений 0, 1, 2 

Пример циклической ссылки: 

class Node: 
    def init(self): 
        self.parent = None 
 
# Создаем узлы-близнецы 
child = Node() 
parent = Node() 
 
# Замыкаем ссылки 
child.parent = parent 
parent.child = child  # Цикл! 
 
# Удаляем внешние ссылки 
child = None 
parent = None 
 
# Теперь GC обнаружит, что объекты недостижимы, и удалит их 

Если ваш код создает много циклических ссылок, периодически вызывайте gc.collect() вручную.

Garbage Collector в Python отслеживает циклические ссылки не для всех объектов, а только для тех, которые потенциально могут их содержать. К таким объектам относятся контейнерные типы, например словари (dicts), списки (lists), множества (sets), а также экземпляры пользовательских классов. Примитивные типы, такие как числа (int, float) и строки (str), не участвуют в проверке на цикличность, поскольку не могут образовывать циклические зависимости. Это позволяет GC работать эффективнее, фокусируясь только на потенциально проблемных участках памяти.

Как оптимизировать память вручную, когда автоматики недостаточно 

Иногда «строительная бригада» Python работает неидеально. Например, если вы создаете миллионы объектов или работаете с большими данными.

Для решения этой проблемы есть несколько способов.

_ _slots_ _: когда словари слишком тяжелы 

Каждый объект в Python хранит атрибуты в словаре dict, что гибко, но неэкономно. 

_ _slots_ _ заменяет словарь на фиксированный набор атрибутов, экономя до 40% памяти. 

Сравнение: 

class User: 
    def init(self, name, age): 
        self.name = name 
        self.age = age 
 
class SlotUser: 
    slots = ['name', 'age'] 
    def init(self, name, age): 
        self.name = name 
        self.age = age 
 
# Память для 100_000 объектов: 
# Обычный класс: ~15 МБ 
# Класс с slots: ~8 МБ 

У этого способа есть пара ограничений. Во-первых, с ним нельзя добавлять новые атрибуты. А, во-вторых, наследование требует аккуратности: если родитель имеет _ _slots_ _, потомок должен его переопределить. 

Лучше всего _ _slots_ _  подходит для классов, которые создаются миллионами, например, узлы дерева, элементы списка и подобные. 

Генераторы: память «на потоке»

Чтение файла через read() загружает все в память. Генераторы обрабатывают данные по частям: 

# Плохо для больших файлов: 
with open("huge.log") as f: 
    lines = f.readlines()  # Весь файл в памяти! 
 
# Хорошо: 
def read_lines(filename): 
    with open(filename) as f: 
        for line in f: 
            yield line  # По одной строке в памяти 
 
for line in read_lines("huge.log"): 
    process(line) 

Поэтому рекомендую использовать генераторы для потоковой обработки данных (CSV, JSON, логи). 

Массивы и numpy: когда списки слишком медленные 

Для чисел используйте модуль array или numpy: 

import array 
 
# Обычный список: 
numbers = [1, 2, 3, 4, 5]  # Каждый элемент -- объект int (~28 байт) 
 
# Массив: 
arr = array.array('i', [1, 2, 3, 4, 5])  # Каждый элемент -- 4 байта 

Такой способ помогает ускорять совершение операций и экономит память, но есть у него и недостаток — однотипные данные. 

Инструменты для детективной работы: как искать утечки памяти 

Теперь несколько слов, что делать, когда ваше приложение со временем начинает «жрать» память. Как найти виновника? 

Tracemalloc: слежка за памятью 

import tracemalloc 
 
tracemalloc.start() 
 
# Код, который может вызывать утечку 
data = [x for x in range(10_000)] 
 
snapshot = tracemalloc.take_snapshot() 
top_stats = snapshot.statistics('lineno') 
 
for stat in top_stats[:3]:  # Топ-3 "подозреваемых" 
    print(f"{stat.count} блоков: {stat.size / 1024} КБ") 
    print(stat.traceback.format()[-1])  # Где выделена память 

Objgraph: визуализация объектов 

import objgraph 
 
# Создаем утечку 
cache = [] 
def leak(): 
    cache.append([1, 2, 3]) 
 
for  in range(100): 
    leak() 
 
# Анализ 
objgraph.showmost_common_types(limit=5)  # Какие объекты плодятся? 
objgraph.show_backrefs([cache], filename="graph.png")  # Граф связей 

Если видите растущее число объектов dict или list, проверьте, не сохраняете ли вы данные в кэш без ограничений.  

Рецепты для эффективной работы с памятью 

Для предотвращения проблем с памятью можно дать несколько рекомендаций.

Кэширование без утечек: слабые ссылки 

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

import weakref 
 
class Cache: 
    def init(self): 
        self._data = weakref.WeakValueDictionary() 
 
    def get(self, key): 
        return self._data.get(key) 
 
    def set(self, key, value): 
        self._data[key] = value 
 
# Объекты в кэше удаляются, когда на них нет других ссылок 

Используйте слабые ссылки для кэшей, которые не должны влиять на жизненный цикл объектов, например, кэш изображений. 

Пулы объектов: tuple vs list 

Используйте неизменяемые типы, например tuple, для константных данных: 

# Плохо: 
points = [ [x, y] for x, y in coordinates ]  # Каждый список -- отдельный объект 
 
# Лучше: 
points = [ (x, y) for x, y in coordinates ]  # Кортежи занимают меньше памяти 

Ленивые вычисления с functools.lru_cache 

Кэшируйте результаты функций, но ограничивайте размер: 

from functools import lru_cache 
 
@lru_cache(maxsize=1000)  # Не более 1000 элементов 
def calculate(x): 
    return x ** 2 

Заключение: доверяй, но проверяй 

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

  • понимать основы работы счетчика ссылок и GC; 

  • использовать инструменты (tracemalloc, objgraph) для отладки;

  • применять паттерны (__slots__, генераторы, слабые ссылки) в критичных к памяти местах. 

Помните: оптимизация ради оптимизации бессмысленна. Сначала пишите читаемый код, а затем «тюнигуйте» его, только если видите проблемы.  

Как говорил Дональд Кнут: 

 Преждевременная оптимизация — корень всех зол.

Теги:
Хабы:
+9
Комментарии0

Публикации

Информация

Сайт
www.ibs.ru
Дата регистрации
Дата основания
1992
Численность
1 001–5 000 человек
Местоположение
Россия
Представитель
Алексей Фёдоров