Напишем крохотную программку. Ничего, на первый взгляд, примечательного.

from dataclasses import dataclass

@dataclass(slots=True)
class Book:
    title: str
    description: str
    cost: float


ParseBookException = Exception("parse book failed!")

def validate_book(book: Book) -> Book:
    if book.cost % 2 == 0:
        return book
    raise ParseBookException


def process_books() -> list[Book]:
    books = []
    for book in (
        Book(
            title="title",
            cost=i,
            description="lorem" * 10**6,
        )
        for i in range(2000)
    ):
        try:
            books.append(validate_book(book=book))
        except Exception:
            continue
        books.append(book)
    return books


def process_and_print_books():
    books = process_books()
    print(len(books))


def main():
    process_and_print_books()
    print("done")


if __name__ == "__main__":
    main()

Создаём 2к объектов, отфильтровываем половину. slots=True, чтобы использовать меньше памяти.

Но она выжрала 9 гигабайт памяти:

pip install scalene
scalene --memory run.py

Странно. Может просто 1000 книг столько занимают. Закомментируем строки:

    # if book.cost % 2 == 0:
    #    return book

Пусть ни одна книга не пройдёт проверку.

Эм, что?

Scanele, где течёт?

В программе!

pip install memray
memray run run.py
memray flamegraph ...
open ...

Memray, где течёт?

Сказали уже тебе, в программе!

Ещё пять модных либ для профилирования спустя вооружаемся вилкой:

import tracemalloc

...

def process_and_print_books():
    books = process_books()
    print(len(books))

    snapshot = tracemalloc.take_snapshot()
    top_stats = snapshot.statistics('lineno')
    print("[ Top 10 ]")
    for stat in top_stats[:10]:
        print(stat)


def main():
    tracemalloc.start()
    process_and_print_books()
    ...

...
╰─➤  python run.py
0
[ Top 10 ]
run.py:27: size=9537 MiB, count=2000, average=4883 KiB
run.py:17: size=406 KiB, count=4000, average=104 B
run.py:32: size=110 KiB, count=2001, average=56 B
run.py:24: size=109 KiB, count=2000, average=56 B
run.py:23: size=54.5 KiB, count=1743, average=32 B
run.py:39: size=208 B, count=1, average=208 B
done

Книг в списке нет, а аллокации есть. Причём на 10 гигов. Ну дела.

Возьмём к вилке ещё и ножик:

pip install objgraph
def main():
    process_and_print_books()
    print("done")

    import objgraph
    objgraph.show_growth()

Запускаем.

╰─➤  python run.py
0
done
traceback                      4000     +4000
function                       2347     +2347
frame                          2002     +2002
Book                           2000     +2000
wrapper_descriptor             1200     +1200
dict                           1081     +1081
tuple                          1020     +1020
method_descriptor               819      +819
builtin_function_or_method      813      +813
ReferenceType                   778      +778

Ничего не понятно, но очень интересно. Кто эти вещи, и что они здесь делают?

Ну ладно “вещи”, но книги-то? Книги-то куда?! Тут же на них ни одной ссылки. Ведь правда..?

def main():
    process_and_print_books()
    print("done")

    import random
    import objgraph
    objgraph.show_growth()
    objgraph.show_chain(
        objgraph.find_backref_chain(
            random.choice(objgraph.by_type('Book')),
            objgraph.is_proper_module,
        ),
        filename='chain.png',
    )

Эээ, что? Висит объект, на которых нет рефов?

Что ж. Слабоумие и отвага - нарисуем их всех.

    for number, obj in enumerate(objgraph.by_type('Book')):
        objgraph.show_chain(
            objgraph.find_backref_chain(
                obj,
                objgraph.is_proper_module,
            ),
            filename=f'chain{number}.png',
        )

Ага! Попался, злодей.

Но трейсбек? Что это зна… о, чёрт…

ParseBookException = Exception("parse book failed!")

def validate_book(book: Book) -> Book:
    if book.cost % 2 == 0:
        return book
    raise ParseBookException

Не мог же один жалкий глобальный эксепшн всех съесть?

def validate_book(book: Book) -> Book:
    # if book.cost % 2 == 0:
    #    return book
    raise Exception("parse book failed!")

...

def main():
    process_and_print_books()
    print("done")

    import objgraph
    objgraph.show_growth()

Или мог.

╰─➤  python run.py
0
done
function                       2347     +2347
wrapper_descriptor             1200     +1200
dict                           1081     +1081
tuple                          1023     +1023
method_descriptor               819      +819
builtin_function_or_method      813      +813
ReferenceType                   778      +778
getset_descriptor               455      +455
type                            413      +413
member_descriptor               361      +361

Scalene, мог?

А если раскомментить строчки cost % 2?

Ожидаемые 50% памяти - 5 гигов от 10.

Но почему так?

Из документации raise:

A traceback object is normally created automatically when an exception is raised and attached to it as the __traceback__ attribute.

Так как объект глобальный, то рефы не чистятся при выходе. И память висит.

Если распечатать ещё один графов объектов, то станет нагляднее:

Следите за руками:

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