Почему, как и сборщик мусора

Привет! Сегодня я выбрал более «волосатую» тему, чтобы поделиться с вами. Он вращается вокруг феномена, называемого эталонный цикл. Основное внимание в этой статье будет уделено тому, как эти эталонные циклы создаются в первую очередь, какие проблемы могут возникнуть с памятью и, конечно же, несколько подходов, которые мы можем предпринять, чтобы гарантировать, что это не произойдет. или вообще убрать.

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

Немного контекста — дорога к сборщику мусора

До создания механизмов автоматического управления памятью программистам приходилось иметь дело с ручным управлением памятью. Кажется, тогда это было очень утомительной практикой, настолько утомительной, что некоторые механизмы автоматического управления памятью, а именно знаменитый сборщик мусора, появились еще в 1959 году, когда Джон Маккарти, американский ученый-компьютерщик, изобрел его, чтобы упростить ручное управление памятью. на Лиспе.»

Обычно значения объектов нашей программы хранятся в памяти. До того, как сборщик мусора вступил в игру, людям приходилось вручную выделять часть памяти для своих переменных и, что наиболее важно, они должны были также освобождать указанную память после того, как они закончили с этими переменными, освобождая ее для использования другими. На практике мир программного обеспечения преследовали две большие проблемы:

  1. Было (и остается, в зависимости от используемого языка) легко просто забыть освободить память, что привело к всем знакомым утечкам памяти;
  2. С другой стороны, слишком раннее освобождение памяти также может вызвать серьезные проблемы, когда ваш код пытается получить доступ к переменной, которая была определена в зоне памяти, которая была освобождена за это время; эта переменная теперь известна как висячий указатель.

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

Одна из самых популярных форм автоматического управления памятью основана на так называемом подсчете ссылок. Это означает, что среда выполнения отслеживает каждую ссылку на каждый объект. У каждого объекта есть счетчик ссылок, который, когда он достигает 0, объект помечается как непригодный для использования/устаревший, поэтому его можно безопасно удалить.

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

CPython — в настоящее время самая популярная реализация Python — обеспечивает идентификацию и сбор мусора с помощью алгоритма подсчета ссылок. Каждый раз, когда мы создаем новый объект в Python, базовый объект C имеет не только тип Python (list, dict и т. д.), но и счетчик ссылок.

Этот счетчик ссылок увеличивается и уменьшается всякий раз, когда объект ссылается и разыменовывается соответственно. Как я упоминал ранее в статье, когда этот счетчик достигает 0, память для этого объекта будет освобождена.

Мы действительно можем видеть эти счетчики ссылок в Python. В нашем очень простом примере мы просто создадим объект списка и назначим его переменной. Используя модуль sys, мы можем проверить счетчик ссылок для нашей переменной:

import sys
# assign a list object to a variable (ref count increases to 1)
my_list = [1, 2, 3]
# add the object to a data structure (ref count increases to 2)
my_dict = {"key": my_list}
# print out the reference count
print(sys.getrefcount(my_list))

Output:
3

И результат ошеломляющий… 3. Если вы так же удивлены, как и я, когда впервые узнали об этом, позвольте мне объяснить: счетчик ссылок для my_list увеличивается до 1, когда мы создаем переменную, присваивая ей объект списка, и далее он увеличивается до 2, когда мы добавляем объект списка в словарь. Но оно также увеличивается на 1, когда мы передаем его в качестве аргумента методу sys.getrefcount() модуля sys. Как говорится в описании этого метода:

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

Конечно, мы могли бы также считать ссылки с помощью модуля ctypes. Различия в том, что мы бы не передавали переменную, а id переменной в качестве аргумента, и результат не увеличивался бы на 1:

import ctypes
# assign a list object to a variable (ref count increases to 1)
my_list = [1, 2, 3]
# add the object to a data structure (ref count increases to 2)
my_dict = {"key": my_list}
# print out the reference count
print(ctypes.c_long.from_address(id(my_list)))

Output:
c_long(2)

Подводя итог, можно сказать, что существует несколько способов увеличения счетчика ссылок в Python:

  • присваивая объект переменной (my_list = [1, 2, 3]);
  • передав объект в качестве аргумента функции/методу (print(sys.getrefcount(my_list)));
  • путем добавления объекта в структуру данных (my_dict = {“key”: my_list}).

Конечно, если бы мы удалили переменные, такие как del my_list, счетчик ссылок для my_list, очевидно, уменьшился бы на 1.

Теперь, поскольку мы, наконец, вплотную подошли к теме статьи, предлагаю оставить остальные теоретические и практические аспекты сборщика мусора (но вот страница официальной документации по нему, если хотите подробнее этот предмет).

Двигаясь дальше, поскольку мы узнали, как каждый объект имеет связанный с ним счетчик ссылок и как достижение 0 запускает сборщик мусора для освобождения выделенной для него памяти, вот упрощенный случай цикла ссылок:

import sys
# define a list object
my_list = [1, 2, 3]
# define a dictionary object referencing the list as value
my_dict = {"key": my_list}
# now append the dictionary to the list
my_list.append(my_dict)
# now the cycle is complete. Each container references
# the other in a reference cycle
print(my_list)
print(my_dict)
# let's count the references for each container:
print(sys.getrefcount(my_list))
print(sys.getrefcount(my_dict))

Output:
[1, 2, 3, {'key': [...]}]
{'key': [1, 2, 3, {...}]}
3
3

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

Обратите внимание на значения списка? Python распечатал его как [1, 2, 3, {‘key’: […]}]. Обозначение с тремя точками здесь используется, чтобы избежать рекурсии, потому что последний элемент списка — это словарь, который ссылается на список, последним элементом которого является словарь… ну вы поняли.

То же самое касается значений словаря: {‘key’: [1, 2, 3, {…}]}. Единственный элемент словаря содержит ключ key и my_list в качестве значения. Но my_list содержит указанный объект словаря, в котором my_list является одним из его значений, который содержит объект словаря… опять же, рекурсия.

Количество ссылок равно 3 для каждого из двух объектов:

  • увеличился до 1 в тот момент, когда мы определили каждый из них;
  • еще больше увеличилось до 2, когда мы начали изменять их обоих, чтобы они ссылались друг на друга;
  • наконец, увеличилось до 3, когда мы использовали их в качестве аргументов метода sys.getrefcount().

Вопрос теперь в следующем: как с этим делом будет разбираться сборщик мусора?

Сборщик мусора в Python состоит из двух компонентов: алгоритма подсчета ссылок (тот, который мы только что продемонстрировали) и другого компонента, сборщика мусора поколения, который создается и используется именно для таких ситуаций.

Давайте рассмотрим еще более простой случай, когда список ссылается сам на себя:

import ctypes

# create the reference cycle
my_list = [1, 2, 3]
my_list.append(my_list)
# get the address for the object
my_list_id = id(my_list)
# count the references of the object
print(ctypes.c_long.from_address(my_list_id))
# delete the variable 'my_list'
del my_list
# count the references of the object again
print(ctypes.c_long.from_address(my_list_id))

Output:
c_long(2)
c_long(1)

Что здесь случилось? Ну, как только мы создали список, счетчик ссылок увеличился с 0 до 1. Затем, как только мы добавили список к самому себе, счетчик ссылок увеличился до 2. Мы удалили переменную my_list, так что, естественно , одной ссылкой на объект списка в памяти меньше. Таким образом, счетчик ссылок уменьшился с 2 до 1. Но он никогда не снижался до 0, поэтому он никогда не собирался сборщиком подсчета ссылок.

Войдите в сборщик мусора поколений (модуль gc):

import ctypes
import gc

# create the reference cycle
my_list = [1, 2, 3]
my_list.append(my_list)
# get the address for the object
my_list_id = id(my_list)
# count the references of the object
print(ctypes.c_long.from_address(my_list_id))
# delete the variable 'my_list'
del my_list
# count the references of the object again
print(ctypes.c_long.from_address(my_list_id))
# run a manual collection
print(f"Collected {gc.collect()} object(s)")
# count the references of the object again
print(ctypes.c_long.from_address(my_list_id))

Output:
c_long(2)
c_long(1)
Collected 1 object(s)
c_long(0)

После создания объекта списка и цикла ссылок счетчик ссылок, естественно, увеличился до 2. Как только мы удалили переменную my_list, счетчик ссылок упал до 1. После запуска руководства сбор через gc.collect(), счетчик ссылок, наконец, достиг 0.

Здесь нужно учитывать ряд моментов. Во-первых, алгоритм обнаружения и, что более важно, уничтожения эталонных циклов довольно затратен с точки зрения вычислительных затрат, поэтому приходится планировать автоматическую сборку мусора. Для этого устанавливается порог. Мы можем получить этот порог, используя gc.get_threshold():

import gc

print(gc.get_threshold())

Output:
(700, 10, 10)

Сборщик мусора Python имеет 3 поколения объектов. Все объекты начинают свою жизнь в первом поколении. Если Python выполняет процесс сборки мусора в поколении и объект выживает, он перемещается к следующему (также называемому старым) поколению.

Значения 700, 10, 10 — это три порога для поколений сборщика мусора. Эти пороги представляют количество объектов в памяти, которые запускают сборку мусора. Если это количество объектов превышает пороговое значение, сборщик запускает сборку мусора в этом поколении.

В примере, который мы только что видели, использовался автоматический сборщик мусора, но он никогда не запускался, потому что у нас никогда не было 700 объектов в памяти. Вот почему нам пришлось запускать gc.collect() вручную.

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

import ctypes
import gc

# alter the threshold for gc
gc.set_threshold(1, 1, 1)
# now disable the gc
gc.disable()
# create the reference cycle
my_list = [1, 2, 3]
my_list.append(my_list)
# get the address for the object
my_list_id = id(my_list)
# count the references of the object
print(ctypes.c_long.from_address(my_list_id))
# delete the variable 'my_list'
del my_list
# count the references of the object again
print(ctypes.c_long.from_address(my_list_id))
# enable the gc
gc.enable()
# count the references of the object again
print(ctypes.c_long.from_address(my_list_id))

Output:
c_long(2)
c_long(1)
c_long(0)

Имейте в виду, что модуль gc относится только к сборщику мусора поколения. Он используется только для прерывания циклов ссылок и освобождения задействованных переменных. Модуль gc не относится к алгоритму подсчета ссылок, который мы также представили.

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

Завершив еще одну (надеюсь) полезную статью по обмену знаниями, я желаю вам всего наилучшего, берегите себя и, как всегда, удачного кодирования! До скорого!

Дак — инженер-программист, наставник, писатель и иногда даже учитель. Обладая более чем 12-летним опытом разработки программного обеспечения, он теперь является настоящим сторонником языка программирования Python, а его страстью является помощь людям в оттачивании их навыков Python и программирования в целом. Вы можете связаться с колодой в Linkedin, Facebook, Twitter и Discord: Deck451#6188, а также следить за его публикациями здесь, на Medium.