← назад к разделу

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

Всё это — про то, что делает интерпретатор. Разберём по порядку.

Что происходит, когда вы запускаете файл

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

Посмотреть на них можно прямо из кода:

live example

import dis

def add_tax(price):
    return price * 1.2

dis.dis(add_tax)
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

В выводе будет несколько строк вида LOAD_FAST price, LOAD_CONST 1.2, BINARY_OP *, RETURN_VALUE. Вот это и выполняется — не текст программы.

Отсюда __pycache__. Компиляция импортируемого модуля стоит времени, и повторять её при каждом запуске незачем: результат складывается в файл .pyc рядом, и в следующий раз берётся готовый — если исходник не менялся. Запускаемый файл, тот самый, что указали в командной строке, не кэшируется: смысла нет, он всё равно компилируется один раз за запуск.

Практический вывод ровно один: __pycache__ не надо коммитить и не надо чинить. Он пересоберётся сам.

Счётчик ссылок: объект умирает сразу

Каждый объект в Python помнит, сколько имён на него смотрит. Присвоили ещё одному имени — счётчик вырос, имя ушло из области видимости — уменьшился. Дошло до нуля — объект уничтожается немедленно.

live example

class Noisy:
    def __del__(self):
        print("удалён")

n = Noisy()
m = n          # на объект смотрят двое
del n          # ничего не печатается: остался m
del m          # «удалён» — счётчик дошёл до нуля
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Это не мелочь, а свойство, которого нет во многих языках со сборщиком мусора: в Python освобождение детерминировано. Файл, открытый без with, закроется ровно тогда, когда на него перестанут смотреть, — и именно поэтому в CPython так долго можно было писать без with и не замечать проблем.

Замечать всё же стоит. Детерминированность — свойство конкретной реализации, а не языка: в других реализациях Python её нет. Поэтому ресурсы закрывают with, а не надеждой на счётчик.

Если захочется посмотреть на счётчик руками, есть sys.getrefcount(obj) — только помните, что он покажет на единицу больше: аргумент функции сам по себе ссылка.

Циклы: почему одного счётчика мало

У счётчика есть слабое место, и оно фундаментальное:

a = {}
b = {}
a["b"] = b
b["a"] = a
del a, b       # имён больше нет, а счётчики не нулевые

Два словаря держат друг друга. Снаружи до них не дотянуться, но счётчик у каждого равен единице, и сам по себе он никогда не дойдёт до нуля. Это циклическая ссылка, и ради неё в Python есть второй механизм — собственно сборщик мусора, модуль gc.

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

Что из этого следует для обычного кода:

  • Специально вызывать 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 существует отдельная сборка интерпретатора без него. Поначалу экспериментальная, она постепенно становится обычным вариантом поставки. Правила выше пока писаны для стандартной сборки — именно она стоит почти везде.

Коротко

  • Python компилирует модуль в байт-код и выполняет его на виртуальной машине; dis.dis показывает инструкции, __pycache__ хранит готовый результат и в репозитории не нужен.
  • Основной механизм уборки — счётчик ссылок: объект умирает сразу, как только на него перестали смотреть.
  • Детерминированность освобождения — свойство CPython, а не языка; ресурсы всё равно закрывают with.
  • Счётчик не видит циклов, поэтому есть второй механизм — сборщик по поколениям, модуль gc. Руками его трогают редко.
  • GIL пускает в байт-код один поток за раз: потоки не ускоряют вычисления, но прекрасно работают на ожидании.
  • Ждём — потоки или asyncio; считаем — процессы.
  • С версии 3.13 существует сборка без GIL; пока это не то, что стоит по умолчанию.

Что почитать дальше