Python на стероидах: бинарные native-расширения

Детальный разбор процесса разработки бинарного модуля для Python на очень простом примере.
22 сентября 20173f999bca2e09533f466a56a541996a22772ed1b5Андрей Вуколов136316

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

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

Высокие требования к производительности конкретной функции или программы
Python — интерпретируемый язык. Его среда выполнения представляет собой отдельную программу (интерпретатор), которая транслирует исходный текст непосредственно в процессе запуска. Такой подход приводит не только к необходимости иметь на целевой системе полную реализацию Python (которой может и не быть), но и подгружать для каждого запуска каждой Python-программы стандартную библиотеку и необходимые модули. В силу философии Python, предписывающей разгружать синтаксис и выносить в отдельные модули все типовые задачи, объем подгрузки может оказаться столь большим, что обесценит выигрыш от оптимизации ваших собственных алгоритмов и кода.

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

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

Продвинутая рекурсия и просчет рекуррентных уравнений
Python не любит рекурсию и ограничивает ее уровни вложенности по причине ограниченной емкости стека выполнения.

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

У таких модулей есть огромный плюс: скорость их выполнения будет максимально достижимой для конкретной машины, ОС и компилятора. Еще один плюс — закрытость исходного текста: распространяемая программа на Python доступна для просмотра всем желающим, тогда как в бинарном модуле сравнительно легко «спрятать» собственную реализацию, например, патентованного алгоритма.

Есть у native-расширений и существенный недостаток: зависимость от конкретной платформы и ОС, а иногда даже от конкретной машины. Кроме того, многих программистов испугает необходимость сталкиваться с другими языками программирования и использовать запутанный внешний API Python.

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

Стратегия расширения и технические вопросы

Собственно, стратегия расширения Python бинарными модулями состоит в выборе способа экспорта имен: экспортировать функции или классы. Сам я являюсь горячим сторонником экспорта функций, по следующим причинам:

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

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

В нашем простейшем примере класса-обертки не будет, так как программа будет напрямую вызывать импортируемую функцию. Главный технический вопрос написания native-расширения касается типизации Python, так вся она скрыта внутри интерпретатора и напрямую обратиться в адресное пространство скомпилированной библиотеки невозможно. Python Extension API предписывает использовать для связи с интерпретатором специальные структуры и имена, определенные в исполняемом коде и предоставляемые файлом python.h. Компиляция полученного native-кода должна проводиться с подключением стандартных бинарных библиотек Python для конкретной платформы.

В примере я рассмотрю компиляцию проекта в Linux. Задача: ускорить символьное преобразование числа между системами счисления, описываемыми стандартным латинским алфавитом и арабскими цифрами. Это тривиальная задача, но похожие задачи встречаются в финансовых вычислениях, криптографии и высокопроизводительных приложениях.

Бинарный модуль будет называться steroid. Он написан на C++ и экспортирует две функции. Одна из них, steroid_system (), напрямую заимствована из примеров в документации Python и является «проводником» для вызова произвольных команд системной оболочки. Вторая, steroid_convert () — решает поставленную задачу.

Тела обеих функций в виде чистого C++ кода без «обвеса» от Python имеют следующий вид:

int steroid_system(char * command) {
  int sts;
  sts = system(command);
  return sts;
}
char * steroid_convert(const char * input, unsigned short old_base, unsigned short new_base) {
  if ((old_base <= 1) || (old_base > 36)) return NULL;
  if ((new_base <= 1) || (new_base > 36)) return NULL;
  char * alphabet = "0123456789ABCDEFGHIJKLMNOPQESTUVWXYZ";
  char buffer[255] = {
    0
  };
  char * result;
  unsigned long long i, k, dec = 0, pos = 1;
  for (i = 0; input[i]; i++);
  for (; i; i--) {
    for (k = 0; alphabet[k] != input[i - 1] && k < 36; k++);
    dec += k * pos;
    pos *= old_base;
  }
  k = 0;
  while (dec) {
    i = dec % new_base;
    buffer[k++] = alphabet[i];
    dec /= new_base;
  }
  result = new char[k + 1];
  for (i = 0; k; i++) result[i] = buffer[--k];
  result[i] = 0;
  return result;
}

В тексте наших функций строгая типизация C++ видна во всей красе: есть подразделение чисел по размеру хранения, разделение символьных и строковых типов, статическое хранение данных и адресов в стеке. Python, где все объекты хранятся в динамической памяти и господствует утиная типизация, с точки зрения C++ определяет свои типы как сложные связные структуры. Таковы, например, всем известные кортежи и списки.

Эти структуры в C++ должны представляться классами, имеющими поля-указатели на данные элементарных типов, а в случае поддержки связности — еще и инкапсулированные функции, реализующие управление памятью. В Python же все по-другому: выделением памяти занимается интерпретатор, он же запускает процедуру сборки мусора — автоматического освобождения занятой памяти.

С точки зрения программиста такой подход приводит к нескольким важным следствиям:

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

Нужно дополнительно учитывать сборку мусора Python
Любая передача данных Python в пространство имен функции C++ приводит к созданию дополнительных внешних ссылок на эти данные за пределами адресного пространства интерпретатора. Сборщик мусора в такой ситуации может сработать некорректно, так как будет не в состоянии учесть возможную «занятость» ссылки на данные, изменяемые из C++. Таким образом, требуется специально оформлять любой возврат значения (даже void) по имени некоторой C+±функции, чтобы адрес возвращенных данных попал в сферу действия сборщика мусора Python.

Нужно явно определить аргументы экспортируемой функции
Поскольку адреса всех данных, определенных в Python-программе, обрабатываются интерпретатором, к адресному пространству которого никакая внешняя программа не имеет доступа, требуется явным образом определить количество и Python-тип аргументов функции при ее экспорте. Иными словами, нужно поместить в доступную интерпретатору область адресного пространства модуля native-расширения информацию о типах аргументов, «ожидаемых» при вызове каждой функции. Если этого не сделать, вызов из Python будет невозможен.

Подготовим наши функции к вызову из Python-программы. Заголовочный файл python.h предоставляет для этого все необходимое. Подключать заголовочные файлы библиотеки Python нужно до подключения всех остальных, так как они содержат команды настройки препроцессора C++ для связывания.

Изменим прототипы функций, в соответствии с документацией Python API, таким образом, чтобы место имен элементарных типов C++ заняли вариантные типы PyObject. Все функции нужно переобъявить в качестве статических, чтобы их относительный адрес не мог изменяться после загрузки бинарного модуля:

static PyObject * steroid_system (PyObject * self, PyObject * args);
static PyObject * steroid_convert (PyObject * self, PyObject * args);

Теперь функции будут вызваны как в Python: аргумент self передает указатель на объект Python, инициировавший вызов, а кортеж с фактическими аргументами, переданными из интерпретатора будет преобразован в специального вида связный список C++, адрес первого элемента которого предоставляет args.

Однако, и здесь не все так просто. Во-первых, каждому объекту C++, описывающему тип из Python, требуются процедуры создания и удаления из динамической памяти (помним, что все объекты Python динамические). Во-вторых, созданный объект должен каким-то неведомым образом «из коробки» поддерживать методику подсчета ссылок на свои поля данных, предоставляемые через доступные извне указатели с помощью вызова публичных функций-запросов (механизм свойств).

Очевидно, что реализовать такой функционал можно, как минимум, двумя способами. Самый очевидный из них — ООП-подход, заключающийся в создании сложной иерархии объектов, наследующих от общего предка все необходимые функции и приводящие к элементарным типам C++ все данные, передаваемые из Python. Таким способом разрабатывается большинство интерфейсных библиотек для C++ (самыми известными из них являются Borland VCL и Qt), он хорошо документирован и детально разработан. Однако, отсутствие в C++ «утиной» типизации вынуждает, помимо самой иерархии объектов, реализовывать еще и дополнительные функции, задачей которых станет определение способа приведения бинарного представления данных Python к элементарным типам C++.

Очевидно, что задачу выбора самих таких функций(или их групп) уже должен решать программист, что приводит к необходимости создания интерфейса и приема команд. Таким образом, ООП-подход выглядит слишком трудоемким в реализации как с точки зрения Python, так и с точки зрения C++. Вместо этого разработчики Python Extension API применили другой подход, частично базирующийся на возможностях функционального стиля программирования и перекладывающий на разработчика native-расширения всю ответственность за приведение типов.

Рассмотрим часть определения вариантного типа PyObject, которое располагается в заголовочном файле python/object.h (на него, в свою очередь, ссылается Python. h):

typedef struct _object {
  _PyObject_HEAD_EXTRA Py_ssize_t ob_refcnt;
  struct _typeobject * ob_type;
}
PyObject; /*…*/
struct _typeobject {
  PyObject_VAR_HEAD
  const char * tp_name;
  Py_ssize_t tp_basicsize, tp_itemsize; //… struct _typeobject *tp_prev; struct _typeobject *tp_next; /*…*/ typedef PyObject * (*unaryfunc)(PyObject *); typedef PyObject * (*binaryfunc)(PyObject *, PyObject *); typedef PyObject * (*ternaryfunc)(PyObject *, PyObject *, PyObject *); typedef PyObject *(*ssizeargfunc)(PyObject *, Py_ssize_t); typedef PyObject *(*ssizessizeargfunc)(PyObject *, Py_ssize_t, Py_ssize_t);

Здесь используется интересный трюк: многократное определение типа данных как указателя на функцию, принимающую заранее определенный набор аргументов, имеющих как раз сам определяемый тип. Документация Python называет этот прием «ручной реализацией наследования»: каждый из типов объектов Python оказывается с точки зрения C++ представимым в виде базовой структуры типа PyObject, хранящей:

  • Бинарный заголовок объекта;
  • Текущее значение ob_refcnt счетчика внешних ссылок на данные, хранимые в полях объекта;
  • Ссылку на структуру, хранящую информацию о типе объекта в Python. Она, в свою очередь, хранит: o C-строку tp_name с человеко-читаемым именем Python-типа;
  • Данные о размере объекта в памяти;
  • Указатели на следующий и предыдущий созданный в текущем сеансе объект Python.

При этом создание того или иного объекта Python в C++ приводит к однократному вызову безымянной функции(аналога конструктора), подготавливающей необходимые данные и создающей экземпляр объекта на базе информации из _typeobject, а затем связывающей его со всеми остальными Python-объектами в двухсвязный список через поля tp_prev и tp_next. Кроме того, вызов функции при создании объекта снимает любые проблемы, касающиеся работы сборщика мусора: достаточно определить указатель или вернуть значение по имени экспортируемой функции, чтобы автоматически уменьшить или увеличить счетчик ссылок. Можно, в сложных случаях, делать это и вручную, с помощью макроблоков INCREF и DECREF, но примеры и основания для такого вмешательства лучше посмотреть в документации Python.

Теперь вернемся к нашим функциям. Вызов каждой из них приводит к созданию целого ряда объектов С++, являющихся репрезентативами объектов Python. Последняя нерешенная проблема перед получением фактических значений из кортежа args заключается в интерфейсе, позволяющем зафиксировать для конкретной функции набор «ожидаемых типов» данных, сохраненных в Python-аргументах. Python Extension API предоставляет для этого группу функций Parse…, которые принимают в качестве одного из аргументов строку формата, содержащую в каждом символе информацию о типе очередного переданного Python-аргумента. Например, для нашей функции steroid_convert (), принимающей C-строку и два беззнаковых целых числа, строка формата будет иметь вид «sII».

Подготовим функции примера для корректного приема Python-аргументов. Для этого вынесем входные данные из аргументов в локальные переменные, а затем вызовем функцию ParseTuple:

static PyObject * steroid_system(PyObject * self, PyObject * args) {
  const char * command;
  int sts;
  if (!PyArg_ParseTuple(args, "s", & command)) return NULL;
  sts = system(command);
  return PyLong_FromLong(sts);
}

Функция steroid_system() готова, к ней более мы возвращаться уже не будем.

static PyObject * steroid_convert(PyObject * self, PyObject * args) { /*Выносим входные данные из аргументов*/
    char * input;
    unsigned int old_base, new_base; /*…*/ /*Разбираем аргументы*/
    if (!PyArg_ParseTuple(args, "sII", & input, & old_base, & new_base)) return NULL; /*…*/

После выполнения разобранного фрагмента кода в переменных input, old_base и new_base, ссылки на которые мы передали на вход ParseTuple, окажутся аргументы из Python, уже заботливо приведенные к элементарным типам С++.

Разберемся теперь с возвратом значений. Поскольку создание или уничтожение каждого из Python-объектов влечет автоматическое обращение к счетчику внешних ссылок, возврат C-строки, как это было ранее, наверняка приведет к некорректной работе сборщика мусора, который не сможет освободить память, занятую строкой с именем result. Можно в данном случае использовать напрямую и INCREF/DECREF, но придется столкнуться с отсутствием автоматической очистки памяти, занимаемой C++ массивами и опасностью утечки с нарушением сегментации (крахом программы при вызове функции расширения).

К счастью, описанные выше особенности управления памятью в Python Extension API предоставляют нам очень простой путь возврата строкового значения из функции. Для этого достаточно создать на основе хранимых в result символов Python-объект, который сохранит все сделанные на него ссылки, и вернуть указатель на него. Тогда сборщик мусора корректно очистит память, занятую вновь созданным объектом, а массив-основа останется в локальном адресном пространстве функции, и его можно будет корректно удалить стандартными средствами C++. Процедура возврата значения из steroid_convert () теперь выглядит так:

result = new char[k + 1]; //Создаем строковый буфер в стиле C 
for (i = 0; k; i++) result[i] = buffer[--k];
result[i] = 0;
/*Формируем объект Python строкового типа, сообщив интерпретатору полную длину C-строки*/
PyObject * rstr = PyUnicode_DecodeASCII(result, i, NULL);
delete[] result; //Корректно очищаем старый массив в стиле C++ 
return rstr; //Возвращаем указатель на объект Python в адресное пространство интерпретатора.

Обвязка

Итак, функции примера полностью описаны. Теперь нужно заложить в адресное пространство бинарного модуля нашего native-расширения сведения о них таким образом, чтобы сделать их доступными интерпретатору.

Python Extension API предписывает определить для этого ряд статических структур со специально сформированными именами. В первой из них описываются сами функции вместе с набором флагов, описывающих правила, которым будет следовать интерпретатор при вызове:

static PyMethodDef SteroidMethods[] = //Массив структур PyMethodDef с постоянным адресом 
  {
    {
      "system",
      steroid_system,
      METH_VARARGS,
      "Executes a shell command."
    },
    {
      "convert",
      steroid_convert,
      METH_VARARGS,
      "Converts a number between notation systems defined with decimal numbers as bases"
    },
    {
      NULL,
      NULL,
      0,
      NULL
    }
  };

Созданный массив структур работает по принципу C-строки: его чтение интерпретатором прекращается, когда находится структура, все элементы которой равны нулю. Сам тип PyMethodDef определяется в файле methodobject. h следующим образом:

struct PyMethodDef {
  const char * ml_name;
  PyCFunction ml_meth;
  int ml_flags;
  const char * ml_doc;
};

Поле ml_name хранит строку с внешним именем функции, которое интерпретатор предоставит Python-программе. Второе поле, ml_meth является указателем на саму функцию, определенным по тому же принципу, что и тип PyObject. ml_doc хранит строку с подробным описанием функции, которое интерпретатор записывает в заголовок модуля. Особый интерес представляет поле ml_flags, в которое записываются флаги вызова:

  • METH_VARARGS является флагом по умолчанию. Он задает функцию с набором аргументов, представленным в виде кортежа и приведением их типов, заданным строкой формата. Комбинируется с METH_KEYWORDS операцией поразрядного ИЛИ.
  • METH_KEYWORDS позволяет принимать от интерпретатора именованные аргументы в стиле Python. При разборе требует вместо ParseTuple вызова служебной функции ParseTupleAndKeywords, в список аргументов которой передается массив, содержащий имена параметров из Python.
  • METH_NOARGS задает функцию без пользовательских аргументов, которой передается только self.
  • METH0 применяется для функций с единственным жестко заданным аргументом объектного типа, к которому невозможно применить строку формата. Позволяет не вызывать Parse…, а читать данные прямо из полей переданного аргумента PyObject.
  • METH_CLASS — используется для импорта в интерпретатор функций, представляющих собой методы класса-обертки Python.
  • METH_STATIC — создает функцию с аргументом self, равным NULL. Применяется при необходимости заместить native-расширением класс Python со статическими методами.
  • METH_COEXIST. Позволяет заместить функцией из native-расширения другую функцию или объект Python. Используется при необходимости перегрузки платформозависимых методов классов Python их версиями, импортированными из бинарного модуля.

Теперь наступает время описать сам бинарный модуль. Он тоже является объектом Python и задается статической структурой, сформированной при помощи макроблока PyModuleDef_HEAD_INIT, принимающего три аргумента:

static struct PyModuleDef steroidmodule = {
  PyModuleDef_HEAD_INIT, //Запускаем макроблок 
  "steroid", //Имя модуля 
  NULL, //Строка описания модуля (сейчас задана равной NULL) 
  -1, //Размер данных, хранимых модулем в адресном пространстве конкретного интерпретатора. -1 означает, что все данные о состоянии модуля в целом будут сохранены в глобальных переменных и недоступны из Python 
  SteroidMethods //Ранее созданная структура, содержащая описание экспортируемых функций 
};

Сам тип PyModuleDef определен в файле moduleobject.h таким образом, что корректно реализует в стиле C++ все операции, необходимые для загрузки модуля, связывания его с интерпретатором и работы сборщика мусора. Теперь осталось определить с помощью макроблока PyMODINIT_FUNC функцию, которая непосредственно создаст и проинициализирует модуль при его загрузке. В нашем случае она выглядит предельно просто:

PyMODINIT_FUNC PyInit_steroid(void) {
  return PyModule_Create( & steroidmodule);
}

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

Компиляция

Для того, чтобы получить корректно работающий модуль, его исходный текст нужно скомпилировать с подключенными библиотеками Python и настройками, гарантирующими постоянство доступных интерпретатору адресов. Рекомендуемая документацией строка настроек компилятора GCC для Linux выглядит в нашем случае следующим образом (пути к библиотекам зависят от версии Python и операционной системы):

g++ -c -fPIC -I/usr/include/python3.5m -I/usr/include/python3.5m -Wno-unused-result -Wsign-compare -g -fstack-protector-strong -Wformat -Werror=format-security -DNDEBUG -g -fwrapv -O3 -Wall -L/usr/lib/python3.5/config-3.5m-x86_64-linux-gnu -L/usr/lib -lpython3.5m -lpthread -ldl -lutil -lm -Xlinker -export-dynamic -Wl,-O2 -Wl,-Bsymbolic-functions -o steroid.o steroid.cpp && g++ --shared -o steroid.so steroid.o

На выходе имеем файл steroid. o, представляющий собой библиотеку ELF Shared Object, в списке динамически экспортируемых функций которой присутствуют определенные нами имена. Имя, определенное для функции PyInit_steroid осталось в ходе компиляции неизмененным (unmangled) и дополнительно помечено как глобальное (GLOBAL), тогда как к остальным именам добавлены служебные данные.

Теперь интерпретатор сможет вызвать эту функцию по ее имени и получить доступ к функции PyModule_Create2, которая, в свою очередь, загрузит в память все описанные нами статические структуры и откроет пользовательские функции для вызова.

Заключение

Конечно, описанный мной пример неполон. В нем не уделяется внимания, например, сложной проблеме вызова функций Python из C++ при посредничестве интерпретатора (Interpreter callbacks), обработке исключений (exception handling), ручному управлению сборкой мусора. Тем не менее, именно такой простой пример показался мне максимально показательным с точки зрения разбора механизма связывания Python и C++.

Он позволяет избавиться от казуистики с типами, способной испугать неопытного программиста и присущей документации Python. Работоспособный проект модуля-примера опубликован на Github под лицензией GNU GPL v3.0 по адресу github.com/twdragon/python-cpp-numconvert-example. Кроме собственно исходного текста, в депозитарий включен также Makefile, позволяющий скомпилировать модуль в практически любом дистрибутиве Linux с установленной версией Python 3.5 и путями, сконфигурированными по умолчанию.