Как работает CPython: байткод и интерпретатор

⏱ 35 мин · 🧩 заданий: 5

Путь от исходника до результата

script.py ──▶ токенизатор ──▶ парсер ──▶ AST ──▶ компилятор ──▶ байткод ──▶ виртуальная машина "x = a + 1" NAME OP NAME дерево Assign инструкции code object цикл eval в C BinOp LOAD/STORE (+ .pyc) выполняет

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

Токены и AST

import ast, io, tokenize

source = "total = price * qty + 10"

for tok in tokenize.generate_tokens(io.StringIO(source).readline):
    if tok.type not in (tokenize.NEWLINE, tokenize.ENDMARKER):
        print(f"{tokenize.tok_name[tok.type]:8} {tok.string!r}")

tree = ast.parse(source)
print(ast.dump(tree.body[0], indent=2))

AST (абстрактное синтаксическое дерево) — структура программы без лишних деталей (скобок, пробелов). С ним работают линтеры (ruff, pylint), форматтеры, pytest (переписывает assert), инструменты рефакторинга.

import ast

class FindPrints(ast.NodeVisitor):              # свой мини-линтер
    def visit_Call(self, node):
        if isinstance(node.func, ast.Name) and node.func.id == "print":
            print(f"строка {node.lineno}: забытый print()")
        self.generic_visit(node)

code = '''
def calc(x):
    print("debug", x)
    return x * 2

print(calc(3))
'''
FindPrints().visit(ast.parse(code))
print(ast.literal_eval("{'a': [1, 2], 'b': (3, None)}"))   # безопасный разбор литералов (вместо eval)

Байткод

Компилятор превращает AST в объект кода — последовательность инструкций для стековой машины:

import dis

def total(price, qty):
    result = price * qty + 10
    return result

dis.dis(total)

Как читать: инструкции кладут значения на стек и снимают их:

  1. LOAD_FAST price, LOAD_FAST qty — положить на стек две локальные переменные;
  2. BINARY_OP * — снять два значения, положить произведение;
  3. LOAD_CONST 10, BINARY_OP + — прибавить 10;
  4. STORE_FAST result — снять результат и сохранить в локальную переменную;
  5. LOAD_FAST result, RETURN_VALUE — вернуть.
def total(price, qty):
    result = price * qty + 10
    return result

code = total.__code__
print("имена локальных:", code.co_varnames)
print("константы:", code.co_consts)
print("аргументов:", code.co_argcount, "| размер стека:", code.co_stacksize)
print("байты:", code.co_code[:16], "...")

Что можно узнать из байткода

import dis

def local_vs_global():
    x = len                   # глобальные/встроенные имена ищутся по словарям (LOAD_GLOBAL)
    return x

def comprehension(xs):
    return [x * 2 for x in xs]

print([i.opname for i in dis.get_instructions(local_vs_global)])
print([i.opname for i in dis.get_instructions(comprehension)][:12])
  • Локальные переменные (LOAD_FAST) — обращение по индексу в массиве, быстро. Глобальные (LOAD_GLOBAL) — поиск в словарях. Отсюда старый совет «кешируйте глобальные функции в локальные переменные в горячих циклах».
  • Начиная с Python 3.12 списковые включения встраиваются в функцию (PEP 709), раньше они были отдельной функцией.

Кадры (frames)

Каждый вызов функции создаёт кадр: локальные переменные, стек значений, указатель на текущую инструкцию, ссылку на вызывающий кадр. Цепочка кадров — это и есть стек вызовов, который печатается в traceback и который показывает визуализатор этого курса.

import sys

def inner():
    frame = sys._getframe()
    chain = []
    while frame:
        chain.append(f"{frame.f_code.co_name}:{frame.f_lineno}")
        frame = frame.f_back
    return chain

def outer():
    return inner()

print(" ← ".join(outer()[:3]))

Кеш байткода: pycache

При импорте модуля Python сохраняет байткод в __pycache__/модуль.cpython-312.pyc. При следующем импорте, если исходник не менялся (сверяется время изменения или хеш), компиляция пропускается. Запускаемый скрипт (python script.py) не кешируется — только импортируемые модули.

  • .pyc ускоряет запуск, но не выполнение.
  • .pyc не скрывает код — байткод легко декомпилировать.
  • PYTHONDONTWRITEBYTECODE=1 отключает запись (часто в Docker).

Почему Python медленный и как его ускоряют

Причины:

  • интерпретация: каждая инструкция проходит через цикл виртуальной машины;
  • динамическая типизация: a + b каждый раз выясняет типы и ищет нужный метод;
  • всё — объекты: число 5 — объект в куче со счётчиком ссылок, а не 8 байт в регистре.

Как ускоряют:

Подход Суть
Специализирующий интерпретатор (3.11+, PEP 659) инструкции «запоминают» типы и заменяются быстрыми версиями: BINARY_OP → BINARY_OP_ADD_INT. Python 3.11 в среднем на 25% быстрее 3.10
JIT (3.13+, экспериментальный) компиляция горячих участков в машинный код
PyPy альтернативная реализация с трассирующим JIT: в разы быстрее на чистом Python
C-расширения numpy, pandas, orjson, pydantic-core — горячий код на C/C++/Rust
Cython, mypyc компиляция Python с аннотациями в C-расширение
Алгоритм правильная структура данных даёт выигрыш в тысячи раз — об этом урок «Сложность операций»
import dis

def add(a, b):
    return a + b

for _ in range(1000):                # «разогреваем», чтобы интерпретатор специализировал код
    add(1, 2)

try:
    dis.dis(add, adaptive=True)      # Python 3.12+: показать специализированные инструкции
except TypeError:
    dis.dis(add)

Реализации Python

Реализация Написана на Особенности
CPython C эталонная, самая распространённая, GIL
PyPy RPython JIT, быстрее на чистом Python, совместимость с C-расширениями ограничена
Jython / IronPython Java / C# интеграция с JVM / .NET (отстают по версиям)
MicroPython C микроконтроллеры
Pyodide CPython → WebAssembly Python в браузере — на нём работают примеры этого курса
import sys, platform
print(sys.implementation.name, platform.python_version(), sys.platform)

Вопрос на собеседовании

«Python компилируемый или интерпретируемый?» — Оба: CPython сначала компилирует исходный код в байткод (кешируется в .pyc), затем байткод выполняет виртуальная машина-интерпретатор. Машинный код по умолчанию не создаётся (кроме экспериментального JIT в 3.13+ и PyPy).

Итог

  • Исходник → токены → AST → байткод → виртуальная машина CPython.
  • ast — анализ и преобразование кода; dis — просмотр байткода; объекты кода и кадры.
  • .pyc в __pycache__ ускоряет импорт, а не выполнение.
  • Медленность — из-за интерпретации и динамической типизации; ускоряют специализация 3.11+, JIT, PyPy, C-расширения и, главное, правильные алгоритмы.

Практика решено 0 из 5

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Программа на Python

С помощью модуля ast напишите count_calls(source): вернуть словарь «имя функции → сколько раз она вызывается» в исходном коде (учитывайте только вызовы по простому имени, вида print(...), то есть ast.Call, у которого func — ast.Name).

Какие тесты проверяют код (3)
assert count_calls("print(len(x))\nprint(1)") == {"print": 2, "len": 1}
assert count_calls("obj.method()\nx = 1") == {}
assert count_calls("def f():\n    return g(h(1), h(2))") == {"g": 1, "h": 2}
Задание 2 Программа на Python

Напишите opnames(func): вернуть множество имён инструкций байткода функции (используйте dis.get_instructions). Затем убедитесь, что для функции def f(a, b): return a + b в нём есть инструкция сложения (BINARY_OP в Python 3.11+).

Какие тесты проверяют код (2)
def f(a, b):
    return a + b
names = opnames(f)
assert isinstance(names, set) and "BINARY_OP" in names
assert any(n.startswith("RETURN") for n in names)

def g(xs):
    return [x for x in xs]
assert "BINARY_OP" not in opnames(g)

Задание 3 Один вариант ответа

Что такое .pyc-файлы в папке __pycache__?

Задание 4 Один вариант ответа

Почему цикл на чистом Python медленнее аналогичного на C?

Задание 5 Заполнить таблицу

Что верно для реализации?

CPythonPyPyCython
Эталонная реализация на C, её скачивают с python.org
JIT-компилятор, часто в разы быстрее на чистом Python
Компилирует Python-подобный код с аннотациями в расширение на C
Отметьте ячейку — «+», пустая — «−».

Войдите, чтобы отмечать пройденные уроки.