«Python компилируется или интерпретируется? Оба."

Внутреннее устройство Python: введение

Прекрасный путь от запуска CPython до выполнения кода

Отказ от ответственности: эта статья может содержать больше кода C, чем кода Python.

Python увлекателен и, вероятно, ближе всего человечество к исполняемому псевдокоду. Он привлекает множество людей, которые никогда раньше не программировали, чтобы прийти и попробовать и, возможно, открыть для себя совершенно новые таланты - например, я. Спустя пару лет я перешел к основным темам компьютерных наук, расширив всю учебную программу для получения степени по компьютерным наукам. Я не забыл о том, откуда я пришел, и с блаженством оглядываюсь назад на то время, когда, по общему признанию, сосредоточился на простейших концепциях, таких как циклы, функции и классы. Тем не менее, идея о том, что мне нужно будет запустить отдельную программу для выполнения моего кода, хотя с детства все, что требовалось для запуска любой программы на моем компьютере, заключалась в двойном щелчке по одному .exe файлу на экране, долго была вне меня.

В этой и следующих статьях я хотел бы исследовать внутреннюю жизнь интерпретатора CPython, прежде всего его среду выполнения. Нетрудно найти материал, связанный с другими внутренними функциями интерпретатора Python, такими как процесс компиляции или интерпретация байт-кода. В моем обзоре предполагается сосредоточить внимание на машинном аспекте внутренней виртуальной машины Python. Глубокое погружение в источник неизбежно, чтобы увидеть и понять, что на самом деле происходит. Давайте сначала наивно пройдемся по стеку вызовов и чудесным образом посмотрим, как среда выполнения развивается вокруг нас. В конце концов мы перейдем к более интригующим вопросам.

В конце я хотел бы найти ответы на следующие вопросы: Как выглядит машина в виртуальной машине Python? Как он управляет процессами и потоками? Какова структура памяти внутри виртуальной машины?

С этого момента Python ссылается на CPython версии 3.9, последней на момент написания. Я использовал 64-разрядную версию Windows 10 и Visual Studio 2019 для создания и анализа исходного кода. Обсуждаемые здесь вещи, скорее всего, не будут выполняться для других реализаций Python, таких как Jython или IronPython. Я ожидаю, что различия в использовании другой операционной системы будут достаточно очевидными, когда бы они ни возникали.

Я редактировал и комментировал любой показанный здесь исходный код для краткости, ясности и удобочитаемости. Я настоятельно рекомендую прочитать исходный код CPython, чтобы получить полную картину.

Общий обзор

Python - это компилируемый или интерпретируемый язык? Оба. Python компилируется в байт-код, который затем интерпретируется виртуальной машиной. Когда мы загружаем интерпретатор исходным кодом Python, мы можем концептуально представить себе два шага:

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

Макет проекта

Чтобы лучше ориентироваться перед тем, как погрузиться в исходный код, может быть полезно ознакомиться с тем, как организован исходный каталог проекта CPython:

Интерпретатор реализован как разделяемая библиотека в трех подкаталогах Objects/, Include/ и Python/. Реализация стандартной библиотеки Python находится в отдельной папке, но модули расширения C, расположенные в каталоге Modules/, также используют заголовки Python, эффективно загружая части интерпретатора в виде библиотеки. То же верно и для сторонних библиотек, таких как numpy, которые реализованы через Python / C API.

Переход вниз `main`

Показанная ниже функция main находится в Programs/python.c. Но фактическая точка входа в интерпретатор - Py_Main, расположенная в Modules/main.c.

Даже если вы раньше не программировали изначально, вы, вероятно, знакомы с функцией main. Но что такое wmain? Что ж, Windows поддерживает как 8-битные типы символов ANSI, так и UTF-16, собственный тип символов в Windows. В дополнение к стандартным символьным строкам C Windows API предоставляет варианты для всех своих функций, принимающих также собственные широкие символьные строки Windows или UTF-16.

На три уровня вниз по стеку вызовов мы проходим pymain_main. Пока что мы немного повозились с аргументами командной строки. Затем выполняется пара процедур инициализации, в которых объект конфигурации собирается из аргументов командной строки и переменных среды. Через два уровня, внутри pymain_run_python, мы подходим к точке пересечения:

В зависимости от того, как он был вызван, интерпретатор должен решить, в каком режиме работать. Интересное примечание сбоку: интерпретатор Python имеет собственное представление исходного кода с помощью структуры PyInterpreterState. Предоставленные флаги и аргументы командной строки определяют, как продолжить отсюда:

  • Флаг -c вызывает ветвь pymain_run_command
  • Флаг -m занимает ветку pymain_run_module
  • Если вместо этого указано имя файла, вызывается pymain_run_file
  • В противном случае прочтите все, что потенциально было передано через <stdin>, и войдите в интерактивный режим (pymain_run_stdin и pymain_repl)

Бит между написанием кода и запуском кода: компилятор

Независимо от того, какая ветвь выбрана, в какой-то момент интерпретатору придется взять исходный код Python и скомпилировать его. Процесс компиляции Python включает четыре отдельных преобразования.

Исходный код сначала принимается парсером для токенизации. Токены расположены в виде узлов в дереве синтаксического анализа, представляющем лексическую структуру кода. Затем дерево синтаксического анализа преобразуется в абстрактное синтаксическое дерево (AST), где токены группируются и интерпретируются как синтаксические элементы. «Грамматика» языка Python определяет, представляет ли поток лексических токенов синтаксически правильный код Python. В-третьих, AST преобразуется в граф потока управления (CFG). CFG по-прежнему имеет древовидную структуру. Поэтому компилятор должен сначала сгладить график, прежде чем он сможет сгенерировать байт-код. Наконец, компилятор выдает свой вывод в виде объектов кода, которые содержат сгенерированный байт-код вместе с дополнительной информацией, необходимой для выполнения этой единицы кода.

Объекты кода - это полноценные объекты Python, о чем свидетельствуют а) имя объекта и б) поле PyObject_HEAD в структуре PyCodeObject. Это также означает, что они полностью проверяются во время выполнения - например, так:

>>> def foo(x, y):
...     return x + y
...
>>> foo.__code__
<code object foo at 0x00...0F50, file "<stdin>", line 1>
>>> foo.__code__.co_code
b'|\x00|\x01\x17\x00S\x00'

Мы видим, что представление байт-кода очень компактно. Если кто-то предпочитает более удобочитаемое представление, можно использовать dis module, который является частью стандартной библиотеки Python:

>>> from dis import dis
>>> dis(foo.__code__)
  1           0 LOAD_FAST                0 (x)
              2 LOAD_FAST                1 (y)
              4 BINARY_ADD
              6 RETURN_VALUE

Это дизассемблированная версия байт-кода, выданного компилятором для нашей foo функции. Оптимизирует ли компилятор Python код? Да, это так, но в очень ограниченной степени. Например, он удаляет мертвый код и сворачивает простые константные выражения. Если вас интересует, какой вид оптимизации применяет Python, взгляните на PyCode_Optimize в Python/peephole.c. Прежде чем продолжить наш обход, я хотел бы указать на одно поле со странным названием в PyCodeObject, которое называется void *co_zombieframe. Это артефакт стратегии управления памятью Python, и он снова появится позже в более разумном контексте, когда речь идет об управлении памятью.

Это все, что мы собираемся обсуждать в компиляторе. Заинтересованные читатели могут найти более подробную информацию в Руководстве разработчика Python (s. [5]), и хотя оно уже немного устарело, я также очень рекомендую блог Эли Бендерски (s. [2]) ).

Последняя миля: объекты кода и оценка кода

Вернемся к тому месту, где мы были: получение исходного кода немного сложнее при работе в интерактивном режиме или при запуске модуля на Python, потому что Python требует больше работы, чтобы собрать все необходимые файлы или запросить ввод от пользователя. Фактически, когда вы запускаете модуль, интерпретатор фактически передает большую часть ответственности модулю runpy, который также является частью стандартной библиотеки Python. Но независимо от режима интерпретатора пути снова сходятся, когда объект кода передается в run_eval_code_obj. Оттуда передаются некоторые незаметные функции, пока не достигнет _PyEval_EvalCode. Тем временем мы прибыли в Python/ceval.c.

Вот три объекта, которые тесно связаны, чтобы научить других: уже знакомые PyCodeObject, PyFrameObject и PyThreadState. Исходная функция, включая те части, которые я пропустил, немного утомительна. Большая часть его служит для инициализации объекта фрейма. Объект фрейма можно понимать как представление объекта кода во время выполнения, в отличие от процесса, представляющего программу во время выполнения.

Если вы уже были знакомы с внутренней архитектурой Python, возможно, вы уже знаете, что будет дальше: основной цикл интерпретатора с печально известным оператором switch.

Ха, странно ... Это не цикл оценки ядра из 2000+ строк, который мы ожидали увидеть. Функция оценки для этого кадра динамически вызывается через указатель функции, который был сохранен внутри экземпляра PyInterpreterState в какой-то момент ранее во время инициализации. Указатель функции непрозрачен для того, где именно он отправляется, но его имя дает подсказку. Чтобы понять, что происходит за пределами этой точки, давайте вернемся назад и посмотрим на процесс инициализации.

Время выполнения

Возвращаясь к тому месту, откуда мы пришли, помните, что мы прошли первую процедуру инициализации в pymain_main - третьем уровне в стеке вызовов. Мы кратко упомянули, что инициализация произошла, но перешагнули через вызов pymain_init.

Состояние выполнения

Инициализация Python происходит в три этапа. Первый - это инициализация среды выполнения Python. _PyRuntime статически инициализируется в Python/pylifecycle.c. Это структура _PyRuntimeState, которая сама определена в Include/internal/pycore_runtime.h. Он отслеживает ряд скрытых состояний, которые напрямую не отображаются в пользовательском пространстве.

На рисунке 4 показаны три поля состояния выполнения, которые будут представлять наибольший интерес в будущем. Первое поле представляет собой связанный список состояний интерпретатора. Следующие два поля указывают на две подсистемы, наименования которых менее очевидны: _ceval_runtime_state и _gilstate_runtime_state.

Состояние ceval - это первая подсистема, которая инициализируется _PyRuntimeState_Init_impl в Python/pystate.c. Состояние ceval - это прокси для Python/ceval.c. В его обязанности входит управление и обеспечение доступа для сохранения к оценщику кадров - _PyEval_EvalFrameDefault, который мы еще не рассматривали. Как ни странно, оценщик не привязан к состоянию ceval, но, как мы видели ранее, на него ссылаются и вызываются через запущенный экземпляр состояния интерпретатора. Состояние Ceval по-прежнему играет решающую роль в оценке объектов фрейма: оно отслеживает, кто в настоящее время владеет глобальной блокировкой интерпретатора (GIL) и кому, следовательно, разрешено входить в цикл оценки. Что такое GIL? Это сильно обсуждаемый пережиток того времени, когда многопоточность по-прежнему означала, что на одном ядре выполняется несколько потоков, а не много. Роль GIL и то, что он делает, станет яснее в следующей статье, когда мы будем говорить о параллелизме и согласованности данных.

После установки локали и некоторых переменных среды настраивается распределитель по умолчанию для интерпретатора. Доступно четыре разных распределителя, каждый из которых имеет три вида, зависящих от домена: PYMEM_DOMAIN_RAW, PYMEM_DOMAIN_MEM и PYMEM_DOMAIN_OBJ. Последний, очевидно, является доменом для распределения объектов Python, в то время как предыдущие два передают запросы на выделение системному распределителю и отличаются только тем, контролируют ли они безопасность потоков или нет. Доступные распределители: default, debug, pymalloc - кстати, распределитель по умолчанию - и malloc.

Интерпретатор, сборщик мусора и основной поток

Последний бит инициализации происходит в два этапа: сначала инициализация ядра, а затем основная инициализация. pyinit_core в Python/pylifecycle.c создает первый экземпляр интерпретатора и вместе с ним первый поток. В PyInterpreterState_New мы находим то, что искали:

Интерпретатор получает свой оценщик кадра: указатель функции на _PyEval_EvalFrameDefault. Затем новый интерпретатор добавляется к списку интерпретаторов времени выполнения, который мы видели на рисунке 4. Мы можем выделить еще один важный подмодуль инициализируемого интерпретатора: Сборщик мусора.

Инициализацию сборщика мусора осуществляет _PyGC_InitState в Modules/gcmodule.c. Еще не зная точной внутренней работы, мы видим, как сборщик мусора получает массив из трех поколений сборщика мусора. Каждое поколение, в свою очередь, имеет указатель заголовка на связанный список объектов, которые отслеживаются сборщиком мусора. Сам сборщик мусора поддерживает указатель заголовка на нулевой список объектов генерации.

count - это количество живых объектов, отслеживаемых в настоящее время в каждом поколении, а threshold - это количество отслеживаемых объектов в каждом поколении, которое инициирует попытку сбора сборщиком мусора. Пороги инициализируются на 700, 10, 10 для первого, второго и третьего поколения соответственно. Вы можете подумать, что пороговые значения кажутся достаточно низкими, чтобы они часто превышались даже средними приложениями. В конце концов, каждое целое число, каждое число с плавающей запятой - это 61_ со всем связанным багажом. Но сборщик мусора Python на самом деле отслеживает не все типы объектов Python, а только те, которые подвержены риску создания ссылочных циклов - в первую очередь изменяемые контейнеры. Циклы ссылок препятствуют тому, чтобы счетчик ссылок объекта когда-либо достигал нуля и, следовательно, не был освобожден - другими словами: они приводят к утечке памяти. Роль сборщика мусора в Python - предотвратить это.

Затем создается новый PyThreadState. Он получает метод доступа для получения собственного текущего кадра, а затем принимает глобальную блокировку интерпретатора.

После инициализации ядра среды выполнения первый интерпретатор и его первый поток запускаются и работают. Основная инициализация, которая завершает процесс инициализации, наконец, устанавливает все встроенные модули, такие как sys и __main__, а также другие функции, доступные программисту Python.

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

Заключение

Внимательный читатель заметит, что я не дал удовлетворительного ответа на изначально поставленные вопросы. Но ради моего рассудка и внимания всех остальных мы собираемся уделить время каждому вопросу в специальной статье, соответственно, рассматривая среду выполнения Python с разных сторон.

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

Следующая статья будет посвящена процессам и потокам Python. Мы собираемся более подробно изучить роли, которые состояние интерпретатора, состояние потока и фрейм-объекты играют в оценке кода Python. Мы собираемся посмотреть, как Python организует и извлекает данные во время выполнения, как он поддерживает согласованность данных и порядок выполнения в многопоточной, многопроцессорной среде, и в процессе раскроет тайну вокруг Global Interpreter Lock. .

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

Это будет тяжелая работа, но я ее уже жду.

использованная литература

[1] А. Шоу: Ваш путеводитель по исходному коду CPython. Настоящий Python, 2019, https://realpython.com/cpython-source-code-guide/. Последнее посещение: 2 августа 2020 г.

[2] Э. Бендерский: Внутреннее устройство Python. 2009–2015, https://eli.thegreenplace.net/tag/python-internals. Последнее посещение: 2 августа 2020 г.

[3] Г. фон Россум: История Python. Философия дизайна Python. 2009 г., http://python-history.blogspot.com/2009/01/pythons-design-philosophy.html. Последний визит: 2 августа 2020 г.

[4] П. Гуо: Внутреннее устройство CPython: десятичасовой обход исходного кода интерпретатора Python. 2014 г., http://pgbovine.net/cpython-internals.htm. Последнее посещение: 2 августа 2020 г.

[5] Руководство разработчика Python: Дизайн компилятора CPython. Python Software Foundation, 2020, https://devguide.python.org/compiler/. Последнее посещение: 2 августа 2020 г.

[6] Руководство разработчика Python: Изучение внутреннего устройства CPython. Python Software Foundation, 2020, https://devguide.python.org/compiler/. Последнее посещение: 2 августа 2019 г.

[7] Ю. Акнин: Внутренности Питона. 2020, https://tech.blog.aknin.name/category/my-projects/pythons-innards/. Последний визит: 2 августа 2020 г.