Привет, Хабр!

Сегодня вместе заглянем за дверки Python и разберемся, что же там внутри. Оказывается, под привычным синтаксисом Python скрывается целая машина — интерпретатор CPython, написанный на языке C.

Это самая популярная и каноничная реализация Python. Существует и другие реализации (PyPy, Jython, IronPython и так далее), но 99% времени, говоря Python, имеют в виду именно CPython — оригинальный и наиболее поддерживаемый интерпретатор. Без строгой формальной спецификации языка Python, именно поведение CPython во многом определяет, что такое Python.

Но для начала, зачем это вообще надо, знать внутренности CPython? Ведь и так все работает.

Во‑первых, это даёт более глубокое понимание самого языка. Многие странные особенности Python перестают быть загадкой, когда знаешь, как они реализованы. Во‑вторых, детали реализации влияют на практику: понимая устройство объектов, работу сборщика мусора и ограничение GIL, вы лучше оцениваете применимость Python для тех или иных задач, избегаете неожиданных проблем с производительностью. В‑третьих, зная внутренности, вы получаете новые инструменты, можно писать расширения на C, встраивать Python в другие приложения, а это открывает совсем другой уровень познания.

Что такое CPython и чем он отличается от просто Python

Начнём с основ. CPython — это реализация интерпретатора языка Python на языке C. По сути, CPython это программа, которая принимает ваш код на Python и выполняет его, преобразуя в понятные машине действия. Наряду с CPython есть альтернативные реализации, но CPython является де‑факто стандартом, он был первым, и именно он разрабатывается самим сообществом Python. Если вы скачали Python с официального сайта или установили через дистрибутив, у вас почти наверняка CPython.

Важно понимать разницу между языком Python и интерпретатором CPython. Язык — это набор правил и синтаксиса (описанных в документации), а CPython конкретная программа, исполняющая код на этом языке. В отличие от, скажем, C или Java, у Python нет жёсткой спецификации, отделённой от реализации. Описание языка дано в документации, но многие детали оставлены на усмотрение реализации.

CPython за долгие годы стал эталоном: другие интерпретаторы стараются быть совместимыми с его поведением. При этом CPython включает и вещи, которые не являются частью спецификации Python, а просто его внутренние механизмы. Пример — механизм управления памятью на основе подсчёта ссылок: формально в спецификации Python этого нет, но в CPython он есть, и мы о нём поговорим.

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

Давайте посмотрим, как именно это происходит.

Как CPython выполняет ваш код

Вы когда‑нибудь задумывались, что делает интерпретатор в тот момент, когда вы запускаете скрипт командой python script.py? Стадии которые проходит ваш код внутри CPython:

  • Инициализация интерпретатора. При запуске CPython готовит всё необходимое: выделяет основные структуры данных, инициализирует встроенные типы (например, создаёт объект типа int, list и так далее), загружает модули, настроенные по умолчанию, настраивает систему импорта и многое другое.

  • Компиляция исходного кода в байт‑код. Несмотря на то, что CPython называют интерпретатором, он не напрямую читает каждую строку и тут же исполняет. Сначала исходный текст программы разбирается, лексер превращает текст в поток токенов, парсер из токенов строит синтаксическое дерево. Затем на основе AST генерируется байт‑код, последовательность низкоуровневых инструкций, понятных виртуальной машине Python. CPython‑компиляция не создает машинного кода (как компилятор C), а создает этот самый байт‑код, своеобразный ассемблер«для интерпретатора. Кстати, CPython даже делает незначительные оптимизации на этом этапе, например, может объединять некоторые константы. Результатом компиляции будет объект кода (code object), содержащий байт‑код функции или модуля. Вы, наверное, видели файлы с расширением .pyc в папке pycache, это сохранённый байт‑код Python, чтобы при следующем запуске не парсить и не компилировать файл заново.»

  • Исполнение байт‑кода. Далее начинается самое интересное: байт‑код передается на исполнение виртуальной машине CPython. Внутри интерпретатора работает цикл исполнения байт‑кода — по сути, большой цикл while, который по очереди читает инструкции и выполняет соответствующие им операции на уровне Си‑кода. CPython использует стековую виртуальную машину, то есть инструкции работают с данными через стек. Например, есть инструкция BINARY_ADD (сложение), она возьмёт два верхних значения на стеке, сложит их и результат положит обратно на стек. Каждая инструкция байт‑кода — это числовой код операции (opcode) и аргумент (опционально). Посмотрим на простой пример байт‑кода.

Вот функция на Python:

def g(x):
    return x + 3

Если скомпилировать её и посмотреть байт‑код через модуль dis (дисассемблер Python), мы увидим примерно такое (для CPython 3.9):

0 LOAD_FAST                0 (x)
2 LOAD_CONST               1 (3)
4 BINARY_ADD
6 RETURN_VALUE

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

Расшифруем: LOAD_FAST 0 (x) — загрузить локальную переменную x (она находится в слоте 0 локальных переменных) на стек; LOAD_CONST 1 (3) — положить на стек константу под индексом 1 (в таблице констант функции это число 3); BINARY_ADD — взять две верхушки стека (они как раз будут x и 3), сложить их и результат поместить на стек; RETURN_VALUE — взять верхнее значение стека (результат сложения) и вернуть из функции.

CPython прошёл путь от исходного кода на Python до исполнения низкоуровневых байт‑операций. Всякий раз, когда вы запускаете программу, внутри происходит подобный цикл: читать инструкцию, выполнить, перейти к следующей.

CPython выполняет байт‑код последовательно в одном потоке, если не использовать специально многопоточность. На каждой итерации цикла интерпретатор тратит некоторую часть времени на раскодирование инструкции, выбор нужной функции на Си для её выполнения и так далее Из‑за этого чистый Python работает медленнее, чем код на компилируемых языках, ведь каждая элементарная операция проходит через интерпретатор.

Этот процесс все время пытаются оптимизировать. Например, в Python 3.11 байт‑код стал адаптивным: интерпретатор во время работы может заменять общие инструкции на более специализированные под конкретный тип данных, ускоряя повторяющиеся операции. Но в целом принципы работы остаются прежними: есть цикл исполнения и набор опкодов.

После завершения исполнения байт‑кода интерпретатор завершается (или переходит к выполнению следующего модуля/скрипта). Но жизнь на этом не заканчивается, нам ещё нужно понять, как Python распоряжается памятью и объектами во время выполнения вашего кода.

Управление памятью и сборка мусора в CPython

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

Каждый объект в памяти CPython содержит счётчик ссылок — число, которое показывает, сколько раз этот объект используется. Когда вы создаёте новый объект (например, делаете a = []), его счётчик ссылок (ob_refcnt) устанавливается в 1 (потому что переменная a ссылается на него). Если вы делаете ещё одну переменную, указывающую на тот же объект (b = a), счётчик увеличится на единицу. При удалении ссылки (например, выполняете del a или переназначаете a = другое) счётчик уменьшается. Как только счётчик ссылок объекта становится равен 0, объект сразу уничтожается и память освобождается (в терминах CPython вызывается соответствующая функция очистки). Такой подход называется сборкой мусора на основе подсчёта ссылок: «мусором» считается объект, на который больше не осталось ссылок, и его можно убрать из памяти.

Давайте проиллюстрируем это на небольшом примере:

import sys

a = []            # создали пустой список, refcount = 1 (на него ссылается 'a')
b = a             # ещё одна ссылка на тот же список, refcount = 2
print(sys.getrefcount(a))   # выведет 3, почему 3? (объяснение ниже)
del b             # удаляем одну ссылку, refcount обратно = 1
print(sys.getrefcount(a))   # выведет 2

Вы могли ожидать увидеть 2 и 1, но sys.getrefcount сам временно создаёт дополнительную ссылку, когда вы передаёте объект ему как аргумент. Поэтому фактически были значения 3 и 2. Главное — видно, что после del b количество ссылок уменьшилось. Когда же мы сделаем del a, счётчик списка станет 0, и интерпретатор немедленно освободит память, занимаемую этим списком.

Подсчёт ссылок — очень понятный и простой механизм управления памятью.

Однако у него есть известная проблема: циклические ссылки. Представьте две Python‑структуры, которые ссылаются друг на друга (например, два списка вложены друг в друга взаимно). Их счётчики ссылок никогда не упадут до нуля, даже если больше нигде в программе нет на них ссылок, они же ссылаются друг на друга! Получится утечка памяти: такие объекты останутся в памяти навсегда, хотя и недоступны программисту. Чтобы решить эту проблему, в CPython существует циклический сборщик мусора (cycle garbage collector).

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

Циклический GC в CPython реализован как поколенческий сборщик.

Объекты разделяются на поколения в зависимости от «возраста» (сколько сборок они пережили). Новые объекты сначала попадают в поколение 0 (молодое). Если объект пережил несколько итераций сборщика (не был собран, так как на него ещё были ссылки), он перемещается в более старшее поколение (1, затем 2). Объекты в старших поколениях проверяются сборщиком реже, чем молодые — считается, что «если объект жив долго, вероятно, он не мусор». Часто в программах множество мелких объектов быстро становятся ненужными и могут быть собраны на младших поколениях, не проверяя каждый раз всю кучу. В CPython традиционно три поколения для обычных объектов (0, 1, 2) и отдельное «поколение» для объектов, которые вообще не собираются (например, статически выделенные объекты).

Периодичность запуска GC настраивается параметрами (есть gc.get_threshold и др.), но по умолчанию он запускается достаточно регулярно, чтобы очищать циклические мусоры, не создавая значительных пауз. В обычных сценариях вам редко приходится явно вызывать gc.collect(), CPython справляется сам. Однако вы можете при необходимости отключить циклический GC (функция gc.disable()), если точно знаете, что у вас не будет циклических структур, и хотите сэкономить немного ресурсов (такие оптимизации применяют в высокочувствительных к задержкам приложениях).

Подсчёт ссылок остаётся главным механизмом управления памятью в CPython. Это означает, что удаление объектов происходит сразу, как только их refcount падает до нуля (в противоположность чисто сборочным языкам, где объект может висеть до следующей паузы GC). Например, вот такая конструкция будет закрывать файл точно в момент выхода из блока:

def read_first_line(path):
    f = open(path)
    try:
        return f.readline()
    finally:
        f.close()

Даже без finally: f.close() файл закроется сам при выходе из функции, потому что объект файла станет недоступен (refcount=0) и тут же вызовется его метод закрытия в деструкторе. Многие Python‑разработчики полагаются на это поведение CPython (хотя явное закрытие лучше). Но учтите: в других реализациях Python (например, PyPy) финализация может происходить позже, при сборке мусора, так что лучше не злоупотреблять.

Теперь пару слов о том, как CPython выделяет память под новые объекты.

Внутри CPython есть специальный менеджер памяти под названием pymalloc. Он оптимизирован для выделения мелких объектов (<= 512 байт), коих в Python очень много (числа, маленькие строки, кортежи и пр.). Вместо того чтобы каждый раз вызывать malloc операционной системы, pymalloc резервирует большие куски памяти (арены по 256 КБ, разбивает их на пулы по 4 КБ, а те — на блоки под объекты нужного размера). За счёт этого распределение памяти для объектов происходит очень быстро, а память используется эффективнее, с меньшей фрагментацией. Запросы на большой размер (>512 байт) pymalloc сразу делегирует системе (стандартному malloc). Для нас это почти прозрачно, но знать полезно: поэтому создание, скажем, миллиона мелких объектов (целых, маленьких строк) в CPython — операция не такая уж страшная, интерпретатор это оптимизирует.

Некоторые интересные детали реализации: CPython ради оптимизации памяти применяет интернирование некоторых объектов. Например, все небольшие целые числа в диапазоне от -5 до 256 включительно являются singleton‑объектами. Эти объекты создаются при старте интерпретатора и переиспользуются в программе. Поэтому, если вы сделаете a = 256; b = 256, то получите всего один объект целого числа 256, и переменные a и b будут ссылаться на него же (проверка a is b вернёт True). Но для числа 257 такого кэша уже нет — c = 257; d = 257 создаст два разных объекта со значением 257, и c is d будет False. Демонстрация:

a = 256
b = 256
print(a is b)   # True, оба именуются одним объектом 256
c = 257
d = 257
print(c is d)   # False, 257 создано дважды как два объекта

На выходе получим:

True
False

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

CPython старается управлять памятью и объектами максимально эффективно, скрывая всю эту кухню от программиста. Но есть одна вещь, которая сильно влияет на поведение Python‑программ и про которую необходимо знать — это GIL.

GIL: глобальная блокировка интерпретатора

Если вы почитаете о многопоточности в Python, то непременно встретите аббревиатуру GIL — глобальная блокировка интерпретатора. Это особенность CPython, которая часто вызывает вопросы у новичков. Проще всего объяснить так: в CPython одновременно исполнять байт‑код может только один поток. Даже если у вас восьмиядерный процессор и запущено несколько потоков Python, в каждый момент времени лишь одному потоку позволено выполнять Python‑операции, остальные вынуждены ждать. За это ограничение и отвечает GIL — по сути, мьютекс (блокировка), защищающий внутренние структуры интерпретатора.

Почему же в CPython появился GIL? Дело в том, что подсчёт ссылок и многие другие операции с объектами не являются атомарными.

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

Сделать менеджер памяти полностью потокобезопасным — задача нетривиальная. Можно было бы ставить мельчайшие локи на каждый объект или каждую операцию, но тогда возникли бы тысячи локов и высокая нагрузка, да и риски дедлоков. Создатели Python выбрали более простой путь: одна глобальная блокировка на весь интерпретатор. Один поток захватывает GIL и выполняет байт‑код, остальные ждут своей очереди. Это решает проблему с потокобезопасностью (большинство внутренних структур можно не защищать отдельно — достаточно глобального замка) и избегает дедлоков (замок один). Когда‑то (в 90-х) это было вполне оправдано: массовой многопоточности не требовалось, а однопоточный код работал чуть быстрее за счёт отсутствия лишних блокировок.

Конечно, у GIL есть обратная сторона: невозможность параллельно загрузить несколько ядер CPU в рамках одного процесса Python. Если у вас задача, нагружающая процессор, то потоков в Python сколько ни запускай, они всё равно по очереди выполняются под надзором GIL. Более того, переключение между потоками тоже отнимает время (хоть и небольшое). В результате чисто CPU‑нагруженный код может даже замедлиться от использования нескольких потоков вместо одного, из‑за оверхеда переключения и соревнования за GIL.

В сообществе же Python про GIL ходят легенды, его порой называют «главной ошибкой дизайна» CPython. Тем не менее, всё не так плохо: в I/O‑bound задачах (сети, ввод‑вывод) потоки в Python работают отлично. Дело в том, что когда поток, удерживающий GIL, делает блокирующий вызов ввода‑вывода (например, read() из сокета или файла), интерпретатор временно освобождает GIL на время ожидания данных. За счёт этого другие потоки могут в этот момент выполняться.

Многие библиотеки на C (например, numpy) тоже освобождают GIL на время длительных вычислений в C‑коде. Python‑потоки вполне пригодны для параллельной обработки множества сетевых запросов, загрузки файлов и прочих операций ввода‑вывода — CPU простаивает, GIL освобождён, другие потоки работают. Но вот для параллельной обработки огромного массива чисел в чистом Python, увы, GIL не даст выиграть. Здесь обычно рекомендуют либо использовать многопроцессный подход (модуль multiprocessing, запускающий несколько процессов Python без общего GIL), либо выносить расчёты в C‑модуль/NumPy (который обойдёт GIL), либо использовать альтернативные реализации (например, Jython или IronPython, у которых нет GIL благодаря другой модели памяти).

Под капотом, GIL в текущих версиях CPython работает так: интерпретатор выполняет пачку байт‑код инструкций, затем добровольно уступает GIL другому потоку (если тот ждёт) — это происходит либо по таймеру, либо по достижении определённого числа инструкций. В Python 3.9 и ниже переключение происходило каждые 100 байт‑код инструкций примерно. В современных версиях сделали «более честное» переключение по таймеру (каждые 5 миллисекунд, примерно) — это улучшило ситуацию с несправедливостью GIL, когда один поток мог долго задерживать другие. Но детали уже не так важны — важно помнить об ограничении: одновременно исполняется только один поток Python.

Стоит отметить, что другие реализации Python могут не иметь GIL. Например, Jython и IronPython полагаются на JVM и.NET соответственно, у них многопоточность реализована силами этих платформ и глобального лока нет. PyPy — альтернативный интерпретатор на Python — тем не менее, тоже имеет GIL (по историческим причинам и для совместимости с C‑расширениями CPython). Но в последние годы идут активные работы по устранению GIL и в самом CPython. Возможно, вы слышали: в 2023 году был принят PEP 703 — предложено сделать вариант CPython без GIL. И уже в экспериментальной версии Python 3.13 появилась опция сборки интерпретатора в режиме no‑GIL.

Это пока что эксперимент: новый CPython без GIL должен сохранить совместимость со старым кодом и не уступать ему в скорости на одном потоке. Задача сложнейшая (ведь половина экосистемы опирается на предположение о GIL), но, похоже, нас действительно ждёт эпоха многопоточного Python. Когда вы читаете эту статью, возможно, уже вышла версия Python, способная работать параллельно на нескольких ядрах без ограничений. Однако на момент написания стандартный выпуск CPython всё ещё включает GIL по умолчанию. Так что учитывать его нужно.

В общем что из этого нужно вынести. Просто помните: если у вас задача нагружена вычислениями, добавить потоков не ускорит её (а иногда и замедлит). Для параллельной работы в Python идите либо в сторону multiprocessing (разделение задачи между процессами), либо асинхронности (asyncio), либо в сторону написания критических участков на C. Последний вариант становится реальнее, когда вы узнаете о Python/C API.

Расширения на C и возможности CPython для оптимизации

Одним из больших преимуществ CPython является возможность интеграции с языком C.

CPython предоставляет так называемый Python/C API — набор функций и макросов, позволяющий писать на Си расширения (модули) для Python или встраивать интерпретатор Python внутрь приложений на C. Многие мощные библиотеки, которыми мы пользуемся, под капотом являются C‑расширениями: тот же NumPy написан на C, что даёт огромный выигрыш в скорости для численных вычислений. Вы, в принципе, можете сами написать модуль на C, который будет импортироваться в Python как обычный модуль, но при этом выполнять тяжёлые операции гораздо быстрее, чем чистый Python.

Как это работает? В двух словах: вы пишете C‑функции, которые используют API CPython для взаимодействия с Python‑объектами (например, получают PyObject* и извлекают из них данные, создают новые объекты и возвращают их). CPython API предоставляет все необходимые инструменты — от создания чисел и строк, до вызова функций Python из Си и наоборот. Конечно, придётся соблюдать ряд правил: вручную управлять счётчиком ссылок (инкрементировать Py_INCREF и декрементировать Py_DECREF для объектов, когда это нужно), следить за GIL (C‑код, вызывающий Python API, обычно должен выполняться под GIL; но длительные вычисления можно выполнить, временно отпустив GIL через специальные вызовы). Задача нетривиальная для новичка, но результат того стоит — C‑расширения могут ускорить критичные части кода на порядки. К примеру, если надо обработать большой список чисел, вы можете написать функцию на C, которая пройдётся по массиву быстрее питоновского цикла.

Альтернативой ручному написанию C‑расширений является использование проектов типа Cython — это инструмент, который позволяет писать код в файлах.pyx, что‑то среднее между Python и C, и компилировать их в расширения. Cython сам берет на себя генерацию C‑кода и использование CPython API, что значительно упрощает жизнь. По сути, вы аннотируете свой Python код статическими типами, а Cython делает из него C‑эквивалент. Для многих задач это идеальный путь повысить скорость, оставаясь в относительно привычном синтаксисе.

И наконец, встраивание CPython: вы можете запускать интерпретатор Python из программы на C/C++. Например, чтобы дать возможность пользователям вашего приложения писать скрипты на Python для расширяемости. CPython предоставляет функции Py_Initialize(), PyRun_SimpleString() и др., позволяющие инициировать интерпретатор и выполнять произвольный код. Не забывайте только, что если ваш C++ приложение многопоточное, при вызове Python нужно тоже позаботиться о GIL (есть API для блокировки/разблокировки GIL вокруг вызовов). Встраивание — тема узкая, но я упоминаю её, чтобы вы знали, насколько гибок CPython: он не только интерпретатор, но и библиотека, которую можно интегрировать куда угодно.

Заключение

Что можно вынести из всего этого начинающему? Во‑первых, понимание, что Python — хоть и высокоуровневый язык, но под ним работает сложный механизм. Зная его нюансы, вы будете избегать некоторых ловушек. Например, теперь вы знаете, почему сравнение с is в случае чисел — плохая идея, и почему многопоточный расчёт в чистом Python не ускорит выполнение. Во‑вторых, вы получили представление, куда расти дальше. Если стало интересно — можно покопаться в исходниках CPython (они открытые, на GitHub, написаны довольно понятно). Там вы найдёте и реализацию всех тех опкодов, и логику сборщика мусора, и даже сможете собрать Python без GIL, чтобы поэкспериментировать. Также рекомендую отличную серию статей Виктора Скворцова «Python за кулисами„, она вдохновила меня в своё время глубже изучить устройство интерпретатора.“»

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


Приглашаем вас на курс Python Developer. Basic, где мы последовательно разберем не только синтаксис, но и принципы работы языка. Вы получите структурированные знания о типах данных, функциях, модулях и особенностях реализации Python.

Записаться на курс

Рост в IT быстрее с Подпиской — дает доступ к 3-м курсам в месяц по цене одного. Подробнее

Комментарии (1)


  1. krukowo
    29.10.2025 19:14

    На удивление мне, как человеку, никогда даже не писавшему Hello world, было понятно и очень интересно. Появился интерес к питону, видимо раст подождет)