суббота, апреля 25, 2015

CPython для самых маленьких: введение

Этой статьей мне хотелось бы открыть цикл, посвященный внутреннему устройству интерпретатора языка Python. Большую часть времени мы будем говорить о коде CPython версии не ниже чем 3.3, который собран и запущен на Linux based OS. Цель этого цикла – показать, что питоновские внутренности - это совсем не страшно (я про интерпретатор), в них может разобраться любой желающий, при этом профит, который вы получаете, выражается если даже не в повышении зарплаты и интереса к вам со стороны женщин, то по меньшей мере в собственном удовлетворении и возможности повыделываться перед коллегами в курилке.


Подготовительный этап

Ок, предположим, что вы тем или иным образом завладели кодом CPython. Если вы собираетесь экспериментировать с этими исходниками, то вот вам небольшой совет – если вы скачали tarball, рекомендую инициализировать внутри git репозиторий, добавить в .gitignore те бинари, которые произведет компилятор в процессе сборки и закоммитить это ванильное состояние, на которое, если что, можно сделать reset. Стандартный скрипт configure нужно будет запустить с ключем --with-pydebug, это поможет нам в дальнейшем, и, напоследок, полезно прогнать тесты. В результате должен получиться полностью работоспособный интерпретатор:
magniff@/home/magniff/Downloads/Python-3.4.2$ ./python 
Python 3.4.2 (default, Apr 10 2015, 20:16:23) 
[GCC 4.9.2 20150212 (Red Hat 4.9.2-6)] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>>

Готово ко вскрытию

Итак, с чего бы нам начать свой путь от полной несознанки до почти коре-девелопера? Взгляните на сорцы, которые вы только что скачали - дюжина директорий с неведомым содержимым. Совершенно не очевидно, что из этого является ядром системы и то, как отдельные компоненты связаны друг с другом. Думаю, что в таких условиях наиболее естественной была бы попытка пройти вместе с интерпретатором весь путь от начальной инициализации до интерпретации питоновской программы, что дало бы минимальное представление о сути проблемы.
Давайте использовать GDB - GNU Debugger. Не пугайтесь, нам понадобится лишь самый минимум его возможностей, так что ни один изящный питонист не пострадает, помимо этого мы частенько будем подглядывать в исходники - вы можете делать это прямо из отладчика.
Запускать мы будем вот такой питоновский код:
import os, signal
os.kill(os.getpid(), signal.SIGTRAP)
его мы заботливо положим в файл probe0.py где нибудь в файловой системе.
Что это и почему оно такое? Перед тем как начать выполнять какой либо скрипт интерпретатор долго готовится, в частности, грузит всякое-разное из стандартной библиотеки (помните непустой sys.modules в только что запущеной REPL сессии, например?), поэтому чтобы поймать в отладчике выполнение именно нашего скрипта мы пошлем себе SIGTRAP, который и будет перехвачен GDBой - вот такой нехитрый трюк.

gdb --args path/to/our/python path/to/probe0.py
GNU gdb (GDB) Fedora 7.8.2-38.fc21
Copyright (C) 2014 Free Software Foundation, Inc.
# blah blah
(gdb) run
Program received signal SIGTRAP, Trace/breakpoint trap.
0x00007ffff7127c07 in kill () from /lib64/libc.so.6
(gdb)

Мы имеем интерпретатор, остановленный на строке с лямбдой. Чтобы посмотреть нативный callstack, достаточно вызвать команду backtrace (или ее короткую версию bt).

#0-6 are skipped...
#7  PyEval_EvalFrameEx (f=0xa0c578, ...) -------- at Python/ceval.c:2835
#8  PyEval_EvalCodeEx (_co=0x7ffff70c7340, ...) - at Python/ceval.c:3586
#9  PyEval_EvalCode (co=0x7ffff70c7340, ...) ---- at Python/ceval.c:773
#10 run_mod (mod=0xa46768, ...) ----------------- at Python/pythonrun.c:2180
#11 PyRun_FileExFlags (fp=0x9ed880, ...) -------- at Python/pythonrun.c:2133
#12 PyRun_SimpleFileExFlags (fp=0x9ed880, ...) -- at Python/pythonrun.c:1606
#13 PyRun_AnyFileExFlags (fp=0x9ed880, ...) ----- at Python/pythonrun.c:1292
#14 run_file (fp=0x9ed880, ...) ----------------- at Modules/main.c:319
#15 Py_Main (argc=2, ...) ----------------------- at Modules/main.c:751
#16 main (argc=2, ...) -------------------------- at Modules/python.c:69

Первый столбик - это номера activation frame`ов сишных функций интерпретатора, лежат они в стековой памяти процесса в виде непрерывной простыни. Очевидно, что в самом начале вызывается функция main - шестнадцатый фрейм. Она выполняет самые первичные проверки - хватает ли памяти на запуск, удается ли декодировать аргументы командной строки и пр. Дальше, убедившись, что всё в порядке, управление передается функции Py_Main. Пока мы не сможем оценить её роли, но именно там происходит вызов Py_Initialize, который создает новый инстанс интерпретатора и выполняет его первоначальную настройку. Об этом мы обязательно поговорим как нибудь в другой раз, сейчас же нас интересуют куда более общие вопросы. Далее, вплоть до десятого фрейма включительно, система пытается собрать инстанс PyCodeObject. Тут мы тоже не будем пока вдаваться в подробности, но думаю ясно, что в этом процессе участвует компилятор, на вход которому подается длинная строка из .py файла, командной строки с ключем -с или, например, пользовательского ввода из REPLа. Так или иначе должен получиться "код", способный управлять интерпретатором точно так же, как машинные инструкции управляют аппаратным процессором. Функция PyEval_EvalCode есть тончайшая обертка вокруг PyEval_EvalCodeEx (восьмой фрейм), которая как раз и решает, что делать с тем кодом, который собрался из нашего питоновского исходника.


Что мы поняли на текущий момент

Мы с вами достигли первой важной точки на пути понимания CPython. Во-первых, мы поняли, что в CPython существует явным образом выделенная виртуальная машина, представленная функцией PyEval_EvalCodeEx (как мы увидим далее, это скорее PyEval_EvalFrameEx, но пока сойдет) и, во-вторых, эта машина на вход принимает специально собранный "код", представленный на низком уровне типом PyCodeObject.

Виртуальная машина

В сердце CPython лежит довольно простая стековая машина, управляемая байткодом, являющимся частью code objecta. Пойдем по порядку - сначала вызывается функция:
PyObject *
PyEval_EvalCodeEx(PyObject *_co, PyObject *globals, PyObject *locals, ...)
{
    PyCodeObject* co = (PyCodeObject*)_co;
    PyFrameObject *f;
    f = PyFrame_New(tstate, co, globals, locals);
    /* usualy it evals frame */
    return PyEval_EvalFrameEx(f,0);
       
}
Фактически, все что она делает - это создает новый питоновский фрейм f (о том, что это такое мы поговорим чуть ниже) и связывает с ним объект кода _co, который нужно будет исполнить. Непосредственно в этот момент ваша программа еще не исполняется - дальше "обычно" управление передается функции PyEval_EvalFrameEx, умеющей запускать фреймы (да, в питоне фреймы запускаются - "евалятся"). Я говорю "обычно", потому что в таком примере:
def some_foo():
    yield
some_foo()
компилятор увидев в теле функции some_foo слово yield, пометит код, связанный с этой функцией, специальным флагом. Он будет опознан внутри PyEval_EvalCodeEx, в результате чего some_foo() не исполнит тело функции (не запустит фрейм), вместо этого создастся новый generator object. Так вот, дальше "обычно" происходит следующее:
PyObject *
PyEval_EvalFrameEx(PyFrameObject *f, int throwflag)
{
    co = f->f_code;
    for (;;) {
        switch (opcode) {
            TARGET(LOAD_FAST)  {\* implementation of LOAD_FAST  *\}
            TARGET(LOAD_CONST) {\* implementation of LOAD_CONST *\}
            TARGET(STORE_FAST) {\* implementation of STORE_FAST *\}
            TARGET(POP_TOP)    {\* implementation of POP_TOP    *\}
            ...
    }
}
На вход подается указатель на питоновский фрейм. Фрейм - это внутреннее представление исполняющегося кода, такой контейнер с кодом и метаданными внутри. Именно фрейм хранит информацию о том, какой опкод сейчас исполняется и куда передать управление, когда код отработает. Последнее достигается хранением в атрибуте f_back ссылки на родительский фрейм, так что множество фреймов образует односвязный список. На один и тот же объект кода могу ссылаться множество фреймов, так что объект кода - это что-то вроде шаблона. В свете вышесказанного можно заключить, что питоновский фрейм очень похож на нативный фрейм, который создала бы программа на Си, внутри которой происходит вызов подпрограммы. Это почти правда, важно понимать, что питоновские фреймы - это обычные объекты, у них есть свой конструктор, память под них выделяется на куче, а не из стековой памяти, как для нативных фреймов, а также они подвержены сборке мусора. Подобно другим объектам, фреймы исполнения имеют представление внутри питоновского кода, что вкупе с другими возможностями делает питон языком с очень богатой интроспекцией.
Вернемся к PyEval_EvalFrameEx, на самом деле эта функция очень длинная, так что я немного укоротил ее код. Посмотрите насколько он простой (макросы вроде TARGET не должны вас смущать, они тоже простые) - из фрейма забирается ссылка на объект кода, а из него байтовая строка, изображающая опкоды. Виртуальная машина перебирая эти опкоды один за другим выполняет примитивные атомарные операции - питонячая программа выполняется. Да, вот так просто, никакой магии.


Объект кода

Инстанс питоновского кода возникает всякий раз, когда компилятор видит независимую единицу исполнения - модуль, функцию (метод) или класс. Помимо этого код можно собрать самостоятельно из произвольного набора выражений с помощью встроенной функции compile, например так
>>> code = compile('print(a + foo.bar())', '<string>', 'exec')
Обратите внимание, что переменные нигде не определены, при этом компилятор считает этот код совершенно валидным.
Мы можем запустить его, предоставив контекст:

>>> class Mock:
...     pass
... 
... Mock.bar = lambda: 'world!'
... eval(code, {'a':'Hello', 'foo': Mock})
Helloworld!
Вы, должно быть, знаете, что в стандартной библиотеке есть модуль dis, позволяющий дизассемблировать объекты питоновского кода:
>>> dis.disassemble(code)
  1           0 LOAD_NAME                0 (print)
              3 LOAD_NAME                1 (a)
              6 LOAD_NAME                2 (foo)
              9 LOAD_ATTR                3 (bar)
             12 CALL_FUNCTION            0 (0 positional, 0 keyword pair)
             15 BINARY_ADD
             16 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             19 POP_TOP
             20 LOAD_CONST               0 (None)
             23 RETURN_VALUE
Что мы видим? На стек нашей стековой машины последовательно грузятся сущности под именами print, a, foo и так далее. Откуда происходит эта загрузка? Точно не из байткода, потому что на момент компиляции корректность таких ссылок никто не гарантировал. Значит существует контекст, объект кода знает о его интерфейсе (он грузит из него сущности, зная их имена), но не знает о реализации, т.е. что там лежит. Это полезно в случае рекурсии, когда один и тот же код переиспользуется многократно внутри разных контекстов.
Детали реализации PyCodeObject вы можете посмотреть в исходниках (Include/code.h). О коде нам важно уяснить вот что:
1. Он включает в себя байтовую строку - байткод, который непосредственно рулит виртуальной машиной.
2. Этот байткод запускается внутри определенного контекста, для обращения к которому ему так же нужен интерфейс.
3. Байткод и интерфейс собраны в один объект - объект кода.
4. Контекст является внешней по отношению к коду сущностью.
5. Фрейм исполнения содержит в себе ссылку на объект кода и на контекст, когда исполняется ваш питоновский скрипт, на низком уровне запущена PyEval_EvalFrameEx.


Думаю на сегодня хватит

Разумеется, непосредственную работу выполняют внутренности объектов (даже, точнее, их классов), так что когда мы пишем:
1 + 'hello'
то именно класс int должен решить, может ли он обработать такое суммирование или нет, в то время как функция PyEval_EvalFrameEx лишь инициирует это суммирование. Поэтому, познакомившись сегодня с фронтендом, мы узнали одновременно и многое и совсем ничего, но не отчаивайтесь, это всего лишь введение, со временем мы обозреем все важные вопросы.
Спасибо за ваше время, надеюсь, что эта тема была интересной.