Для любого языка программирования, компилируемого в байт-код, самой интересной частью его реализации является виртуальная машина (также называемая интерпретатором байт-кода), где и происходит выполнение этого байт-кода. Поскольку это ключевой элемент работы языка, его реализация должна быть высокопроизводительной. Даже если вы не являетесь разработчиком компиляторов, изучение этих механизмов может дать новые идеи для оптимизации производительности. А если вы работаете с компиляторами, то вам всегда полезно изучать, как реализованы другие языки — чтобы почерпнуть детали, которые ранее могли быть вам неизвестны.
В этой статье мы рассмотрим формат инструкций байт-кода в CPython, а также реализацию цикла его интерпретации, в котором происходит выполнение байт-кода.
Оглавление:
Архитектура виртуальных машин байт-кода
Выполнение байт-кода в виртуальной машине
Внутреннее устройство виртуальной машины CPython
Выполнение программы на Python в виртуальной машине CPython
Архитектура виртуальных машин байт-кода
Когда речь идёт о языках программирования, компилируемых в машинный код, задача компилятора — преобразовать код с исходного языка в набор инструкций, которые может выполнить целевая машина. Эта целевая машина может быть реальным оборудованием, таким как архитектуры X86, ARM или RISC-V, каждая из которых имеет собственный предопределённый набор инструкций.
Генерация кода для различных аппаратных архитектур — задача сложная. Более того, это создаёт проблемы с переносимостью: машинный код, скомпилированный для одной архитектуры процессора, будет работать только на соответствующих устройствах. Чтобы запустить ту же программу на другой архитектуре, необходимо повторно скомпилировать код.
Из-за этих сложностей многие языки программирования используют виртуальные машины (VM). Виртуальная машина — это программная эмуляция аппаратной машины, которая исполняет набор абстрактных инструкций, не привязанных к конкретной аппаратной архитектуре. Задача компилятора — перевести исходный код в эти инструкции. Компиляция для виртуальной машины упрощает процесс, а также решает проблему переносимости: скомпилированную один раз программу можно запустить на любом оборудовании, где доступна реализация этой виртуальной машины.
Python также использует виртуальную машину, и его встроенный компилятор генерирует для неё инструкции, называемые байт-кодом.
Типы виртуальных машин
Подобно реальным аппаратным машинам, виртуальные машины можно классифицировать на два основных типа. Первый — это регистровые виртуальные машины, в которых инструкции используют регистры для хранения операндов. Большинство современных аппаратных машин также основаны на регистрах. Такие машины обычно обеспечивают более высокую производительность, однако их сложнее реализовать с точки зрения компилятора из-за задач, таких как распределение регистров.
Альтернативой является стековая виртуальная машина. В этом случае инструкции работают с операндами, которые помещаются в стек и извлекаются оттуда для выполнения операций. Этот подход проще для реализации, и генерация инструкций компилятором также становится менее сложной.
В случае Python используется стековая виртуальная машина. Другие языки, такие как Ruby, JavaScript и Java, также используют стековые виртуальные машины.
Инструкции байт-кода
Теперь давайте поговорим о том, как выглядят инструкции для стековых виртуальных машин и как они записываются в виде байт-кода. Рассмотрим гипотетическую стековую виртуальную машину, которая поддерживает следующие шесть инструкций:
PUSH <value> # pushes the given value onto the stack
ADD # pops top two values from the stack, adds them and pushes result back onto the stack
SUB
MUL
DIV
HALT # Marks the end of the program and current stack top becomes return value
Каждой из этих инструкций можно назначить целочисленный опкод. Например:
PUSH: 0
ADD: 1
SUB: 2
MUL: 3
DIV: 4
HALT: 5
По мере того как компилятор генерирует код для этой виртуальной машины, он будет выдавать одно из этих значений опкода в зависимости от типа выполняемой операции. Например, при компиляции выражения "1 + 2", компилятор сгенерирует следующую последовательность опкодов:
PUSH 1
PUSH 2
ADD
HALT
Две инструкции PUSH поместят операнды в стек, а инструкция ADD извлечёт их из стека, сложит и поместит результат обратно в стек.
Однако, вместо генерации инструкций в текстовом формате, как показано выше, компилятор сгенерирует реальные опкоды для каждой инструкции. Это будет выглядеть следующим образом:
0|0|1|6
0 — это опкод для PUSH, 1 — опкод для ADD, а 6 — для HALT.
Поскольку эта виртуальная машина поддерживает всего шесть инструкций, одного байта достаточно, чтобы закодировать каждую из них. Однако инструкция PUSH требует аргумент, который также нужно включить в байт-код.
Мы можем добавить поддержку аргумента для инструкции PUSH, увеличив размер инструкции PUSH до 2 байт — первый байт будет представлять опкод, а второй байт — значение аргумента. Аргумент размером в 1 байт означает, что мы можем помещать в стек значения только до 255, но для нашей гипотетической простой виртуальной машины это приемлемо.
Такая схема инструкций переменной длины работала бы, но усложнила бы реализацию виртуальной машины, так как ей пришлось бы обрабатывать инструкции разной длины. Мы можем упростить реализацию, если сделаем все инструкции фиксированного размера в 2 байта: первый байт — это опкод, а второй — необязательный аргумент, который может быть равен 0, если аргумент не требуется. Согласно этой спецификации, байт-код для приведённой выше программы будет выглядеть так:
0 1|0 2|1 0|6 0
(Символ "|" используется для визуального выделения границ инструкций).
Эта последовательность байтов называется байт-кодом, и задача виртуальной машины — извлечь инструкции из этой последовательности байтов и выполнить их.
Выполнение байт-кода в виртуальной машине
Теперь давайте рассмотрим, как виртуальная машина извлекает и выполняет байт-код. Мы продолжим использовать пример нашей простой стековой виртуальной машины из предыдущего раздела, которая поддерживает только шесть инструкций.
Следующий код показывает, как такая виртуальная машина может выполнять байт-код.
class VirtualMachine():
def __init__(self, bytecode):
self.stack = []
self.bytecode = bytecode
def execute_bytecode(self):
ip = 0
while ip < len(self.bytecode) - 1:
opcode = self.bytecode[ip]
oparg = self.bytecode[ip + 1]
ip += 2
match opcode:
case PUSH:
self.stack.append(oparg)
case ADD:
lhs = self.stack.pop()
rhs = self.stack.pop()
result = lhs + rhs
self.stack.append(result)
case SUB:
lhs = self.stack.pop()
rhs = self.stack.pop()
result = lhs - rhs
self.stack.append(result)
case MUL:
lhs = self.stack.pop()
rhs = self.stack.pop()
result = lhs * rhs
self.stack.append(result)
case DIV:
lhs = self.stack.pop()
rhs = self.stack.pop()
result = lhs / rhs
self.stack.append(result)
case HALT:
return stack.pop()
Виртуальная машина выполняет цикл по инструкциям байт-кода и, исходя из значения опкода, выполняет соответствующую операцию.
Этот код преднамеренно упрощён, чтобы проиллюстрировать основные принципы выборки, декодирования и выполнения инструкций. Например, эта виртуальная машина может работать только с целочисленными значениями, которые передаются в качестве аргументов инструкций и помещаются в стек. Реальная реализация виртуальной машины содержит гораздо больше деталей, таких как стековые фреймы, указатель стека, указатель инструкций и контекст выполнения, которые здесь опущены. Мы обсудим их позже, когда будем рассматривать детали реализации виртуальной машины CPython.
Реализация виртуальной машины CPython
Теперь, когда мы понимаем, как работает виртуальная машина байт-кода (в теории), мы готовы обсудить особенности реализации виртуальной машины CPython.
Инструкции байт-кода CPython
Мы начнём с рассмотрения инструкций, поддерживаемых виртуальной машиной Python, а затем обсудим, как они кодируются в байт-код.
В CPython опкоды инструкций определены в файле Include/opcode_ids.h. На следующем изображении показаны некоторые из этих инструкций и их соответствующие идентификаторы опкодов.
На момент написания этой статьи (CPython 3.14 в стадии разработки) в CPython определено всего 223 инструкции.
Примечание: Этот файл генерируется автоматический с помощью скрипта, а сами инструкции описаны в файле Python/bytecodes.c. Фактически, большая часть реализации виртуальной машины создаётся с использованием различных скриптов, которые разбирают определения инструкций из этого файла.
Формат представления байт-кода CPython
Аналогично простому примеру, который мы рассмотрели в предыдущем разделе, инструкции байт-кода CPython также занимают 2 байта. Первый байт представляет опкод, а второй байт — значение аргумента для опкода. Если инструкция не требует аргумента, второй байт устанавливается в 0.
Однако использование только 1 байта для аргумента означает, что его значение не может быть больше 255. Чтобы поддерживать более крупные значения аргументов, есть специальная инструкция под названием EXTENDED_ARG. Если компилятор обнаруживает, что значение аргумента не умещается в один байт, он добавляет инструкцию EXTENDED_ARG, чтобы задействовать дополнительные байты для хранения значения аргумента.
Инструкция может быть предварена максимум тремя последовательными инструкциями EXTENDED_ARG, что позволяет увеличить размер аргумента до 4 байтов (1 байт для аргумента самой инструкции и 3 байта для инструкций EXTENDED_ARG).
Понимание байт-кода CPython на примере
Рассмотрим пример функции и посмотрим на сгенерированный для неё байт-код, чтобы лучше понять этот процесс.
>>> def add(a, b):
... return a + b
...
>>> dis.dis(add)
1 RESUME 0
2 LOAD_FAST_LOAD_FAST 1 (a, b)
BINARY_OP 0 (+)
RETURN_VALUE
У нас есть простая функция для сложения двух значений, и мы можем увидеть сгенерированные для неё инструкции байт-кода с помощью модуля dis.
Мы также можем изучить сам байт-код этой функции. Каждый вызываемый (callable) объект в Python имеет поле __code__, которое содержит поле _co_code_adaptive с компилированным байт-кодом для этого объекта. Давайте его исследуем.
>>> add_bytecode = add.__code__._co_code_adaptive
>>> len(add_bytecode)
10
>>> for i in range(0, len(add_bytecode) - 1, 2):
print(f"opcode_value: {add_bytecode[i]}, oparg: add_bytecode[i+1]}")
opcode_value: 149, oparg: 0
opcode_value: 88, oparg: 1
opcode_value: 45, oparg: 0
opcode_value: 0, oparg: 0
opcode_value: 36, oparg: 0
>>>
Мы видим, что байт-код действительно представляет собой последовательность байтов. Мы напечатали отдельные инструкции и их аргументы, перебирая каждую пару байтов в этой последовательности.
Заметьте, что вывод dis.dis()
показал 4 инструкции, и можно ожидать, что длина байт-кода составит 8 байтов. Однако сгенерированный байт-код имеет длину 10 байтов. Это связано с дополнительной инструкцией CACHE (опкод 0), которая генерируется компилятором и не отображается в стандартном выводе dis.dis()
. Инструкция CACHE используется виртуальной машиной для оптимизации выполнения определённых инструкций. CPython профилирует и оптимизирует инструкции для повышения производительности. Механизм специализацим инструкций мы обсудим в будущей статье.
Мы можем пойти ещё дальше и декодировать эти значения опкодов, чтобы убедиться, что они соответствуют инструкциям, отображаемым модулем dis.
>>> import opcode
>>> for i in range(0, len(add_bytecode) - 1, 2):
... opcode_value, oparg = add_bytecode[i], add_bytecode[i + 1]
... print(f"opcode_value: {opcode_value}, opcode_name: {opcode.opname[opcode_value]}, oparg: {oparg}")
...
opcode_value: 149, opcode_name: RESUME, oparg: 0
opcode_value: 88, opcode_name: LOAD_FAST_LOAD_FAST, oparg: 1
opcode_value: 45, opcode_name: BINARY_OP, oparg: 0
opcode_value: 0, opcode_name: CACHE, oparg: 0
opcode_value: 36, opcode_name: RETURN_VALUE, oparg: 0
>>>
Для декодирования инструкций мы использовали модуль opcode, который содержит сопоставление идентификаторов опкодов с их названиями. Мы можем увидеть, что значения опкодов соответствуют именам инструкций. Также можно открыть сгенерированный файл opcode_ids.h и проверить названия опкодов для идентификаторов в указанном байт-коде.
Внутреннее устройство виртуальной машины CPython
Теперь давайте рассмотрим реализацию виртуальной машины CPython. При запуске программы на Python происходят несколько ключевых процессов: инициализация среды выполнения, разбор и компиляция исходного кода, настройка стекового фрейма и т. д.
Цикл выполнения байт-кода сам по себе достаточно сложен, поэтому его полное рассмотрение вынесено в отдельную статью.
В этой статье мы перейдём непосредственно к циклу выполнения байт-кода, который реализован в функции _PyEval_EvalFrameDefault
в файле ceval.c.
На следующем рисунке показана её сигнатура.
Функция принимает стековый фрейм блока кода Python, который будет выполняться на виртуальной машине. Стековый фрейм содержит скомпилированный байт-код и контекст выполнения.
Функция извлекает байт-код из стекового фрейма и начинает его выполнение с помощью цикла выполнения байт-кода. Прежде чем углубиться в реализацию этой функции, важно понять, что такое стековые фреймы, а также ознакомиться с концепцией computed goto, которая используется в CPython для оптимизации цикла выполнения байт-кода.
Примечание о стековых фреймах
Виртуальная машина CPython выполняет байт-код для одного блока кода за раз, таким блоком может быть модуль, класс, функция, скрипт и т.д. Каждый блок содержит множество данных, необходимых интерпретатору для выполнения его кода.
Например, интерпретатору нужен доступ к локальным, глобальным и встроенным объектам в области видимости блока, а также к таким данным, как стек, адрес возврата, указатели стека и инструкций. Эти данные объединяются в структуру, называемую стековым фреймом.
Перед началом выполнения интерпретатор создаёт стековый фрейм для каждого блока кода, который управляет процессом выполнения и хранит контекст этого выполнения. Это позволяет виртуальной машине CPython изолировать выполнение каждого блока кода и управлять ими независимо друг от друга.
На рисунке ниже показано определение структуры стекового фрейма в CPython:
Рассмотрим некоторые ключевые поля:
f_executable
: содержит скомпилированный байт-код для блока.previous
— указатель на фрейм вызывающей функции. Он эффективно реализует стек вызовов. Фреймы функций, выполняющихся в виртуальной машине, связаны друг с другом с помощью этого указателя. Когда выполняемая функция (вызывающая) вызывает другую функцию (вызываемую), фрейм вызывающей функции связывается с фреймом вызываемой в качестве предыдущего фрейма. Это позволяет виртуальной машине знать, куда вернуться после завершения вызываемой функции.globals
,locals
иbuiltins
— словари, хранящие глобальные, локальные и встроенные объекты в области видимости данного блока.instr_ptr
— указатель инструкции, используемый виртуальной машиной для отслеживания следующей инструкции для выполнения. У каждого фрейма есть свой указатель, что позволяет виртуальной машине продолжать выполнение фрейма с того места, где оно было прервано.stacktop
— указатель на вершину стека.localsplus
— хранилище для локальных переменных и аргументов функции, которое также используется как стек для данного блока. Первые x (где x — количество локальных переменных в блоке) элементов массива зарезервированы для локальных переменных, а оставшаяся часть пространства используется как стек.
В качестве примера на следующем рисунке показана цепочка вызовов функций. foo()
вызывает bar()
, а bar()
вызывает baz()
. Справа на рисунке изображена цепочка стековых фреймов, созданных для выполнения этих функций. Сначала создаётся и помещается в интерпретатор стековый фрейм для foo()
. Затем создаётся фрейм для bar()
, и наконец — для baz()
.
Мы также видим, что каждый из этих стековых фреймов связан с другими при помощи указателя previous
. Когда виртуальная машина завершает выполнение текущей функции, она удаляет стековый фрейм этой функции и возвращается к фрейму вызывающей функции для продолжения её выполнения.
Computed Goto vs Switch Case
Перед тем как приступить к рассмотрению кода виртуальной машины CPython, нам необходимо познакомиться с понятием computed goto.
Как мы видели в реализации простой виртуальной машины, цикл выполнения байт-кода обычно реализуется с использованием конструкции switch case. В случае CPython это выглядит примерно так:
Обратите внимание, что на самом деле цикл выполнения байт-кода в CPython реализован с помощью макросов. На рисунке выше показано, как выглядит реализация на основе switch case после расширения макросов.
Мехнизм довольно прост для понимания. Здесь нет явного цикла while или for, но выполняется логика цикла за счёт постоянного перехода к метке dispatch_opcode. Каждый опкод после завершения своего выполнения возвращается к этой метке, чтобы начать обработку следующего опкода, что фактически формирует цикл.
На рисунке выше показан код инструкции LOAD_FAST, чтобы проиллюстрировать это процесс. Инструкция LOAD_FAST загружает значение из списка локальных переменных (locals) в стек. После этого указатель на байт-код увеличивается, чтобы перейти к следующей инструкции, и управление возвращается в начало switch case для обработки нового опкода.
Хотя эта реализация проста, с точки зрения производительности она проблематична. Большинство современных процессоров используют параллелизм на уровне инструкций (ILP), который позволяет выполнять программы как можно быстрее. Для этого они исполняют инструкции не по порядку, чтобы найти больше независимых инструкций. Когда в коде встречается ветвление, например в случае с переключателем switch, процессоры не вссегда знают, какое значение примет условие ветвления, поскольку оно может всё ещё оцениваться в другой инструкции. Вместо того чтобы ждать результата ветвления, процессор использует предсказатель ветвлений, чтобы угадать путь исполнения программы и выполнить инструкции по предсказанному адресу. Если предсказание верно, программа выполняется быстроее, но в случае ошибки производительность может снизиться.
Предсказатели ветвлений обычно хорошо работают, когда ветвление следует определённому шаблону, который можно изучить. Однако в данном случае блок switch содержит более 230 вариантов, и на каждой итерации цикла переход может произойти в любой из этих блоков. Это значительно усложняет задачу предсказателя ветвлений. Чем больше вариантов в блоке switch, тем сложнее предсказателю корректно угадать цель ветвления. Если предсказатель часто ошибается, это снижает общую производительность интерпретатора Python.
Более эффективная альтернативная реализация этого цикла возможна с использованием конструкции computed goto (вычисляемый переход). Computed goto – это расширение языка C, поддерживаемое некоторыми компиляторами, такими как Clang и GCC. Оно позволяет использовать специальный оператор && для получения указателей на метки в коде на C.
В реализации виртуальной машины CPython каждому опкоду назначается уникальная метка, после чего на этапе компиляции создаётся статическая таблица переходов, содержащая адреса этих меток. Во время выполнения байт-кода виртуальная машина обращается к этой таблице по идентификатору опкода и выполняет переход на соответствующий адрес.
Следующая схема иллюстрирует, как виртуальная машина CPython использует computed gotos для реализации цикла выполнения байт-кода:
Такая реализация обеспечивает значительно лучшую производительность по сравнению с использованием оператора switch case, так как предсказатель ветвлений (branch predictor) работает с этой схемой гораздо эффективнее. Точная причина повышения производительности связана с оптимизацией предсказания ветвлений, которую мы рассмотрим в другой статье.
Цикл выполнения байт-кода CPython
Благодаря более высокой производительности computed goto является предпочтительным способом реализации цикла выполнения байт-кода. Однако не все компиляторы поддерживают эту конструкцию. В результате в CPython используется гибкая реализация с помощью препроцессорных макросов, которые могут генерировать код как для switch case, так и для computed goto, в зависимости от возможностей компилятора.
На следующей иллюстрации представлен код из функции _PyEval_EvalFrameDefault
, реализующий цикл выполнения байт-кода. Хотя он не выглядит как классический цикл из-за использования макросов, мы разберём его работу более подробно.
Чтобы понять этот код, мы разделим его на две части. В первой части мы объясним код, который находится перед циклом (пролог), а во второй части рассмотрим макросы, которые создают сам цикл.
Пролог цикла
На иллюстрации выше весь код до вызова DISPATCH() составляет пролог цикла выполнения байт-кода, где инициализируются ключевые переменные и объекты для отслеживания состояния виртуальной машины во время выполнения байт-кода.
Разберёмся подробнее:
Переменные
opcode
иoparg
содержат текущую инструкцию байт-кода и значения её аргументов. Эти переменные будут обновляться при каждой итерации цикла.Далее инициализируется объект
entry_frame
, который устанавливается в качестве базового (самого нижнего) фрейма в стеке. Этот фрейм будет последним, выполняющимся, когда весь код Python завершён. Он содержит инструкции байт-кода, которые завершают выполнение виртуальной машины, выходя из цикла.next_instr
— это указатель на текущую инструкцию, подлежащую выполнению. Он инициализируется указателем на байт-код активного фрейма. Когда виртуальная машина меняет фрейм (например, при вызове функции),next_instr
обновляется, чтобы чтобы указывать на следующую инструкцию в новом фрейме.
После этого начинается тело цикла выполнения байт-кода, которое в значительной степени зависит от использования макросов. Давайте поговорим об этом детальнее.
Макросы цикла
Цикл выполнения байт-кода реализован точно так же, как мы видели в разделе о computed goto и switch case, но с использованием макросов. Это позволяет использовать switch-case, если компилятор не поддерживает computed goto, и автоматическое разворачивание в код с использованием computed goto, если оно поддерживается. Чтобы понять реализацию цикла, необходимо рассмотреть определения используемых макросов.
На иллюстрации ниже показаны определения этих макросов. Вместе они образуют код для выполнения одной итерации цикла. На каждой итерации выполняются следующие шаги:
Сначала необходимо получить следующий опкод, что достигается с помощью макроса NEXTOPARG().
Затем необходимо перейти к реализации опкода для его выполнения. Это делается с помощью макроса DISPATCH_GOTO(). Если computed goto не используется, макрос разворачивается в переход к началу блока switch. Если поддерживается computed goto, макрос разворачивается в обращение к таблице переходов opcode_targets и переход к реализации соответствующего опкода.
Оба этих шага объединены в единый макрос под названием DISPATCH(). Каждый раз, когда в коде встречается вызов DISPATCH(), это означает, что следующий опкод извлекается и выполняется.
Обратите внимание на макрос TARGET(op). Он принимает опкод в качестве аргумента и разворачивается либо в TARGET_op:, либо в case op: TARGET_op, в зависимости от того, используются ли computed goto или нет. Этот макрос используется для начала обработки каждой инструкции (опкода) байт-кода.
Тело цикла
Теперь мы готовы обсудить тело цикла. На следующей иллюстрации представлен код.
Давайте разберёмся, что здесь происходит. Я объясню каждую из пронумерованных частей на рисунке по порядку.
Вызов DISPATCH() извлекает первый опкод и переходит к его реализации. Если используется computed goto, переход осуществляется напрямую к метке с реализацией опкода с помощью таблицы переходов. Если computed goto не поддерживается, используется конструкция
switch-case
.Когда computed goto не используется, условная компиляция создает начало блока switch, присваивая ему метку "dispatch_opcode".
Затем подключается файл generated_cases.c.h, где реализованы все опкоды. На иллюстрации показана реализация инструкции LOAD_FAST в качестве примера. Вы можете видеть, что реализация начинается с макроса TARGET, который мы обсуждали ранее.
Если computed goto не используется, макрос TARGET(LOAD_FAST) разворачивается в "case LOAD_FAST: TARGET_LOAD_FAST:". Это формирует начало блока case. Я показал, как выглядит сгенерированный switch case справа. Обратите внимание, что в конце блока case происходит переход к началу блока switch с помощью оператора goto. Это создаёт цикл, который продолжается до тех пор, пока есть инструкции для выполнения.
Если используется computed goto, макрос TARGET(LOAD_FAST) разворачивается в метку TARGET_LOAD_FAST, и реализация опкода будет находиться под этой меткой. На иллюстрации справа показан сгенерированный код. Обратите внимание, что в конце выполнения инструкции происходит извлечение следующего опкода и переход к его реализации с использованием таблицы переходов. Таким образом формируется цикл при реализации на основе computed goto.
Следующая иллюстрация показывает сгенерированный код для цикла на основе switch case и computed goto рядом друг с другом.
Выполнение программы на Python в виртуальной машине CPython
До этого мы рассмотрели реализацию цикла выполнения байт-кода в виртуальной машине CPython. Давайте подведём итоги, рассмотрев пример программы на Python и разберёмся, как она выполняется в виртуальной машине. Это поможет закрепить всё, о чём говорилось в статье.
В качестве примера мы будем использовать следующий код, чтобы пошагово проследить выполнение в виртуальной машине:
>>> def add(a, b):
return a + b
>>> add(2, 3)
>>> dis.dis("add(2,3)")
0 RESUME 0
1 LOAD_NAME 0 (add)
PUSH_NULL
LOAD_CONST 0 (2)
LOAD_CONST 1 (3)
CALL 2
RETURN_VALUE
Программа состоит из одной строки кода на Python, вызывающей функцию add с аргументами 2 и 3. Мы также можем увидеть скомпилированные инструкции байт-кода для этой однострочной программы, которые будут выполняться на виртуальной машине.
Для выполнения этого скомпилированного кода в интерпретаторе он будет заключён в стековый фрейм и передан в функцию _PyEval_EvalFrameDefault
для исполнения.
Напомним, что в _PyEval_EvalFrameDefault
, перед началом цикла выполнения байт-кода, создаётся объект entry_frame
, который становится самым нижним фреймом в стеке интерпретатора. На следующей иллюстрации показано, как это выглядит визуально:
Изначально указатель инструкций виртуальной машины будет указывать на первую инструкцию LOAD_NAME. Затем интерпретатор войдёт в цикл выполнения байт-кода и будет выполнять каждую инструкцию по одной. Инструкция CALL — это тот момент, где начинается самое интересное.
Инструкция CALL выполняет вызов функции, создавая новый стековый фрейм для вызываемой функции (callee) и делая его активным фреймом. Затем обновляется указатель инструкций, чтобы указывать на первую инструкцию вызываемой функции, и выполнение возвращается к началу цикла байт-кода, чтобы начать выполнение кода вызываемой функции. На следующей схеме показано, как всё это будет выглядеть после завершения инструкции CALL.
Обратите внимание, что каждый стековый фрейм имеет собственные значения указателя инструкций и указателя стека. В этот момент указатель инструкций фрейма вызывающей функции указывает на инструкцию RETURN_VALUE, к которой виртуальная машина вернётся после завершения выполнения функции add.
Далее инструкции функции add будут выполняться последовательно. Когда выполнение дойдёт до инструкции RETURN_VALUE, её стековый фрейм будет извлечен из стека, и управление вернётся к вызывающей функции. На следующей иллюстрации показан код инструкции RETURN_VALUE.
Как видно, эта инструкция берёт возвращаемое значение из стека вызываемой функции, затем извлекает стековый фрейм, чтобы стековый фрейм вызывающей функции снова стал активным. Наконец, обновляются указатели стека и инструкций, чтобы они указывали на соответствующие значения вызывающей функции.
На следующей иллюстрации показано состояние стековых фреймов в интерпретаторе после завершения инструкции RETURN_VALUE.
Управление возвращается к фрейму вызывающей функции для выполнения её следующей инструкции — в данном случае это инструкция RETURN_VALUE. Происходит аналогичный набор действий: этот фрейм будет извлечён, и entry_frame станет активным фреймом.
entry_frame содержит только две инструкции. Инструкция NOP ничего не делает, поэтому указатель инструкций переходит к инструкции INTERPRETER_EXIT, которая завершиает цикл выполнения байт-кода и работу интерпретатора. Ниже показана её реализация.
Обратите внимание, что, в отличие от других инструкций, которые заканчиваются оператором goto для продолжения цикла, INTERPRETER_EXIT завершает работу оператором return
. Это заставляет виртуальную машину завершить цикл выполнения байт-кода и вернуться из функции _PyEval_EvalFrameDefault
. Это также завершает выполнение программы на Python, и результат выполнения блока кода передаётся обратно, откуда была вызвана виртуальная машина.
Заключение
Это подводит нас к логическому завершению данной статьи. Мы начали с общего обсуждения виртуальных машин байт-кода, рассмотрели все нюансы выполнения байт-кода в CPython и завершили пошаговым разбором небольшой программы на Python, чтобы увидеть, как всё это объединяется. Далее представлен краткий обзор всего, что мы обсудили в этой статье.
Байт-код и виртуальные машины: Мы начали с обсуждения того, как компилируемые языки используют виртуальные машины для выполнения байт-кода, что позволяет обеспечить переносимость кода между разными аппаратными архитектурами.
Типы виртуальных машин: Мы рассмотрели два типа виртуальных машин — регистровые и стековые. Python использует стековую виртуальную машину, так же как Java и JavaScript.
Формат байт-кода: Мы изучили, как кодируются инструкции байт-кода, на примере гипотетической стековой виртуальной машины с несколькими базовыми инструкциями.
Цикл выполнения: Мы подробно рассмотрели, как виртуальная машина обрабатывает и исполняет инструкции байт-кода.
Особенности CPython: Мы погрузились в детали работы виртуальной машины CPython, обсудили её инструкции байт-кода и их организацию.
Стековые фреймы: Мы рассмотрели стековые фреймы и их важную роль в отслеживании состояния выполнения. Каждый вызов функции создаёт новый фрейм, что позволяет виртуальной машине управлять выполнением программы.
Computed Goto: Мы объяснили, как и почему CPython использует computed goto для повышения производительности по сравнению с традиционным switch-case в цикле выполнения байт-кода.
Как это работает вместе: Мы проанализировали выполнение программы на Python на виртуальной машине CPython, шаг за шагом проследив процесс выполнения.
Больше про языки программирования эксперты OTUS рассказывают в рамках практических онлайн-курсов. С полным каталогом курсов можно ознакомиться по ссылке.
А в ноябре в рамках курса "Python Developer. Professional" пройдут открытые уроки, на которые приглашаем всех желающих:
7 ноября: gRPC в Python. Узнать подробнее
13 ноября: Делаем по красоте: паттерны проектирования в Python-приложениях. Узнать подробнее
21 ноября: Основы FastAPI. Узнать подробнее
icya
Один перевод хорошо, два - уже лишнее
jbourne
И опечатки паровозом заехали:
https://habr.com/ru/companies/beget/articles/850578/comments/#comment_27419932
unreal_undead2
Так подпись к картинке про computed goto перевели полностью ) Но всё равно чтобы понять смысл надо читать жутко мелкий шрифт на самой картинке.