Как работает CPython: байткод и интерпретатор
Путь от исходника до результата
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)
Как читать: инструкции кладут значения на стек и снимают их:
LOAD_FAST price,LOAD_FAST qty— положить на стек две локальные переменные;BINARY_OP *— снять два значения, положить произведение;LOAD_CONST 10,BINARY_OP +— прибавить 10;STORE_FAST result— снять результат и сохранить в локальную переменную;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
С помощью модуля 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}
import ast
from collections import Counter
def count_calls(source):
tree = ast.parse(source)
names = [node.func.id for node in ast.walk(tree)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)]
return dict(Counter(names))
Напишите 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)
import dis
def opnames(func):
return {ins.opname for ins in dis.get_instructions(func)}
Что такое .pyc-файлы в папке __pycache__?
Почему цикл на чистом Python медленнее аналогичного на C?
Что верно для реализации?
| CPython | PyPy | Cython | |
|---|---|---|---|
| Эталонная реализация на C, её скачивают с python.org | |||
| JIT-компилятор, часто в разы быстрее на чистом Python | |||
| Компилирует Python-подобный код с аннотациями в расширение на C |
Войдите, чтобы отмечать пройденные уроки.