Пока программа маленькая, между «написал» и «работает» ничего интересного нет. Потом появляются вопросы, на которые из самого кода ответа не видно: почему файл на десять строк оставил после себя папку __pycache__, почему объект исчез ровно в тот момент, когда вы этого не просили, и почему восемь потоков на восьмиядерной машине считают ровно так же медленно, как один.
Всё это — про то, что делает интерпретатор. Разберём по порядку.
Что происходит, когда вы запускаете файл
Python называют интерпретируемым, и отсюда частое заблуждение, будто он читает ваш файл построчно. На самом деле сначала весь модуль компилируется — не в машинный код, а в байт-код: короткие инструкции для виртуальной машины, которая живёт внутри интерпретатора.
Запуск файла это компиляция в байт-код и его исполнение виртуальной машиной CPython; для импортируемых модулей байт-код кешируется в __pycache__.
Посмотреть на них можно прямо из кода:
живой пример
import dis
def add_tax(price):
return price * 1.2
dis.dis(add_tax)
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
В выводе будет несколько строк вида LOAD_FAST price, LOAD_CONST 1.2, BINARY_OP *, RETURN_VALUE. Вот это и выполняется — не текст программы.
Отсюда __pycache__. Компиляция импортируемого модуля стоит времени, и повторять её при каждом запуске незачем: результат складывается в файл .pyc рядом, и в следующий раз берётся готовый — если исходник не менялся. Запускаемый файл, тот самый, что указали в командной строке, не кэшируется: смысла нет, он всё равно компилируется один раз за запуск.
Практический вывод ровно один: __pycache__ не надо коммитить и не надо чинить. Он пересоберётся сам.
Счётчик ссылок: объект умирает сразу
Каждый объект в Python помнит, сколько имён на него смотрит. Присвоили ещё одному имени — счётчик вырос, имя ушло из области видимости — уменьшился. Дошло до нуля — объект уничтожается немедленно.
живой пример
class Noisy:
def __del__(self):
print("удалён")
n = Noisy()
m = n # на объект смотрят двое
del n # ничего не печатается: остался m
del m # «удалён» — счётчик дошёл до нуля
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Это не мелочь, а свойство, которого нет во многих языках со сборщиком мусора: в Python освобождение детерминировано. Файл, открытый без with, закроется ровно тогда, когда на него перестанут смотреть, — и именно поэтому в CPython так долго можно было писать без with и не замечать проблем.
Замечать всё же стоит. Детерминированность — свойство конкретной реализации, а не языка: в других реализациях Python её нет. Поэтому ресурсы закрывают with, а не надеждой на счётчик.
Если захочется посмотреть на счётчик руками, есть sys.getrefcount(obj) — только помните, что он покажет на единицу больше: аргумент функции сам по себе ссылка.
Циклы: почему одного счётчика мало
У счётчика есть слабое место, и оно фундаментальное:
a = {}
b = {}
a["b"] = b
b["a"] = a
del a, b # имён больше нет, а счётчики не нулевые
Два словаря держат друг друга. Снаружи до них не дотянуться, но счётчик у каждого равен единице, и сам по себе он никогда не дойдёт до нуля. Это циклическая ссылка, и ради неё в Python есть второй механизм — собственно сборщик мусора, модуль gc.
Он ищет именно такие группы: объекты, которые ссылаются друг на друга, но недостижимы снаружи. Работает по поколениям, и идея здесь простая и проверенная практикой: большинство объектов умирает молодыми. Новые объекты попадают в молодое поколение, его проверяют часто; пережившие проверку переезжают в старое, и туда сборщик заглядывает редко. До версии 3.13 поколений было три, с 3.14 их два, а старое сборщик обходит не целиком, а по кусочкам: так исчезают длинные паузы на больших кучах.
Что из этого следует для обычного кода:
- Специально вызывать
gc.collect()не нужно. Это инструмент для двух ситуаций: измерить память честно в тесте и убрать паузу из горячего участка, собрав мусор заранее. - Отключать сборщик (
gc.disable()) — приём для коротких пакетных задач, где программа всё равно скоро завершится, а не для сервисов. __del__у объекта в цикле долгое время мешал его собрать. В современных версиях это починили, но__del__всё равно остаётся плохим местом для важной логики: момент вызова не гарантирован, а исключения в нём проглатываются. Закрывать ресурсы надоwith.
GIL: почему два потока не считают вдвое быстрее
Самая известная особенность CPython. GIL — глобальная блокировка интерпретатора: в каждый момент байт-код выполняет ровно один поток, сколько бы их ни было запущено.
Причина историческая и практическая: счётчик ссылок есть у каждого объекта, и менять его из нескольких потоков одновременно нельзя. Можно было бы поставить блокировку на каждый объект, но это сделало бы медленным всё — в том числе однопоточные программы, которых большинство. Вместо этого поставили одну блокировку на интерпретатор.
Что это значит на практике:
# восемь потоков будут считать не быстрее одного
with ThreadPoolExecutor(8) as pool:
pool.map(посчитать_хеши, куски)
Поток, который считает, держит GIL. Интерпретатор отбирает его принудительно каждые пять миллисекунд, чтобы другие потоки тоже двигались, но суммарная скорость от этого не растёт — работа просто размазывается.
А вот поток, который ждёт, GIL отпускает. Чтение из сокета, запрос в базу, чтение файла — на время ожидания блокировка свободна, и другие потоки работают. То же делают многие библиотеки с тяжёлой арифметикой внутри: на время своего вычисления в C они GIL отпускают.
Отсюда правило выбора, короткое и почти без исключений:
- Ждём ввод-вывод — потоки или
asyncio. Сто одновременных запросов к чужому API прекрасно живут в потоках, потому что все они почти всё время ждут. - Считаем — процессы,
multiprocessingилиProcessPoolExecutor. У каждого процесса свой интерпретатор и свой GIL, и ядра загружаются по-настоящему. Плата — данные между процессами надо передавать, а не просто разделять.
Разница между потоками и asyncio при этом не в скорости, а в цене ожидания: поток стоит около мегабайта стека и переключается через ядро, задача asyncio — объект в памяти. Поэтому на сотнях одновременных ожиданий выигрывает asyncio, а на десятках разницы не видно.
Стоит знать и то, что GIL перестаёт быть неизбежным: начиная с версии 3.13 существует отдельная сборка интерпретатора без него, а с 3.14 она официально поддерживается. По умолчанию при этом по-прежнему ставится обычная сборка с GIL, и правила выше писаны для неё. Там же, в 3.13 и 3.14, живёт экспериментальный JIT-компилятор: он выключен по умолчанию и пока ускоряет код на единицы процентов.
Глубже: сколько весит объект и где искать памятьрасширенное
Счётчик ссылок и сборщик отвечают на вопрос «когда объект умрёт», но не на вопрос «почему процесс занимает два гигабайта». На него отвечают три инструмента из стандартной библиотеки.
sys.getsizeof показывает размер самого объекта без того, на что он ссылается: int занимает 28 байт, пустой список 56, пустой словарь 64, строка из одной буквы 42. Обычный объект с двумя полями тянет за собой свой __dict__, и вместе это около 330 байт; тот же класс с __slots__ = ("x", "y") занимает 48. Поэтому __slots__ ставят на классы, экземпляров которых миллионы (точки, строки отчёта, узлы дерева), и не ставят на всё подряд: такой класс теряет __dict__, и к нему нельзя приписать поле на лету.
tracemalloc отвечает, какая строка кода выделила память: tracemalloc.start() при запуске, take_snapshot().statistics("lineno") в подозрительный момент, и первые десять строк отчёта обычно называют виновника. Он стоит до нескольких десятков процентов скорости, поэтому его включают по флагу, а не держат постоянно.
Две мелочи про сборку интерпретатора: sys._is_gil_enabled() говорит, с GIL ли запущен процесс (у обычной сборки True, у python3.14t без него False), а python -X importtime -c "import app" печатает, сколько микросекунд стоит каждый импорт, и это первое, что смотрят, когда контейнер стартует слишком долго.
Глубже: чем ускоряют Pythonрасширенное
Вопрос «как ускорить код на CPython» имеет порядок ответов, и порядок важнее списка. Сначала профиль: cProfile или py-spy показывают, где время на самом деле, и в сервисе это почти всегда ожидание базы и сети, а не вычисления. Затем алгоритм и структуры: поиск в set вместо списка, один запрос вместо тысячи, генератор вместо списка на миллион элементов. Только после этого имеет смысл смотреть на сам интерпретатор.
Дальше инструменты по возрастанию цены. Встроенные функции и модули, написанные на C, sum, sorted, json, re, работают без байт-кода. Для числовой работы берут NumPy и pandas: цикл по массиву уезжает в C, и выигрыш измеряется десятками раз. Горячую функцию можно скомпилировать: Cython или mypyc превращают аннотированный Python в C-расширение, а numba компилирует числовые функции на лету. PyPy это другой интерпретатор с JIT-компилятором, который ускоряет чистый Python в разы, но не все C-расширения с ним работают, поэтому веб-сервисы на нём редкость. Параллелизм на вычислениях даёт multiprocessing или сборка без GIL, появившаяся в 3.13 и ставшая официальной в 3.14. И наконец, самое дорогое и самое честное: переписать горячий кусок на Go или Rust и подключить его как расширение или отдельный сервис.
Коротко
- Python компилирует модуль в байт-код и выполняет его на виртуальной машине;
dis.disпоказывает инструкции,__pycache__хранит готовый результат и в репозитории не нужен. - Основной механизм уборки — счётчик ссылок: объект умирает сразу, как только на него перестали смотреть.
- Детерминированность освобождения — свойство CPython, а не языка; ресурсы всё равно закрывают
with. - Счётчик не видит циклов, поэтому есть второй механизм — сборщик по поколениям, модуль
gc. Руками его трогают редко. - GIL пускает в байт-код один поток за раз: потоки не ускоряют вычисления, но прекрасно работают на ожидании.
- Ждём — потоки или
asyncio; считаем — процессы. - С 3.14 сборка без GIL официально поддерживается, но по умолчанию стоит обычная.
sys.getsizeofиtracemallocпоказывают, куда ушла память;__slots__режет объект с трёх сотен байт до 48; сборку без GIL узнают поsys._is_gil_enabled(), долгий старт — по-X importtime.- Ускоряют по порядку: профиль, алгоритм и структуры, C-модули и NumPy, Cython или mypyc для горячей функции, PyPy для чистого Python, процессы или сборка без GIL для вычислений.
Что почитать дальше
- Функции и модули — импорт, из-за которого и появляется
__pycache__. - Исключения и контекстные менеджеры — почему
withнадёжнее счётчика ссылок. - Итераторы и генераторы — ленивые объекты, которые живут ровно столько, сколько их читают.
- Асинхронность в FastAPI — как выбор «потоки или asyncio» выглядит в рабочем сервисе.