Девять фаз между python script.py и первой исполненной инструкцией: запуск процесса, старт интерпретатора, токенайзер, парсер, AST, таблица символов, компилятор, code object, кеш. Каждую можно вызвать руками и посмотреть, что получилось.
Зафиксируй вывод и причину до того, как откроешь ответ.
import sys
print(len(sys.modules))
print(sum(1 for m in sys.modules.values()
if getattr(getattr(m, '__spec__', None), 'origin', None) == 'frozen'))34
14До первой твоей строки уже загружено тридцать четыре модуля, четырнадцать из них вшиты в бинарник интерпретатора. Ноль там быть не может: сам механизм импорта — это модуль.code = compile("z = [1, 2, 3]", "<s>", "exec")
print(code.co_consts)((1, 2, 3), None)В константах лежит кортеж, хотя в исходнике список. Список изменяем и константой быть не может — компилятор кладёт неизменяемый прототип и разворачивает его в список инструкцией.import io, tokenize
src = "def f():\n return 1\n"
for t in tokenize.generate_tokens(io.StringIO(src).readline):
print(tokenize.tok_name[t.type], repr(t.string))NAME 'def'
NAME 'f'
OP '('
OP ')'
OP ':'
NEWLINE '\n'
INDENT ' '
NAME 'return'
NUMBER '1'
NEWLINE '\n'
DEDENT ''
ENDMARKER ''INDENT и DEDENT — настоящие токены, наравне со скобками. Отступ в Python не форматирование: он попадает в поток токенов и участвует в грамматике.DEBUG = False
def handle(verbose):
if verbose:
DEBUG = True
if DEBUG:
return "подробно"
return "коротко"
print(handle(True))
print(handle(False))подробно
UnboundLocalError: cannot access local variable 'DEBUG'
where it is not associated with a valueОдна ветка работает, другая падает. Решение о том, что DEBUG — локальная переменная, принято при компиляции, для всей функции сразу, ещё до того как что-то исполнилось.Между «нажал Enter» и «выполнилась первая инструкция» проходит девять различимых фаз. Ни одна из них не спрятана: у каждой есть модуль в стандартной библиотеке, который позволяет вызвать её отдельно и посмотреть результат. Ниже — весь конвейер по порядку, с числами, снятыми на этой машине.
Python компилируется. Не в машинный код, а в байткод для стековой виртуальной машины — но проходит через полноценный компилятор: лексер, парсер, промежуточное представление, оптимизации, генерация кода.
Компиляция происходит не «заранее», а при первом импорте — и её результат кешируется в .pyc. Отсюда следует всё остальное поведение: и время старта, и __pycache__, и то, что синтаксическая ошибка в неимпортированном модуле не мешает программе работать.
Ядро запускает /usr/bin/python3 обычным execve. Динамический загрузчик подтягивает libpython3.11.so (или он вкомпилирован статически), управление уходит в main() из Programs/python.c — файл в двадцать строк, вся его работа состоит в вызове Py_BytesMain.
С точки зрения ОС Python — обычная C-программа. Никакой виртуальной машины уровня JVM с отдельным процессом-хостом здесь нет: интерпретатор это библиотека, слинкованная с крошечным main.
Modules/main.c разбирает аргументы и строит конфигурацию, после чего Python/pylifecycle.c поднимает интерпретатор. Порядок здесь неслучаен и решает проблему курицы и яйца:
sys, builtins, _imp — они скомпилированы в бинарник и не импортируются обычным путём, потому что обычного пути ещё нет.importlib._bootstrap и importlib._bootstrap_external — сам механизм импорта, написанный на Python. Его нельзя импортировать: он и есть импорт. Поэтому его байткод вшит в бинарник (Python/frozen.c). В 3.11 список замороженных расширили до четырнадцати модулей ради скорости старта.encodings нужен раньше, чем что-либо прочитается с диска.sys.path. Собирается из места бинарника, переменных окружения и .pth-файлов.site.py. Добавляет site-packages, обрабатывает .pth. Самая дорогая часть старта.| запуск | время | модулей в sys.modules |
|---|---|---|
python -c pass | 13.9 мс | 34 |
python -S -c pass | 9.5 мс | 23 |
python -c "import json" | 22.1 мс | — |
python -c "import numpy" | 101.6 мс | — |
Четыре с половиной миллисекунды — цена site.py. Восемьдесят восемь — цена одного import numpy. Для утилиты командной строки, которую вызывают в цикле из shell-скрипта, это и есть её производительность.
Файл читается как байты и декодируется. По умолчанию UTF-8; PEP 263 разрешает объявить другую кодировку строкой # -*- coding: cp1251 -*- в первых двух строках, и токенайзер вычитывает её раньше, чем начинает разбор. BOM тоже распознаётся.
Это единственная фаза, где Python видит байты. Дальше везде — str, то есть последовательность кодовых точек из урока 02.
Parser/tokenizer.c превращает текст в поток токенов. Главная особенность видна во фрагменте C: отступы становятся токенами. Токенайзер держит стек уровней отступа и на каждой строке сравнивает текущий с вершиной стека — глубже выдаёт INDENT, мельче выдаёт по DEDENT на каждый снятый уровень.
Отсюда три следствия, которые обычно считают причудами:
IndentationError — отдельный подкласс SyntaxError, потому что ошибка возникает на другой фазе и с другой диагностикой.INDENT вовсе. Поэтому длинный вызов можно переносить как угодно.С версии 3.9 (PEP 617) в CPython PEG-парсер вместо старого LL(1). Грамматика лежит в Grammar/python.gram и из неё генерируется Parser/parser.c — файл на десятки тысяч строк, который никто не пишет руками.
Практическое отличие PEG от LL(1): парсер может попробовать альтернативу, откатиться и попробовать следующую. Это сняло грамматические ограничения, из-за которых часть синтаксиса раньше была невыразима, и дало резко лучшие сообщения об ошибках — «Perhaps you forgot a comma?» появилось именно потому, что парсер способен разобрать неудачную ветку до конца и понять, чего в ней не хватило.
Парсер выдаёт дерево. Модуль ast отдаёт его же в Python-объектах — это буквально то самое дерево, а не отдельная реализация.
На дереве работает Python/ast_opt.c — оптимизатор, который вычисляет то, что можно вычислить сразу. Отсюда фрагмент B и вот это:
import ast
print(ast.dump(ast.parse("x = 1 + 2"), indent=2))
# в дереве останется BinOp: свёртка происходит позже, уже в компиляторе
code = compile("x = 1 + 2\ny = 'ab' * 3", "<s>", "exec")
print(code.co_consts)
# → (3, 'ababab', None)
В константах уже готовые значения: ни сложения, ни умножения при исполнении не будет. Оптимизатор осторожен — он не свернёт 'a' * 10**9, чтобы не раздуть .pyc до гигабайта.
Python/symtable.c делает отдельный полный проход по дереву до того, как сгенерирована хоть одна инструкция. Его единственная задача — для каждого имени в каждой области видимости решить, что это: локальная, глобальная, ячейка (переменная, которую захватывает вложенная функция) или свободная (захваченная у внешней).
import symtable
code = '''
g = 1
def outer():
a = 1
def inner():
nonlocal a
b = g
return a + b
return inner
'''
st = symtable.symtable(code, "<s>", "exec")
outer = st.get_children()[0]
inner = outer.get_children()[0]
for s in inner.get_symbols():
print(s.get_name(), "local" if s.is_local() else "",
"global" if s.is_global() else "", "free" if s.is_free() else "")
# → a free
# → b local
# → g global
Правило простое и статическое: имя, которому в функции где-либо присваивается значение, локально во всей этой функции. Не с момента присваивания — везде, включая строки выше него. Никакого анализа потока управления здесь нет.
Из этого одного правила выводится фрагмент D: в handle есть строка DEBUG = True, значит DEBUG локальна на протяжении всей функции, значит if DEBUG читает локальную. При verbose=True она к этому моменту присвоена, при verbose=False — нет. Отсюда же global и nonlocal: это не «модификаторы времени исполнения», а указания этой фазе, единственный способ переопределить её решение.
И отсюда же — почему области видимости в Python лексические и статические, хотя типы динамические. Тип неизвестен до исполнения; принадлежность имени к области известна всегда.
Python/compile.c обходит дерево, зная решения таблицы символов, и строит граф потока управления из базовых блоков. На графе делаются оптимизации уровня «убрать недостижимый блок», «схлопнуть цепочку переходов», после чего блоки раскладываются в линейный поток байтов.
Одновременно вычисляется три служебных структуры:
co_stacksize — максимальная глубина стека значений. Считается статически, чтобы при вызове функции выделить ровно столько места и больше никогда не проверять переполнение.co_linetable — сжатое соответствие «смещение в байткоде → строка исходника». То, из чего строится трассировка.co_exceptiontable — появилась в 3.11. Таблица «для диапазона инструкций от и до — обработчик находится там». Раньше вход в try исполнял инструкцию SETUP_FINALLY; теперь не исполняет ничего. Разбор — в уроке 23.Результат компиляции — объект code, обычный неизменяемый объект Python. Его можно получить, разобрать, передать, сохранить.
def demo(a, b=2, *args, kw=3, **kwargs):
total = a + b
for x in args:
total += x
return total
co = demo.__code__
print(co.co_argcount, co.co_kwonlyargcount, co.co_nlocals, co.co_stacksize)
# → 2 1 7 3
print(co.co_varnames)
# → ('a', 'b', 'kw', 'args', 'kwargs', 'total', 'x')
print(len(co.co_code), "байт байткода")
# → 36 байт байткода
Обрати внимание на порядок в co_varnames: сначала позиционные аргументы, потом keyword-only, потом *args и **kwargs, и только потом настоящие локальные. Порядок фиксирован компилятором, и по нему инструкция LOAD_FAST 0 знает, что ноль — это a. Имена в исполнении не участвуют: доступ к локальной переменной это индекс в массиве, а не поиск в словаре.
Это прямое следствие фазы 6. Локальные быстрые именно потому, что таблица символов решила всё заранее.
.pycКомпилировать один и тот же модуль при каждом запуске незачем, поэтому code object сериализуется через Python/marshal.c и кладётся в __pycache__. Формат простой до неприличия: шестнадцать байт заголовка и дальше marshal.
import struct, marshal, importlib.util, os
import json # что угодно из стандартной библиотеки
raw = open(json.__cached__, "rb").read()
magic, flags, mtime, size = struct.unpack("<4sIII", raw[:16])
print(magic.hex(), flags, mtime, size)
print(importlib.util.MAGIC_NUMBER.hex()) # тот же magic
obj = marshal.loads(raw[16:])
print(type(obj).__name__)
# → code
| поле | байты | смысл |
|---|---|---|
| magic | 0–3 | Версия формата байткода. Меняется почти каждый релиз — поэтому .pyc от 3.10 просто игнорируется |
| flags | 4–7 | 0 — валидация по времени, 1 — по хешу источника (PEP 552) |
| mtime | 8–11 | Время модификации исходника на момент компиляции |
| size | 12–15 | Размер исходника в байтах |
Валидация по умолчанию — сравнение mtime и размера, а не содержимого. Отсюда практическое: если развернуть код так, что время модификации файла окажется старше, чем записано в кеше, будет исполняться старый байткод. Именно поэтому в сборках, где важна воспроизводимость, включают hash-based .pyc (py_compile --invalidation-mode checked-hash).
Замер на модуле в 1199 строк: первый импорт с компиляцией — 23 мс, повторный с готовым кешем — 14 мс. Сама компиляция стоит 9 мс, и ровно столько платит каждый процесс, у которого __pycache__ недоступен на запись — например контейнер с read-only файловой системой.
Не весь код проходит фазу 9. exec, eval, compile и код из REPL компилируются каждый раз заново — кеша для них нет и быть не может, потому что нет файла. Строка, собираемая в цикле и передаваемая в eval, компилируется на каждой итерации целиком.
Режим компиляции меняет результат. compile(src, name, "exec"), "eval" и "single" дают разный байткод для одного текста: single — режим REPL, он добавляет печать результата выражения. Отсюда разница между «вставил в файл» и «вставил в интерактивную сессию».
Флаги -O и -OO меняют байткод. Первый выкидывает assert и код под if __debug__, второй ещё и строки документации. Кеш при этом кладётся под другим именем (.opt-1.pyc), так что перепутать версии нельзя, но код, чья корректность зависит от assert, ломается молча.
Всё содержимое этой главы — деталь реализации 🔧 целиком. Ни один опкод, ни размер code object, ни формат .pyc не являются частью языка. В 3.12 конвейер уже другой: генерация интерпретатора из Python/bytecodes.c, переработанная линеаризация блоков, другой набор инструкций. Гарантируется семантика, а не путь к ней.
Глава разворачивает R7 · импорт — это исполнение кода вниз, к механике. Утверждение «модуль исполняется один раз за процесс» на этом уровне означает: один раз проходятся фазы 2–8, результат кладётся в sys.modules, а фаза 9 переносит его ещё и между процессами.
Отсюда же видно, почему у Python нет фазы линковки. В C имена функций резолвит линковщик и записывает адреса в бинарник. Здесь имён в байткоде почти нет — есть индексы для локальных и строки в co_names для всего остального, и связывание происходит при каждом обращении, в рантайме. Это R2, увиденный со стороны компилятора: связывать заранее нечего, потому что связывать нечем.
И третье: фаза 6 показывает границу между статическим и динамическим в Python точнее любого определения. Область видимости решается статически, тип — динамически. Первое даёт быстрые локальные переменные и UnboundLocalError; второе даёт R8 — невозможность надёжного вывода типов.
Самая точная аналогия — сборка C-проекта без линковки.
gcc -c file.c даёт file.o: машинный код с дырками на месте внешних имён. Дальше линковщик заполняет дырки адресами. В Python первая половина есть, второй нет: code object — это тот же «объектный файл», где вместо адресов лежат строки, и заполнение дырок происходит при каждом исполнении инструкции.
__pycache__ при этом ведёт себя как make с одной оговоркой: make сверяет зависимости, а Python — только сам файл. Изменил модуль, от которого зависит другой, — второй не перекомпилируется, потому что его собственный mtime не изменился. Ему и не нужно: связывание-то в рантайме.
Отсюда практический вывод, знакомый всем, кто мучился с make clean: в Python не бывает «протухшей сборки» из-за зависимостей. Бывает протухшая из-за mtime самого файла — и это единственный сценарий, который стоит держать в голове.
Вопросы, которые возникают сами, если читать внимательно. Ответ — под вопросом.
Компилируются оба. Разница в том, кому адресован результат: C++ выдаёт машинный код, который исполняет процессор, Python — байткод, который исполняет другая программа. А машинный код Python выдать не может потому, что тип операндов неизвестен до момента выполнения: скомпилировать a + b в машинную инструкцию сложения не из чего.
Дальше — почему сама постановка вопроса неудачна.
В этой паре слов смешаны два разных вопроса, и «компиляция» значит две разные вещи.
Первое значение — перевод исходника в другое представление. Это Python делает, и вся глава про это. Второе — перевод в машинный код целевой машины, заранее, с получением самостоятельного артефакта. Этого Python не делает.
Настоящий различающий вопрос звучит иначе: кто исполняет результат — железо или программа? После gcc результат исполняет процессор. После compile() результат исполняет другая программа — цикл вычисления из урока 23. По этой оси Python интерпретируемый, и это единственный смысл, в котором утверждение верно.
Причём свойство приписано не тому. В спецификации языка про это нет ни слова: «компилируемый» и «интерпретируемый» — характеристики реализации, а не языка. Nuitka компилирует Python в C, с 3.13 в CPython есть JIT. И наоборот: есть интерпретаторы C — Cling, tcc -run. Никто при этом не говорит, что C стал интерпретируемым.
Добавь сюда то, что почти ничего «интерпретируемое» не интерпретирует текст. Ruby, Lua, JavaScript, Java — все компилируют в промежуточное представление. Прямая интерпретация исходника вымерла вместе с ранним BASIC. То есть популярное значение слова описывает реализации, которых больше нет.
Точная формулировка, которая заменяет всю пару: дело не в компиляции против интерпретации, а в раннем связывании против позднего. В C имена резолвит линковщик и вшивает адреса. В Python имя резолвится при каждом обращении — это R2. Язык с поздним связыванием невозможно скомпилировать в смысле C не потому, что никто не написал компилятор, а потому что вшивать нечего.
Ловится — но только то, что относится к синтаксису, и только в тех модулях, которые действительно импортируются.
Модуль компилируется целиком до исполнения первой строки. Проверяется буквально:
# selfbad.py
print("первая строка")
print("вторая")
def bad(:
pass
$ python3 selfbad.py
def bad(:
^
SyntaxError: invalid syntax
Ни одна из двух печатей не выполнилась. Это поведение компилируемого языка, и оно ровно такое же, как у gcc.
А вот сломанный модуль, который никто не импортирует, не проявится никогда — потому что фазы 2–8 для него просто не запускались. Отсюда классическое «в проде упало на редком пути, которого не было в тестах».
Чего не ловится: опечатки в именах, несуществующие атрибуты, несовпадение типов. Но не из-за интерпретации, а потому что в конвейере нет фазы проверки типов — это R8. Добавить её отдельным инструментом можно (mypy), встроить в конвейер нельзя, и урок 18 объясняет почему.
.pyc, спрятав исходники?Поставлять можно, спрятать нельзя.
Технически это работает: положи mod.pyc рядом (в старой раскладке, не в __pycache__), удали mod.py — импорт пройдёт.
import py_compile, os, pathlib
pathlib.Path("mod.py").write_text('''
SECRET = "виден ли я"
def f(): return SECRET
''')
py_compile.compile("mod.py", cfile="mod.pyc")
os.remove("mod.py")
import sys; sys.path.insert(0, ".")
import mod
print(mod.f())
# → виден ли я ← исходника нет, модуль работает
А теперь то, ради чего это обычно затевают:
raw = open("mod.pyc", "rb").read()
print("виден ли я".encode() in raw) # → True
print(b"SECRET" in raw) # → True
Все строковые литералы и все имена лежат в .pyc открытым текстом — они обязаны там быть, потому что co_names и co_consts нужны при исполнении. Плюс существуют декомпиляторы, восстанавливающие из байткода читаемый Python почти без потерь: структура управления в байткоде сохранена, а имена переменных лежат в co_varnames.
Итог: .pyc-поставка добавляет ровно один барьер — «нельзя открыть в редакторе». От человека, которому действительно нужно посмотреть, она не защищает никак. И привязывает поставку к минорной версии Python намертво: magic в заголовке меняется каждый релиз.
Можно, и это делают — но то, что получается, содержит внутри целый интерпретатор.
Два разных подхода, и оба упираются в одно:
PyInstaller, PyOxidizer. Никакой компиляции вообще: складывают в один файл интерпретатор, стандартную библиотеку, твой байткод и зависимости. Это архив с распаковщиком. Размер — десятки мегабайт, старт — плюс время на распаковку.
Nuitka. Настоящая компиляция: превращает Python в C и собирает компилятором. Но сгенерированный C — это не «твой алгоритм на C», а последовательность вызовов CPython API, делающая ровно то, что делал бы цикл вычисления. Поэтому бинарник линкуется с libpython, а ускорение обычно в пределах десятков процентов, а не в разы.
Почему иначе не выходит. Чтобы скомпилировать a + b в машинную инструкцию сложения, компилятору нужно доказать, что a и b — числа определённого типа. В Python этого доказать нельзя: тип определяется в момент обращения (R2), а любое расширение на C может подменить у объекта что угодно (R6). Остаётся генерировать код, который в рантайме проверяет типы и выбирает операцию — то есть ровно интерпретатор, только развёрнутый в прямую линию.
Go компилируется в самостоятельный бинарник не потому, что его авторы старательнее, а потому что там типы известны статически. Это не разница в качестве реализации, это разница в том, что язык обещает.
dis лежит в стандартной библиотеке?Потому что «не гарантируется» и «не показывается» — разные вещи, и Python последовательно выбирает второе.
dis, symtable, ast, marshal открыты не потому, что их поведение обещано, а потому что закрывать их бессмысленно: на них стоят отладчики, профилировщики, покрытие, линтеры и codemod-инструменты. Спрятать конвейер — значит убить весь этот слой.
Цену за открытость платят регулярно: каждый релиз ломает инструменты, разбирающие байткод. Именно поэтому dis._inline_cache_entries и dis._specialized_instructions названы с подчёркиванием — «смотри, но не жалуйся».
Здесь виден общий принцип канона в чистом виде: механизм показан, обещания на него нет. Ровно та граница, которую размечает шкала 🔒 · 🔧 · 🕳, и ровно тот навык, который она тренирует — различать «я это вижу» и «на это можно опереться».
python script.py и первой инструкцией девять фаз. Процесс стартует как обычная C-программа; интерпретатор поднимает встроенные и замороженные модули, потому что механизм импорта сам написан на Python и импортировать его нечем — 34 модуля и 13.9 мс до первой твоей строки, из них 4.5 мс на site.py. Дальше исходник декодируется, токенайзер превращает отступы в настоящие токены, PEG-парсер строит дерево, оптимизатор сворачивает константы. Затем идёт самая недооценённая фаза: таблица символов отдельным проходом решает для каждого имени, локальное оно или глобальное — статически, для всей функции сразу, без анализа потока управления. Отсюда UnboundLocalError, отсюда global и nonlocal, отсюда быстрые локальные переменные как индекс в массиве вместо поиска в словаре. Компилятор строит граф базовых блоков и раскладывает его в байткод, попутно вычисляя глубину стека, таблицу строк и таблицу исключений. Результат — обычный неизменяемый объект code, который marshal кладёт в .pyc под шестнадцатибайтовым заголовком, а валидируется он по времени модификации и размеру, а не по содержимому. Весь конвейер целиком — 🔧 деталь реализации: гарантируется семантика, а не путь к ней.C и C++. Компиляция полностью отделена от исполнения: препроцессор, компилятор, ассемблер, линковщик — и на выходе машинный код с уже проставленными адресами. Ошибка в неиспользуемой функции ловится до запуска; в Python синтаксическая ошибка в неимпортированном модуле не проявится никогда. Цена симметрична: правка одной строки требует пересборки и перезапуска, а в Python модуль перекомпилируется сам и незаметно.
Java. Ближе всего: javac даёт байткод в .class, JVM его исполняет. Но компиляция там — явный шаг сборки, и .class — часть артефакта, а не кеш. В Python .pyc можно удалить в любой момент без последствий: это оптимизация, а не результат.
JavaScript. Никакого кеша между запусками — движок парсит и компилирует исходник при каждой загрузке. Отсюда весь фронтендный инструментарий минификации: там размер исходника напрямую влияет на время старта. У Python эта проблема решена .pyc — и поэтому в Python никого не волнует длина имён переменных.
Флаг отладки, который включается по условию. Читается идеально, ревью проходит, тесты проходят.
DEBUG = False
def handle(request, verbose=False):
if verbose:
DEBUG = True # ← «включим подробный режим»
if DEBUG:
log_everything(request)
return process(request)
Что происходит. Присваивание DEBUG = True в теле функции делает имя локальным на всю функцию — решение принято таблицей символов при компиляции, до всякого исполнения. Строка if DEBUG читает локальную переменную. При verbose=True она уже присвоена и всё работает; при verbose=False — UnboundLocalError.
Почему проходит ревью. Глазами читается как «глобальный флаг, который иногда переопределяем». Модуль-уровневый DEBUG = False прямо над функцией усиливает иллюзию. А тесты на подробный режим пишут первыми — и они зелёные.
Проверить за десять секунд:
import dis
dis.dis(handle)
# Ищи LOAD_FAST DEBUG вместо LOAD_GLOBAL DEBUG.
# Ещё быстрее:
print(handle.__code__.co_varnames) # если DEBUG здесь — он локальный
print(handle.__code__.co_names) # если DEBUG здесь — глобальный
Как чинить. Не через global — глобальный флаг, который меняют из обработчика запроса, это ошибка сама по себе, особенно с потоками из урока 19. Правильно — не трогать глобальное состояние: if verbose or DEBUG: и никакого присваивания.
Общее правило. Присваивание имени где угодно в функции делает его локальным везде в этой функции. Единственный способ прочитать это из кода — co_varnames против co_names.
Типовой CLI: одна тяжёлая команда, десяток лёгких, и import numpy в шапке файла.
# cli_eager.py — импорт наверху
import sys
import numpy as np # нужен только для команды "stats"
def main(argv):
if argv[1:2] == ["stats"]:
print(np.arange(10).mean())
else:
print("usage: cli [stats]")
main(sys.argv)
# cli_lazy.py — импорт по месту
import sys
def main(argv):
if argv[1:2] == ["stats"]:
import numpy as np # платим только когда действительно нужно
print(np.arange(10).mean())
else:
print("usage: cli [stats]")
main(sys.argv)
| команда | импорт наверху | импорт по месту |
|---|---|---|
| обычная (usage) | 105 мс | 14 мс |
| тяжёлая (stats) | 112 мс | 115 мс |
Семь с половиной раз на частом пути и три миллисекунды штрафа на редком. Причина прямо из фазы 1: import на верхнем уровне — это исполнение кода при старте процесса, а не объявление зависимости.
Где искать, не гадая:
python -X importtime -c "import myapp" 2>&1 | sort -k2 -nr | head -15
Второй столбец — собственное время модуля, третий — с учётом вложенных импортов. Строка в самом верху и есть ответ.
Когда это оправдано. Утилиты командной строки, serverless-функции с холодным стартом, любой процесс, который запускается чаще, чем работает. Для долгоживущего сервиса выигрыш нулевой — там процесс стартует один раз, и ленивый импорт только добавит непредсказуемости: тяжёлый модуль загрузится на первом запросе вместо старта.
Чего не делать. Не переносить в функции всё подряд ради принципа: import внутри функции проверяет sys.modules при каждом вызове, это дёшево, но не бесплатно, а читаемость страдает заметно. Переносить стоит только то, что показал -X importtime.
Весь конвейер доступен из стандартной библиотеки. Прогони по фазам и сверься с предсказанием.
import io, tokenize, ast, symtable, dis, marshal, struct
SRC = '''
DEBUG = False
def handle(verbose):
if verbose:
DEBUG = True
return DEBUG
'''
# фаза 3 — токены
for t in list(tokenize.generate_tokens(io.StringIO(SRC).readline))[:12]:
print(tokenize.tok_name[t.type], repr(t.string))
# фаза 5 — дерево
print(ast.dump(ast.parse("x = 1 + 2"), indent=2))
# фаза 6 — кто локальный, кто глобальный
st = symtable.symtable(SRC, "<s>", "exec")
fn = st.get_children()[0]
for s in fn.get_symbols():
print(s.get_name(), "local" if s.is_local() else "global")
# фазы 7–8 — байткод и code object
code = compile(SRC, "<s>", "exec")
handle_co = [c for c in code.co_consts if hasattr(c, "co_code")][0]
print(handle_co.co_varnames, handle_co.co_names)
dis.dis(handle_co)
Вопросы к результату: почему DEBUG оказался в co_varnames, а не в co_names? Какая инструкция читает его — LOAD_FAST или LOAD_GLOBAL? Что изменится, если добавить в функцию строку global DEBUG?
И отдельно — сколько стоит компиляция:
import timeit, pathlib, json
src = pathlib.Path(json.__file__).read_text()
print(timeit.timeit(lambda: compile(src, "json.py", "exec"), number=100) / 100 * 1000, "мс")
# Столько платит каждый процесс, у которого __pycache__ недоступен на запись.
Lib/tokenize.py. Чистый Python-токенайзер, совместимый с сишным. Полезен не сам по себе, а для инструментов: любой линтер, форматтер и codemod начинается с него, потому что он сохраняет комментарии и пробелы, которые AST теряет.
Lib/ast.py. ast.NodeTransformer — тридцать строк, на которых стоят все автоматические миграции кода. Стоит прочитать ast.unparse: он показывает, что именно дерево не хранит — комментарии, кавычки, скобки для читаемости.
Lib/symtable.py. Тонкая обёртка над фазой 6. Единственный способ увидеть решение компилятора об имени, не читая байткод. Полезен, когда споришь, локальная переменная или нет.
Lib/dis.py. Кроме dis.dis, там есть dis.get_instructions, дающий структурированный поток, и dis._parse_exception_table, показывающий таблицу исключений из фазы 7.
Lib/py_compile.py и Lib/compileall.py. Второй — то, чем прогревают __pycache__ в docker-образе, чтобы не платить компиляцию на каждом старте контейнера. Одна строка в Dockerfile, экономящая десятки миллисекунд на каждом запуске.
Lib/importlib/_bootstrap_external.py. Здесь функция _validate_timestamp_pyc — те самые двадцать строк, которые решают, доверять кешу или перекомпилировать. Читается за минуту и снимает все вопросы про __pycache__.
Code object целиком. Всё, что нужно для исполнения функции, кроме её текущего состояния:
| поле | значение для demo | зачем |
|---|---|---|
co_argcount | 2 | Сколько позиционных параметров принимает |
co_kwonlyargcount | 1 | Сколько только-именованных |
co_nlocals | 7 | Размер массива локальных, выделяемого при вызове |
co_stacksize | 3 | Максимальная глубина стека значений, посчитана статически |
co_flags | 15 | OPTIMIZED | NEWLOCALS | VARARGS | VARKEYWORDS |
co_varnames | 7 имён | Только для отладки: исполнение работает по индексам |
co_names | — | Имена, которые придётся искать в рантайме |
co_consts | (None,) | Константы, включая вложенные code object |
co_code | 36 байт | Собственно инструкции |
co_linetable | 42 байта | Сжатая карта «смещение → строка». Больше, чем сам байткод |
co_exceptiontable | 0 байт | Пусто: в функции нет try |
Таблица строк здесь больше байткода — 42 против 36. Это цена читаемых трассировок, и в 3.11 её сознательно увеличили ради точных колонок в сообщениях об ошибках (PEP 657): теперь стрелочка под выражением указывает на конкретный операнд.
Вложенность. Code object функции лежит в co_consts модуля. Вложенная функция — в co_consts внешней. Всё дерево модуля сериализуется одним marshal-вызовом:
code = compile("def outer():\n def inner(): pass\n return inner", "<s>", "exec")
outer = [c for c in code.co_consts if hasattr(c, "co_code")][0]
inner = [c for c in outer.co_consts if hasattr(c, "co_code")][0]
print(code.co_name, "→", outer.co_name, "→", inner.co_name)
# → <module> → outer → inner
Размер кеша. Модуль из двух строк (VALUE = 42 и однострочная функция), 33 байта исходника, даёт .pyc в 269 байт — в восемь раз больше. Причина: заголовки объектов, таблицы строк и имена в каждом code object. Для стандартной библиотеки соотношение лучше, но __pycache__ всё равно обычно крупнее исходников, и в тонких docker-образах это заметная величина.
Свёртка констант в цифрах. Оптимизатор AST сворачивает арифметику, конкатенацию и повторение строк, но с ограничителем на размер результата:
print(compile("x = 'a' * 100", "<s>", "exec").co_consts)
# → ('aaaa...', None) — свёрнуто
print(compile("x = 'a' * 10**9", "<s>", "exec").co_consts)
# → ('a', 1000000000, None) — не свёрнуто, иначе .pyc был бы гигабайтным
Редкий случай, когда исходники читаются по порядку: конвейер линеен, и каждой фазе соответствует один файл.
| фаза | файл |
|---|---|
| 0 · вход | Programs/python.c — двадцать строк |
| 1 · конфигурация | Python/initconfig.c, Modules/main.c |
| 1 · подъём | Python/pylifecycle.c — Py_InitializeFromConfig, и там же Py_FinalizeEx для урока 23 |
| 1 · frozen | Python/frozen.c — список вшитых модулей |
| 3 · токены | Parser/tokenizer.c — стек отступов ищи в tok_get |
| 4 · грамматика | Grammar/python.gram — читается как EBNF |
| 4 · парсер | Parser/pegen.c — движок; Parser/parser.c — сгенерированный |
| 5 · свёртка | Python/ast_opt.c — весь файл про константы |
| 6 · символы | Python/symtable.c — комментарий в шапке объясняет фазу лучше любой статьи |
| 7 · компилятор | Python/compile.c — самый большой файл конвейера |
| 8 · code object | Include/cpython/code.h, Objects/codeobject.c |
| 9 · marshal | Python/marshal.c — формат сериализации, тип на байт |
С чего начать, если открывать один файл. Python/symtable.c: комментарий в шапке на сорок строк излагает фазу 6 целиком, и после него UnboundLocalError, global и nonlocal перестают быть тремя отдельными фактами.
Что изменилось после 3.11. В 3.12 интерпретатор генерируется из Python/bytecodes.c — файла на предметно-ориентированном языке, из которого собираются и цикл вычисления, и таблицы, и документация опкодов. Если читать main, структура будет другой; всё, что здесь описано, относится к 3.11.
sys.modules, порядок исполнения, циклические импорты. Эта глава — его нижний этаж.obj.x насквозь — co_names из фазы 8 это ровно те имена, которые придётся искать по процедуре из четвёртого урока. Локальные быстрые, потому что их разрешили здесь.co_exceptiontable из фазы 7 объясняет, почему в 3.11 try перестал стоить хоть что-нибудь. Механика — в уроке 23..pyc. Короткий, и в нём прямо изложено, почему валидация по mtime ненадёжна в сборочных системах.co_linetable в 3.11 вырос и что мы за это получили.