Привет, Хабр! Меня зовут Игорь Алимов, я ведущий разработчик группы Python в МТС Digital, и это вторая часть статьи, посвященной тому, как писать быстрый код на Python с использованием C-расширений. Я расскажу о всех нюансах и приведу конкретный пример применения этого метода.
Первую часть статьи читайте здесь.
В прошлый раз мы дошли как раз до примера, с него и начнем.
Как считать sha256?
В Python-коде мы задействовали библиотеку hashlib, но ее использование в нашей C-функции выглядит неправильным. Не для того мы уходили в С-код, чтобы из него снова запускать Python-код. Возможны следующие варианты:
Написать свою реализацию sha256 на C.
Использовать готовую библиотеку.
Есть мнение, что программисты по своей натуре – люди ленивые, поэтому первый вариант лично у меня вызывает отторжение.
Рассмотрим второй вариант. Какую готовую библиотеку мы можем взять? Любую, которая соответствует нашим потребностям и имеет подходящую лицензию. Если у вас есть мощная видеокарта и библиотека, которая может вычислять хеш суммы при помощи этой карты, – это отличный вариант.
Еще более производительное решение – аппаратный майнер и библиотека для управления. К сожалению, далеко не у всех есть аппаратный майнер или мощная видеокарта, поэтому воспользуемся скромным расчетом хеш-сумм на CPU. Но это все еще не отвечает на вопрос, какую библиотеку использовать. Если вы работаете в Linux (на что я искренне надеюсь) – откройте консоль и наберите man sha256. Далее я привожу короткую выдержку из того, что появилось у меня на экране:
SHA256_INIT(3) OpenSSL SHA256_INIT(3)
NAME
SHA1, SHA1_Init, SHA1_Update, SHA1_Final, SHA224, SHA224_Init, SHA224_Update, SHA224_Final, SHA256,
SHA256_Init, SHA256_Update, SHA256_Final, SHA384, SHA384_Init, SHA384_Update, SHA384_Final, SHA512,
SHA512_Init, SHA512_Update, SHA512_Final - Secure Hash Algorithm
SYNOPSIS
#include <openssl/sha.h>
...
int SHA256_Init(SHA256_CTX *c);
int SHA256_Update(SHA256_CTX *c, const void *data, size_t len);
int SHA256_Final(unsigned char *md, SHA256_CTX *c);
unsigned char *SHA256(const unsigned char *d, size_t n,
unsigned char *md);
Библиотека OpenSSL умеет вычислять sha256, она есть в любом дистрибутиве Linux и использует в своей работе CPU. При виде прототипов функций появляется предложение о возможной оптимизации функции mining_mining. Мы могли бы один раз обработать строку с начальным значением и наработать от нее контекст. Затем мы сохраним наработанный контекст и для дальнейшего расчета случайного значения используем его копию. С нашей небольшой начальной строкой это не даст заметного увеличения производительности. Но если начальная строка размером несколько мегабайт – это позволит значительно выиграть время.
Парсинг входных аргументов
На Python заголовок функции mining_mining выглядел бы так:
from typing import Optional, Tuple
def mining(init_value: str, printable: str, start: int, end: int, expected: str) -> Optional[Tuple[str, str, int]]:
"""
Производит поиск hash сумм начинающихся особым образом
:param init_value: Начальная строка к которой будет добавляться случайная строка
:param printable: Набор символов, используемый для формирования случайной строки
:param start: Начальный номер строки
:param end: Конечный номер строки
:param expected: Ожидаемое начало hash суммы
:return: Tuple с тремя значениями: случайная строка, хеш сумма, номер случайной строки
"""
Для парсинга входных аргументов в C-функции применим следующий код:
static PyObject *
mining_mining(PyObject *self, PyObject *args)
{
unsigned long long start, end;
const char *init_value, *printable, *expected;
size_t len_init_value, len_printable, len_expected;
if (!PyArg_ParseTuple(args, "s#s#KKs#", &init_value, &len_init_value, &printable,
&len_printable, &start, &end, &expected, &len_expected))
return NULL;
...
}
Ключевым элементом здесь является вызов функции PyArg_ParseTuple, мы передаем в нее указатель на Tuple с аргументами args, форматную строку и адреса переменных, которые в случае успеха установит PyArg_ParseTuple. Форматная строка:
s# первый аргумент – строка, помещается в переменную init_value, длина этой строки помещается в len_init_value;
s# второй аргумент – строка, помещается в переменную printable, длина этой строки в len_printable;
K третий аргумент – int, преобразуется к типу unsigned long long и помещается в переменную start;
K четвертый аргумент – int, преобразуется к типу unsigned long long и помещается в переменную end;
s# пятый аргумент – строка, помещается в переменную expected, длина этой строки помещается в len_expected.
Если при парсинге входных аргументов произойдет ошибка – функция PyArg_ParseTuple вернет 0 и установит соответствующий объект исключения, нам же остается только вернуть NULL. Подробнее о функции PyArg_ParseTuple и форматной строке Parsing arguments and building values можно почитать здесь.
Выделение памяти
В нашей функции mining_mining только одна переменная имеет изменяемый размер и это случайная строка. Размер всех остальных переменных фиксирован и известен в момент компиляции, размещать мы их будем на стеке. Для определения максимального размера случайной строки используется функция get_max_length_value, которая выглядит следующим образом:
static size_t
get_max_length_value(unsigned long long n, size_t len_printable)
{
return log(n) / log(len_printable) + 2;
}
Максимальный размер строки определяется наибольшим номером строки end и длиной строки с символами, используемых для формирования случайной строки. Численное значение равно логарифму от end по основанию len_printable, значение округляется до целого и к нему прибавляется 2. Первая единица берется из округления, если логарифм равен 8.76 – то для строки потребуется 9 символов, еще один символ необходим для терминирующего '\0'. Строки в C должны заканчиваться нулевым байтом. Функция get_max_length_value используется для внутреннего использования и не имеет отображения в Python.
Выделение памяти под случайную строку:
char *value = PyMem_RawCalloc(1, get_max_length_value(end, len_printable));
if (value == NULL)
return PyErr_NoMemory();
Используется функция PyAPI_FUNC(void *) PyMem_RawCalloc(size_t nelem, size_t elsize), которая предназначена для выделения памяти под массив с nelem-элементами, каждый из которых имеет размер elsize. Почему она здесь используется? Эта функция производит инициализацию выделенной памяти нулями, которые обязаны быть в конце C-строк. Если при выделении памяти произошла ошибка, то функция PyMem_RawCalloc возвращает NULL. В этом случае мы вызываем функцию PyErr_NoMemory, которая установит объект исключения и вернет NULL, который мы, как признак ошибки, вернем вызывающей функции. Если все прошло хорошо – необходимо не забыть перед выходом из функции сделать
PyMem_RawFree(value);
для того, чтобы освободить занимаемую память.
Как освободить GIL?
Настала пора победить GIL. Глобальная блокировка интерпретатора не нужна C-коду и ее можно смело отпускать. Главное условие: нельзя обращаться к Python объектам, если блокировка отпущена. Последовательность действий такая:
Распарсить входные аргументы и скопировать их в C-переменные.
Отпустить GIL.
Произвести необходимые вычисления.
Захватить GIL.
Сформировать результат в виде Python объекта.
Вернуть результат.
Для отпускания и получения GIL можно использовать следующие макросы:
Py_BEGIN_ALLOW_THREADS
// Здесь могут находится интенсивные вычисления и блокирующие IO операции
Py_END_ALLOW_THREADS
С этими макросами надо соблюдать осторожность, в макросе Py_BEGIN_ALLOW_THREADS содержится открывающая фигурная скобка, а в макросе Py_END_ALLOW_THREADS есть закрывающая скобка. Поэтому макросы должны находится на одном уровне вложенности.
Если это условие не соблюдается, то можно воспользоваться макросами:
PyThreadState *_save;
Py_UNBLOCK_THREADS
// Здесь могут находится интенсивные вычисления и блокирующие IO операции
Py_BLOCK_THREADS
Подробнее о Thread State and the Global Interpreter Lock – здесь.
Продолжение функции mining_mining
Как уже отмечалось выше, мы будем использовать два контекста для вычисления sha256:
initial_context, который вычисляется от начальной строки;
context, это копия initial_context, в который мы добавляем значение случайной строки, финализируем его, получаем двоичное представление хеш-суммы и затем преобразуем его в текстовое представление.
Другие переменные функции mining_mining:
hash – двоичное представление хеш суммы;
hexdigest – текстовое (в виде шестнадцатеричных чисел) представление хеш-суммы;
value – случайная строка;
len_value – текущая длинна случайной строки.
Пояснения к коду:
Цикл расположенный в строках 62-79, обеспечивает перебор значений от start до end.
В строке 64 производится копирование initial_context в context.
В строке 65 вызывается функция get_value, которая формирует случайное значение, значение записывается в переменную value, функция возвращает длину формированной строки.
В строках 66-67 производится наработка контекста от случайной строки и финализация расчета хеш суммы.
Цикл в строках 68-71 преобразует двоичное представление sha256 в текстовое.
В строке 72 производится проверка, что начало хеш суммы соответствует ожиданиям.
Формирование результата
Если начало хеш-суммы соответствует нашим ожиданиям – захватываем GIL и формируем результат:
PyObject *res = Py_BuildValue("ssK", value, hexdigest, start);
Функция Py_BuildValue создает новый tuple, состав, которого определяется форматной строкой ssK. Первые два значения – строки. Value – случайная строка, hexdigest – хеш-сумма, третье значение – номер случайной строки, значение типа unsigned long long преобразуется к типу int. У созданного значения увеличивается счетчик ссылок и в таком виде оно возвращается вышестоящей функции.
Освобождаем память, занимаемую случайной строкой value, PyMem_RawFree(value) и возвращаем результат.
В случае, если не удалось найти хеш-сумму, также захватываем GIL, освобождаем память и возвращаем None. Для этого используем макрос Py_RETURN_NONE, он увеличивает счетчик ссылок на None и возвращает указатель на объект.
Формирование необходимых структур и инициализация модуля
Все функции, которые мы хотим импортировать в Python необходимо собрать в следующую структуру:
static PyMethodDef mining_funcs[] = {
{"mining", mining_mining, METH_VARARGS, "Search for special kinds of hash"},
...
{NULL, NULL, 0, NULL}
};
Определение структуры PyMethodDef:
struct PyMethodDef {
const char *ml_name; /* The name of the built-in function/method */
PyCFunction ml_meth; /* The C function that implements it */
int ml_flags; /* Combination of METH_xxx flags, which mostly
describe the args expected by the C func */
const char *ml_doc; /* The __doc__ attribute, or NULL */
};
typedef struct PyMethodDef PyMethodDef;
ml_name – название функции, как она будет видна в Python;
ml_meth – указатель на C-функцию;
ml_flags – тип функции, в данном случае это функция с позиционными аргументами, METH_VARARGS;
ml_doc – Документация функции.
{NULL, NULL, 0, NULL} – признак окончания списка.
Структура, описывающая модуль:
static struct PyModuleDef mining_module = {
PyModuleDef_HEAD_INIT,
"mining", /* name of module */
"Documentation for mining module", /* module documentation, may be NULL */
-1, /* size module */
mining_funcs /* List funcs */
};
Функция инициализации модуля:
PyMODINIT_FUNC
PyInit_mining(void)
{
return PyModule_Create(&mining_module);
}
Надо отменить, что функция инициализации, это единственный non static элемент файла mining.c, то есть эту функцию видно за пределами файла.
Сборка модуля
Для сборки модуля используется файл setup.py:
from setuptools import setup, Extension
module = Extension(
'mining',
sources=['mining.c'],
libraries=['crypto', 'm'],
extra_compile_args=['-Wall', '-Werror', '-O2']
)
setup(
name='mining',
version='0.0.1',
ext_modules=[module]
)
Сборка осуществляется стандартным классом Extension. Для получения списка библиотек следует использовать команду:
pkg-config --libs --cflags openssl
Ее вывод будет таким: '-lssl -lcrypto'. В процессе сборки выяснилось, что библиотека ssl не требуется, но в функции get_max_length_value мы использовали log и для его работы необходимо подтянуть библиотеку math или, как это принято в C, m.
Аргументы компилятора:
-Wall – включить все предупреждения;
-Werror – все предупреждения считать ошибками и прерывать компиляцию;
-O2 – уровень оптимизации.
Для сборки модуля нужно использовать
python setup.py build
Переменная working, функции для ее установки и сброса
В файле mining.c определена переменная working:
#include <stdbool.h>
static volatile bool working = true;
Это глобальная, в рамках файла mining.c, переменная, которая предназначена для немедленного завершения workers.
Ключевое слово volatile, оно говорит компилятору, что он не должен делать предположений о ее состоянии. Переменная может быть изменена другим потоком.
Используется в двух случаях:
Если один из workers нашел хеш сумму с необходимыми свойствами.
При преждевременном завершении программы по Ctrl-C.
По умолчанию переменная working равна true и workers могут работать. Для сброса working в состояние false используется функция:
static PyObject *
mining_stop_working(PyObject *self, PyObject *args)
{
working = false;
Py_RETURN_NONE;
}
Для того, что бы вернуть переменную working в состояние true, используется функция:
static PyObject *
mining_enable_working(PyObject *self, PyObject *args)
{
working = true;
Py_RETURN_NONE;
}
Обе эти функции импортируются в Python и для этого добавляются в массив mining_funcs.
Головной файл blockchain.py
Исходный код blockchain.py в целом соответствует prototype_multi.py с некоторыми изменениями:
модуль multiprocessing заменен на ThreadPoolExecutor из модуля concurrent.futures. Нам теперь не страшен GIL.
Добавлен обработчик signal_handler, который завершает работу всех workers при завершении программы по Ctrl-C.
Не требуется очереди для получения результатов работы workers.
Результаты работы blockchain.py
Результаты работы blockchain.py представлены в файле blockchain.log. Время выполнения – 56 минут 23.663 секунды, средняя производительность 2122.755 kH/s. Это меньше времени исполнения prototype_multi.py на 5 минут 31.347 секунд. Я ожидал большего.
Почему так ? В исходном коде модуля hashlib видно, что производится попытка импорта модуля _hashlib. Если она удачна, то для расчета хеш-сумм используется он.
Модуль _hashlib написан на C и его исходный код – _hashopenssl.c. Он также использует для расчета хеш-сумм библиотеку OpenSSL. Причем перед интенсивными расчетами там тоже отпускается GIL. Для примера смотрим определение функции EVP_update.
Получается, что время в 5 минут мы выиграли исключительно за счет того, что формировали случайные строки в функции get_value на C.
В случае этого конкретного примера логично остановиться на варианте prototype_multi.py.
Что НЕ НУЖНО переписывать с Python на C
Верхнеуровневую логику работы приложения, разбор параметров командной строки, чтение конфигурационных файлов, логирование и то, что выполняется один раз.
Клиентскую работу с сетью. При сетевых операциях большая часть времени уходит на задержки, ожидание ответа удаленного сервера. Пусть 95% времени занимает ожидание ответа сервера, а 5% – время работы нашего кода. Если мы перепишем код на C и заставим его работать в пять раз быстрее – мы уменьшим в пять раз только 5%. Общая выгода от такого рефакторинга – 4%, с учетом случайности сетевых задержек ее трудно уловить.
Работу с базами данными и сетевыми хранилищами. Исключение – когда вы имеете дело с экзотической базой данных, для соединения с которой есть только C-библиотека. Тогда нужно написать binding к этой библиотеке на C, затем выложить его в открытый доступ, чтобы в дальнейшем ни у кого такой проблемы не было
Переписывать код, который уже выполняется при помощи C extensions (это как раз наш случай).
Вывод
Комплексное использование языков высокого и низкого уровней в одном проекте позволяет достичь ощутимо более высоких результатов, чем при применении одного только языка высокого уровня, а разработка идет быстрее и дешевле. Python в этом отношении не уникален, практически все промышленные языки программирования в той или иной форме позволяют использовать нативные расширения. Общим знаменателем этого метода были, есть и еще достаточно долго будут C/C++.
А вы применяете в своей работе с Python С-расширения? Или знаете другие способы ускорить Python и побороть GIL? Расскажите о своем опыте в комментариях к статье!