Встраиваем Lua в PHP через FFI
Foreign Function Interface — это перспективная альтернатива для традиционных PHP-расширений.
Сегодня мы будем разбирать FFI-библиотеку для работы с liblua5 из PHP, которая позволит исполнять скрипты на Lua из нашего приложения.
Мотивация
Для PHP уже есть расширение Lua с PECL. Тем не менее смысл в нашей задумке есть:
- Через FFI есть доступ к полному Lua API, поэтому у нас больше свободы.
- FFI-библиотеки не используют Zend API, им проще пережить мажорный релиз PHP.
- Читать PHP код нам обычно проще, чем C в связке с внутренностями PHP.
- Легче распространять результат как composer пакет, ведь это обычный PHP-код.
Ещё одним бонусом FFI-библиотек является то, что они будут успешно запускаться и на KPHP, если мы правильно расставим типы через phpdoc.
Сейчас один из главных недостатков этого подхода — не очень высокая производительность. И хотя в KPHP использование FFI несёт минимальные накладные расходы, в PHP всё гораздо сложнее. Автор JIT-компиляции в ядре PHP, Дмитрий Стогов, планирует в отдалённом будущем «подружить» JIT и FFI, значительно увеличив производительность этого механизма.
В качестве занимательного факта: Дмитрий, помимо прочего, ещё и автор FFI для PHP.
Но зачем именно liblua? Есть две основные причины:
- Это уникальный и довольно сложный пример для FFI. Полезен в образовательных целях.
- В компилируемом KPHP полезно иметь возможность использовать динамические плагины.
Подготовка к началу
На Хабре я уже несколько описывал, как создавать composer-пакеты для FFI-библиотек, поэтому сегодня мы сразу перейдём к делу. Я буду придерживаться практик, изложенных в статье «Используем SQLite в KPHP и PHP через FFI».
Нам потребуется установить liblua. Подойдут любые версии в диапазоне 5.1–5.4. Затем находим в системе эту библиотеку. На Linux нам может помочь утилита ldconfig
.
# Запомним путь, по которому можно найти библиотеку,
# он нам скоро понадобится.
$ ldconfig -p | grep lua
liblua5.3.so.0 (libc6,x86-64) => /lib/x86_64-linux/liblua5.3.so.0
Нам также потребуются полифилы для KPHP:
$ composer require vkcom/kphp-polyfills
Hello, world!
Чтобы использовать Lua, надо получить lua_State
. Для этого можно воспользоваться функцией luaL_newstate
.
Для запуска какого-нибудь кода на Lua можно было бы взять luaL_dostring
, но это макрос. Макросы использовать у нас не получится, но мы можем подсмотреть в его определение:
#define luaL_dostring(L, str) \
(luaL_loadstring(L, str) || lua_pcall(L, 0, LUA_MULTRET, 0))
#define lua_pcall(L, n, r, f) \
lua_pcallk(L, (n), (r), (f), 0, NULL)
Добавляем в список функции luaL_loadstring
и lua_pcallk
.
Без стандартной библиотеки будет сложновато вывести сообщение на экран, поэтому возьмём ещё и luaL_openlibs
.
Наш минимальный заголовочный файл для FFI, lua.h
, будет выглядеть так:
#define FFI_LIB "./ffilibs/liblua5"
#define FFI_SCOPE "lua"
typedef struct lua_State lua_State;
typedef intptr_t lua_KContext;
int luaL_loadstring(lua_State *L, const char *s);
int lua_pcallk(lua_State *L,
int nargs,
int nresults,
int errfunc,
lua_KContext ctx,
void *k);
lua_State *luaL_newstate();
void luaL_openlibs(lua_State *L);
Теперь разместим liblua там, где его сможет найти наша библиотека:
$ mkdir ffilibs
$ cp /lib/x86_64-linux/liblua5.3.so.0 ffilibs/liblua5
Для PHP FFI::load
будет работать с FFI::scope
только при использовании внутри opcache preload. В случае с KPHP у нас нет opcache preload, но FFI::load
является более быстрой операцией и должен выполняться где-то в начале скрипта.
Создадим два скрипта: main.php
и preload.php
.
preload.php
:
main.php
:
luaL_newstate();
$lib->luaL_openlibs($state);
$lib->luaL_loadstring($state, 'print("Hello, World!")');
$lib->lua_pcallk($state, 0, 0, 0, 0, null);
Попробуем запустить нашу программу через PHP:
$ php -d opcache.enable_cli=1 \
-d opcache.preload=preload.php \
-f main.php
Hello, World!
Запустим на KPHP:
$ kphp --mode cli --composer-root $(pwd) main.php
$ ./kphp_out/cli
Hello, World!
Отлично! Мы уже можем исполнять произвольные Lua-фрагменты в наших программах. Дальше будем дорабатывать свою библиотеку, делая её удобнее, эффективнее и функциональнее.
Аллокатор памяти
Есть два способа создать lua_State
:
luaL_newstate
(то, что мы использовали ранее)lua_newstate
Сигнатура у lua_newstate
более сложная:
typedef void* (*lua_Alloc) (void *ud,
void *ptr,
size_t osize,
size_t nsize);
lua_State *lua_newstate(lua_Alloc f, void *ud);
Через lua_newstate
мы можем контролировать, как среда исполнения Lua будет выделять и очищать память. luaL_newstate
использует для работы с памятью системный realloc
.
Есть пара недостатков у использования стандартного аллокатора. Если скрипт получит таймаут и его работа будет прекращена до завершения Lua-скрипта, может произойти утечка памяти. Помочь избежать этого может вызов lua_close
где-то внутри shutdown function.
Другой минус — появляется отдельный пул памяти, поэтому становится сложнее подсчитывать и контролировать её потребление скриптом.
Передавая свой аллокатор, мы можем собирать статистику по аллокациям, выделять через скриптовую «кучу» (которая будет очищена после обработки запроса), а также ограничивать максимальное потребление памяти для исполняемого Lua-скрипта.
Я покажу, как можно реализовать простой аллокатор через FFI:
// Наш аллокатор должен эмулировать поведение realloc.
$state = $lib->lua_newstate(function ($ud, $ptr, $orig_size, $new_size) {
// Так как у нас нет настоящего FFI::realloc, мы будем распознавать
// три случая: очищение памяти, выделение нового блока и
// настоящий realloc (когда нужно выделить более крупный блок и
// скопировать туда данные из старого блока, не забыв при этом
// освободить ранее выделенную память).
if ($new_size === 0) {
if ($orig_size !== 0 && $ptr !== null) {
// 1. free()
\FFI::free(\FFI::cast("uint8_t[$orig_size]", $ptr));
}
return null;
}
if ($ptr === null) {
// 2. malloc()
$mem = \FFI::new("uint8_t[$new_size]", false);
return \FFI::cast('void*', \FFI::addr($mem));
}
// 3. realloc()
$copy_size = ($new_size > $orig_size) ? $orig_size : $new_size;
$mem = \FFI::new("uint8_t[$new_size]", false);
\FFI::memcpy($mem, $ptr, $copy_size);
\FFI::free(\FFI::cast("uint8_t[$orig_size]", $ptr));
return \FFI::cast('void*', \FFI::addr($mem));
}, null);
Мы используем FFI::new
с аргументом $owned=false
, так как мы хотим вернуть память в C, передавая владение. Другими словами, создаваемый объект не будет очищать память, когда счётчик его ссылок достигает нуля.
FFI::free
предназначен для очищения памяти, которая выделялась с флагом $owned=false
. Память, которой владеет PHP, очищать через FFI::free
нельзя.
В PHP FFI нет способа сделать настоящий realloc
, но мы можем эмулировать это поведение через комбинацию вызовов FFI::new
, FFI::memcpy
и FFI::free
.
Внутри функции lua_Alloc
можно разместить уместные для приложения ограничения и подсчёт статистики. В случае с KPHP при использовании своего аллокатора мы также можем отслеживать выделение памяти через ktest-бенчмарки. Но к ним мы вернёмся позднее.
Далее я буду считать, что у нас есть класс MyLua
, который содержит в себе $lib
и $state
. В него мы будем добавлять всю новую функциональность.
class MyLua {
/** @var ffi_scope */
public static $lib;
/** @var ffi_cdata */
public static $state = null;
public static function eval(string $code) {
// Код, через который мы выводили hello world.
}
}
Предостережения при работе с FFI: free
Есть несколько способов завалить PHP (и KPHP) в segfault через FFI::free
. Кроме базовых правил, известных нам ещё из C, есть менее очевидные нюансы, которые легко проглядеть.
Я приведу несколько примеров, как делать точно не стоит.
// ПЛОХО: вызываем free() применительно к результату FFI::addr()
$obj = FFI::new('uint64_t', false);
FFI::free(FFI::addr($obj));
// ХОРОШО: вызываем free() применительно к самому CData-объекту
$obj = FFI::new('uint64_t', false);
FFI::free($obj);
// ПЛОХО: преобразуем массив к void* без использования addr()
$obj = FFI::new("int[$size]", false);
$ptr = FFI::cast('void*', $obj);
FFI::free($ptr);
// ХОРОШО: используем addr() при преобразовании массива к указателю
$obj = FFI::new("int[$size]", false);
$ptr = FFI::cast('void*', FFI::addr($obj));
FFI::free($ptr);
// ПЛОХО: освобождаем массив с указанием неправильного размера
$arr = FFI::new('int[10]', false);
$arr_ptr = FFI::cast('void*', FFI::addr($arr));
$arr2 = FFI::cast('int[5]', $arr_ptr);
FFI::free($arr2);
// ХОРОШО: освобождаем массив с правильным размером
$arr = FFI::new('int[10]', false);
$arr_ptr = FFI::cast('void*', FFI::addr($arr));
$arr2 = FFI::cast('int[10]', $arr_ptr);
FFI::free($arr2);
Конвертация значений из PHP в Lua
Чтобы передавать в Lua какие-то осмысленные значения, нужно научиться конвертировать PHP-значения в эквиваленты Lua. Для этого пишем метод MyLua::php2lua
. Он принимает на вход mixed
и пытается положить это значение в Lua-стек.
Вот таблица PHP-типов, которые будем поддерживать, а также Lua-функции для размещения этих данных в стеке:
В наш заголовочный файл lua.h
добавим вышеуказанные функции:
typedef double lua_Number;
typedef int64_t lua_Integer;
void lua_pushnil(lua_State *L);
void lua_pushboolean(lua_State *L, int b);
void lua_pushnumber(lua_State *L, lua_Number n);
const char *lua_pushlstring(lua_State *L, const char *s, size_t len);
void lua_createtable(lua_State *L, int narr, int nrec);
void lua_rawset(lua_State *L, int index);
void lua_rawseti(lua_State *L, int index, lua_Integer i);
Далее я не буду акцентировать внимание на новых функциях, которые нужно добавить в lua.h
. Процесс всегда довольно предсказуемый: если хотим использовать функцию из PHP, то находим её сигнатуру в документации и добавляем в заголовочный файл.
Первый набросок php2lua
будет выглядеть так:
public static function php2lua($value) {
if (is_string($value)) {
self::$lib->lua_pushlstring(self::$state, $value, strlen($value));
} else if (is_int($value) || is_float($value)) {
self::$lib->lua_pushnumber(self::$state, (float)$value);
} else if (is_bool($value)) {
self::$lib->lua_pushboolean(self::$state, (int)$value);
} else if (is_array($value)) {
// TODO: будет реализовано ниже.
} else {
// Какие-то непонятные значения (в том числе null),
// будем пушить как nil; это не самое правильное решение,
// но оно безопаснее, чем кидать исключение (читайте ниже).
self::$lib->lua_pushnil(self::$state);
}
}
Отдельно стоит сказать про исключения в контексте этой библиотеки. Поскольку мы работаем со стеком Lua, нужно быть осторожными и не оставлять его в неопределённом состоянии. Если при попытке вызова какой-то функции мы не смогли преобразовать один из аргументов, то все уже добавленные в стек аргументы должны быть удалены. То же самое верно и для всех остальных операций, которые нужно выполнять атомарно. Применять исключения можно только в том случае, если верхнеуровневые (публичные) методы всегда перехватывают стек и восстанавливают его изначальное состояние. Чтобы это работало, нужно всегда пессимистично записывать глубину стека перед исполнением логики метода, что добавляет лишние накладные расходы.
Конвертировать PHP-массив в Lua-таблицу — с одной стороны, понятная задача. Каждое значение элемента будет преобразовываться через php2lua
. А с другой, хочется уметь создавать sequence-like таблицы для Lua, если в PHP массив был без пропусков.
if (array_is_list($value)) {
self::$lib->lua_createtable(self::$state, count($value), 0);
$table_index = 1; // В Lua "массивах" индексы начинаются с 1
foreach ($value as $elem) {
self::php2lua($elem);
self::$lib->lua_rawseti(self::$state, -2, $table_index);
$table_index++;
}
return;
}
// Создаём таблицу более прямолинейным способом.
self::$lib->lua_createtable(self::$state, 0, 0);
foreach ($value as $key => $elem) {
self::php2lua($key);
self::php2lua($elem);
self::$lib->lua_rawset(self::$state, -3);
}
Конвертация значений из Lua в PHP
Метод MyLua::lua2php
производит операцию, обратную MyLua::php2lua
. lua2php
принимает на вход индекс внутри Lua-стека и возвращает данные из этой ячейки, преобразовав их в PHP-формат. Эта функция не удаляет элемент из стека, поэтому, если требуется операция типа pop()
, нужно сначала извлечь значение, а затем уже выполнить stackDiscard(1)
.
/**
* @param int $n
*/
public static function stackDiscard($n) {
// lua_pop - это макрос, поэтому используем lua_settop.
self::$lib->lua_settop(self::$state, -($n) - 1);
}
Чтобы понять, что за тип данных хранится по индексу, нам потребуются константы тегов типа:
public const TNIL = 0;
public const TBOOLEAN = 1;
public const TLIGHTUSERDATA = 2;
public const TNUMBER = 3;
public const TSTRING = 4;
public const TTABLE = 5;
public const TFUNCTION = 6;
public const TUSERDATA = 7;
public const TTHREAD = 8;
/**
* @param int $index
* @return mixed
*/
public static function lua2php($index) {
switch (self::$lib->lua_type(self::$state, $index)) {
case self::TNIL:
return null;
case self::TBOOLEAN:
return (bool)self::$lib->lua_toboolean(self::$state, $index);
case self::TNUMBER:
return self::$lib->lua_tonumberx(self::$state, $index, null);
case self::TSTRING:
return self::$lib->lua_tolstring(self::$state, $index, null);
case self::TTABLE:
return self::lua2phpTable($index);
default:
return ['_error' => "unsupported Lua->PHP type"];
}
}
Таблицы будем возвращать как есть, без попыток распознать там sequence/array table.
В библиотеке KLua реализован более сложный алгоритм, который может преобразовать{"a", "b"}
в["a", "b"]
вместо[1 => "a", 2 => "b"]
. Но это довольно много кода с эвристиками, которые не обязательны для этой статьи.
/**
* @param int $index
* @return mixed[]
*/
public static function lua2phpTable($index) {
$result = [];
// Кладём на стек первый ключ - nil.
self::$lib->lua_pushnil(self::$state);
while (self::$lib->lua_next(self::$state, $index) !== 0) {
$value = self::lua2php(-1);
self::stackDiscard(1);
$result[self::lua2php(-1)] = $value;
// Верхушка стека (ключ) остаётся для следующей итерации.
}
return $result;
}
lua2php
понадобится как минимум в двух местах:
- Для метода
MyLua::call
, который мы скоро напишем. - Для возвращаемых значений из скриптов, которые исполняются через
MyLua::eval
.
В методах типа MyLua::getGlobalVar
также понадобилась бы конвертация.
Соединяем два мира
Мы умеем конвертировать значения в обе стороны. Это пригодится, чтобы вызывать из PHP функции на Lua, получая при этом результат, с которым тоже можно работать из PHP.
Процесс вызова будет выглядеть примерно так:
- Кладём Lua-функцию на стек.
- Перемещаем все PHP-аргументы в стек через
php2lua
. - Вызываем Lua-функцию через
lua_pcallk
. - Результаты функции забираем со стека через
lua2php
.
Для простоты вызывать будем только глобальные функции. Вызов функции из таблицы отличается лишь тем, что нужно сначала положить в стек таблицу, а потом извлечь из неё функцию по нужному ключу.
/**
* @param string $func_name
* @param int $num_results
* @param mixed[] $args
*/
public static function call($func_name, $num_results, ...$args) {
$type = self::$lib->lua_getglobal(self::$state, $func_name);
if ($type !== self::TFUNCTION) {
self::stackDiscard(1); // Значение переменной $func_name.
throw new \Exception("can't find $func_name function");
}
foreach ($args as $arg) {
self::php2lua($arg);
}
$status = self::$lib->lua_pcallk(self::$state,
count($args), $num_results,
0, 0, null);
if ($status) {
// Lua кладёт ошибку на стек.
$err = self::lua2php(-1);
self::stackDiscard(1);
throw new \Exception("$func_name: $err");
}
return self::collectCallResults($num_results);
}
public static function collectCallResults($num_results) {
switch ($num_results) {
case 0:
return null;
case 1:
$result = self::lua2php(-1);
self::stackDiscard(1);
return $result;
default:
// Здесь либо цикл с lua2php с добавлением в массив,
// либо более эффективный способ с индексацией стека.
}
}
Использовать это сможем так:
$result = MyLua::call('type', 1, 43.5);
var_dump($result); // "number"
Автоматический подсчёт $num_results
Каждый раз указывать количество результатов при вызове функции не очень удобно. К тому же некоторые функции могут возвращать разное количество результатов в зависимости от входных аргументов. Мы можем реализовать более умный способ извлечения результатов и избавиться от явного параметра $num_results
.
Меняем сигнатуру:
- public static function call($func_name, $num_results, ...$args) {
+ public static function call($func_name, ...$args) {
Перед тем как положить вызываемую функцию на стек, запишем его текущую глубину:
+ $stack_top = self::$lib->lua_gettop(self::$state);
$type = self::$lib->lua_getglobal(self::$state, $func_name);
В lua_pcallk
нужно передать MULTRET
(-1) вместо $num_results
:
$status = self::$lib->lua_pcallk(self::$state,
- count($args), $num_results,
+ count($args), -1,
0, 0, null);
Сразу после lua_pcallk
мы можем вычислить количество результатов:
+ $num_results = self::$lib->lua_gettop(self::$state) - $stack_top;
return self::collectCallResults($num_results);
Вызываем PHP из Lua
Чтобы вызвать PHP-функцию из Lua, нужно передать её как lua_CFunction
в lua_pushcclosure
и сохранить где-нибудь (например, в глобальной переменной). Эти функции будут вызываться из внешнего контекста. В нашем случае это C-код, интерпретирующий Lua-скрипты.
При исполнении в таком внешнем контексте запрещается кидать исключения. В PHP это будет ошибкой исполнения, а в KPHP такой код просто не скомпилируется. KPHP также накладывает дополнительное ограничение: можно использовать только статические методы, глобальные функции и лямбды без замыкаемых переменных.
lua_CFunction
— это низкий уровень абстракции. Параметры вызова мы извлекаем из стека сами, а результаты кладём в стек. Предлагаю упростить задачу и создавать обёртки для всех PHP-функций, которые хотим сделать доступными в Lua. Обёртка будет делать следующее:
- Забирать со стека нужное количество аргументов через
lua2php
. - Вызывать зарегистрированную PHP-функцию.
- Преобразовывать возвращённое значение через
php2lua
(оно попадает на стек).
При этом с точки зрения публичного API можно будет использовать замыкания с переменными.
Я покажу реализацию для PHP функций с двумя аргументами, но в реальности нам потребуются несколько схожих функций, для учёта разной арности.
Вот первая попытка:
/**
* @param string $func_name
* @param callable(mixed,mixed):mixed $fn
*/
public static function registerFunction2($func_name, $fn) {
self::$lib->lua_pushcclosure(self::$state, function ($s) use ($fn) {
// 1. Извлекаем и конвертируем аргументы.
$arg1 = self::lua2php(1);
$arg2 = self::lua2php(2);
// 2. Вызываем функцию.
$result = $fn($arg1, $arg2);
// 3. Конвертируем результат.
self::php2lua($result);
return 1;
}, 0);
// Присваиваем созданную функцию Lua переменной.
self::$lib->lua_setglobal(self::$state, $func_name);
}
К сожалению, в KPHP нельзя использовать $fn
из тела лямбды: такой код не скомпилируется. А к чему у нас есть доступ из этой лямбды? К глобальному состоянию, в частности, к статическим полям классов. Этим и воспользуемся. Добавим в MyLua
статический массив лямбд.
/** @var (callable(mixed,mixed):mixed)[] */
public static $phpfuncs2 = [];
Теперь можем доработать метод registerFunction2
:
+ $id = count(self::$phpfuncs2);
+ self::$phpfuncs2[] = $fn;
- self::$lib->lua_pushcclosure(self::$state, function ($s) use ($fn) {
+ self::$lib->lua_pushcclosure(self::$state, function ($s) {
// 1. Извлекаем и конвертируем аргументы.
$arg1 = self::lua2php(1);
$arg2 = self::lua2php(2);
// 2. Вызываем функцию.
+ $fn = self::$phpfuncs2[$id];
$result = $fn($arg1, $arg2);
// 3. Конвертируем результат.
self::php2lua($result);
return 1;
}, 0);
Но подождите, а как получить этот самый $id
, чтобы найти функцию в массиве? На помощь придут upvalues из Lua API. Правда, прежде чем ими воспользоваться, предстоит решить загадку. Вот определение lua_upvalueindex
:
#if LUAI_BITSINT >= 32
# define LUAI_MAXSTACK 1000000
#else
# define LUAI_MAXSTACK 15000
#endif
#define LUA_REGISTRYINDEX (-LUAI_MAXSTACK - 1000)
#define lua_upvalueindex(i) (LUA_REGISTRYINDEX - (i))
Это не простой макрос, ведь он зависит от константы препроцессора. А та, в свою очередь, вообще может конфигурироваться при сборке liblua.
На этот раз красиво решить задачу не получится. Лучшее, что можно сделать, это предположить для LUAI_MAXSTACK
значение по умолчанию для 64-битных систем и предоставить пользователю возможность переопределить его, если liblua компилировался с другими параметрами.
/**
* @param int $i
*/
public static function upvalueIndex($i) {
// $lua_max_stack - конфигурируемое значение,
// по умолчанию равно 1000000.
$registry_index = (-self::$lua_max_stack - 1000);
return $registry_index - $i;
}
Сложная часть позади. Теперь можем написать финальный вариант registerFunction2
.
/**
* @param string $func_name
* @param callable(mixed,mixed):mixed $fn
*/
public static function registerFunction2($func_name, $fn) {
// Сохраняем PHP-функцию для дальнейшего использования.
// $id выдаём последовательные.
$id = count(self::$phpfuncs2);
self::$phpfuncs2[] = $fn;
// Кладём $id на стек, чтобы сохранить его как upvalue.
self::$lib->lua_pushnumber(self::$state, (float)$id);
self::$lib->lua_pushcclosure(self::$state, function ($s) {
// 1. Извлекаем и конвертируем аргументы.
$arg1 = self::lua2php(1);
$arg2 = self::lua2php(2);
// 2. Вызываем функцию.
$up_index = self::upvalueIndex(1);
$id = (int)self::$lib->lua_tonumberx($s, $up_index, null);
$fn = self::$phpfuncs2[$id];
$result = $fn($arg1, $arg2);
// 3. Конвертируем результат.
self::php2lua($result);
return 1;
}, 1); // Обратите внимание: теперь у нас 1 upvalue, а не 0.
// Присваиваем созданную функцию Lua-переменной.
self::$lib->lua_setglobal(self::$state, $func_name);
}
Попробуем это всё в деле!
MyLua::registerFunction2('phpconcat', function ($s1, $s2) {
return $s1 . $s2;
});
$result = MyLua::call('phpconcat', 'a', 'b');
var_dump($result); // "ab"
Это выглядит так естественно, что даже не задумываешься о том, какой путь проделали строчки "a"
и "b"
, прежде чем мы распечатали их вместе как "ab"
.
- Сначала преобразовали PHP-строки в Lua-строки через
php2lua
. - Затем аргументы
phpconcat
из Lua-строк превратились в PHP-строки. - Функция
phpconcat
приняла PHP-строку и вернула PHP-строку. - Наша обёртка преобразовала результат из PHP-строки в Lua-строку.
- И в самом конце
MyLua::call
преобразовала результат в PHP-строку.
Вызывать PHP-функции через MyLua::call
смысла особого нет, а вот внутри полноценных скриптов это уже гораздо полезнее.
MyLua::eval('
print(phpconcat("a", "b"))
');
Здесь мы делаем почти то же самое, но строки изначально создаются в Lua-контексте. Да и результат не нужно преобразовывать в PHP-значения.
Заметим, что ограничения на замыкаемое лямбдами состояние в нашем API теперь нет:
class MyContext {
public $value = 0;
}
$context = new MyContext();
MyLua::registerFunction0('next_id', function () use ($context) {
return $context->value++;
});
MyLua::eval('
print(next_id()); -- 0
print(next_id()); -- 1
');
Ограничиваем доступ к стандартной библиотеке Lua
Ранее мы всегда использовали luaL_openlibs
для загрузки стандартной библиотеки Lua. Это не всегда предпочтительный способ, так как он подключает абсолютно все библиотеки. Перед тем как перейдём к выборочной загрузке стандартной библиотеки для Lua, рассмотрим более простой способ. Допустим, вы хотите заменить функцию print
, чтобы скрипты писали не в stdout
, а в ваш буфер. Для этого достаточно заменить глобальную переменную print
. Как известно, MyLua::registerFunction
записывает функцию в глобальную переменную — этим и воспользуемся.
class LuaLogger {
public $messages = [];
public function doPrint($arg) {
$this->messages[] = $arg;
return null;
}
}
$logger = new LuaLogger();
// Методы вместе с замыкаемыми объектами использовать тоже можно.
KLua::registerFunction1('print', [$logger, 'doPrint']);
// Вызовы print из Lua теперь добавляют сообщения в
// массив $logger->messages.
KLua::eval('
print(1)
print("hello")
');
Вернёмся к предыдущей задаче. Перечислим модули, которые есть в стандартной библиотеке Lua:
Уже известный нам luaL_openlibs
делает luaL_requiref
для каждого из модулей. Если дать пользователю возможность выбрать массив подключаемых модулей, то мы сможем реализовать выборочную инициализацию.
Для начала попробуем подключить модуль base
без luaL_openlibs
:
self::$lib->luaL_requiref(
self::$state, "_G", self::$lib->luaopen_base, 1);
Если запустим этот код, PHP может быть недоволен:
# Я отформатировал сообщение ошибки для простоты восприятия.
FFI\Exception:
Passing incompatible argument 3 of C function 'luaL_requiref',
expecting 'int32_t(*)()',
found 'int32_t(*)()'
Рабочим вариантом будет введение дополнительной лямбды:
self::$lib->luaL_requiref(self::$state, "_G", function ($s) {
return self::$lib->luaopen_base($s);
}, 1);
Я приведу фрагмент метода загрузки всех модулей по имени, но опущу однотипную часть:
// Где-то около инициализации lua_State.
if ($config->preload_stdlib !== null) {
foreach ($config->preload_stdlib as $lib_name) {
self::openLib($lib_name);
}
} else {
self::$lib->luaL_openlibs(self::$state);
}
/**
* @param string $lib_name
*/
private static function openLib($lib_name) {
switch ($lib_name) {
case "base":
self::$lib->luaL_requiref(self::$state, "_G", function ($s) {
return self::$lib->luaopen_base($s);
}, 1);
break;
case "package":
self::$lib->luaL_requiref(self::$state, $lib_name, function ($s) {
return self::$lib->luaopen_package($s);
}, 1);
break;
case "coroutine":
// Аналогично...
// + все оставшиеся модули из списка выше.
default:
throw new \Exception("can't load $lib_name");
}
self::stackDiscard(1); // lib
}
Предоставляем плагинам красивый SDK
MyLua::registerFunction
позволяет регистрировать глобальные функции. При этом мы можем предоставить красивый доступ к этим функциям через таблицу, загружая перед плагинами наш собственный Lua-скрипт.
// Мы будем использовать префикс php_, чтобы избежать
// возможных коллизий имён.
MyLua::registerFunction2('php_preg_match', function ($pat, $s) {
return preg_match($pat, $s) === 1;
});
// Наш скрипт с таблицами будет загружаться до
// пользовательского кода.
MyLua::eval('
pcre = {}
function pcre.match(pat, s)
return php_preg_match(pat, s)
end
');
// Пользовательский код может использовать функции
// через таблицу pcre.
MyLua::eval('
print(pcre.match("/[0-9]+/", "abc")) -- true
print(pcre.match("/[0-9]+/", "435")) -- false
');
Альтернативный путь — добавлять в интерфейс нашей библиотеки дополнительные способы регистрации PHP-функций. Например, дополнительным аргументом мы могли бы принимать имя глобальной таблицы, в которую стоит добавить новую функцию.
Тюним производительность
Самый простой способ проверить производительность кода на PHP или KPHP — это запустить бенчмарк ktest. Его можно установить при помощи composer:
$ composer require --dev vkcom/ktest-script
Сразу же проверяем, что всё хорошо:
$ ./vendor/bin/ktest --help
Usage:
ktest COMMAND
Possible commands are:
phpunit run phpunit tests using KPHP
compare test that KPHP and PHP scripts output is identical
benchstat compute and compare statistics about benchmark results
bench run benchmarks using KPHP
bench-ab run two selected benchmarks using KPHP, compare results
bench-php run benchmarks using PHP
bench-vs-php run benchmarks using KPHP and PHP, compare results
env print ktest-related env variables
version print ktest version info
Run 'ktest COMMAND -h' to see more information about a command.
Создадим файл benchmarks/BenchmarkMyLua.php
:
Эти бенчмарки можно запускать в нескольких режимах. Рассмотрим самые интересные.
Запуск через KPHP:
$ ./vendor/bin/ktest bench --benchmem ./benchmarks
class: BenchmarkMyLua
BenchmarkMyLua::Call2PHPMin 126440 507.0 ns/op 0 B/op 0 allocs/op
BenchmarkMyLua::Call2PHPConcat 68980 924.0 ns/op 32 B/op 2 allocs/op
BenchmarkMyLua::EvalPHPConcat 13260 7580.0 ns/op 1662 B/op 56 allocs/op
ok BenchmarkMyLua 972.144303ms
Для KPHP доступен флаг --benchmem
, который добавляет в результаты бенчмарков информацию о том, сколько памяти было выделено. Как видите, вызывать Lua через MyLua::call
гораздо быстрее, чем через eval
. Как минимум, не нужно парсить и компилировать исходники в байт-код. У ваших плагинов, скорее всего, будет понятная точка входа, вроде функции run
или main
, поэтому запускать их рекомендуется именно через MyLua::call
. При этом точка входа может быть автоматически сгенерирована вами, чтобы правильно изолировать окружение (_ENV
) плагинов.
Запуск через PHP (пока нет поддержки для --benchmem
):
$ php --version
PHP 8.1.8 (cli) (built: Jul 11 2022 08:29:57) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.1.8, Copyright (c) Zend Technologies
with Zend OPcache v8.1.8, Copyright (c), by Zend Technologies
$ ./vendor/bin/ktest bench-php --preload preload.php ./benchmarks
class: BenchmarkMyLua
BenchmarkMyLua::Call2PHPMin 5260 9986.0 ns/op
BenchmarkMyLua::Call2PHPConcat 8120 10120.0 ns/op
BenchmarkMyLua::EvalPHPConcat 1460 74153.0 ns/op
ok BenchmarkMyLua 456.230781ms
По умолчанию для PHP8 тесты запускаются с такими настройками JIT:
opcache.enable_cli=1
opcache.jit_buffer_size=96M
opcache.jit=on
При желании можно гонять PHP8 без JIT:
$ ./vendor/bin/ktest bench-php --no-jit --preload preload.php ./benchmarks
class: BenchmarkMyLua
BenchmarkMyLua::Call2PHPMin 6840 9413.0 ns/op
BenchmarkMyLua::Call2PHPConcat 7880 9980.0 ns/op
BenchmarkMyLua::EvalPHPConcat 1460 79526.0 ns/op
ok BenchmarkMyLua 442.669261ms
А ещё можно запустить режим сравнения KPHP-vs-PHP:
# Разбил команду на две строки, чтобы уместить по ширине.
$ ./vendor/bin/ktest bench-vs-php --geomean\
--preload preload.php ./benchmarks
name PHP time/op KPHP time/op delta
MyLua::Call2PHPMin 9.35µs ± 1% 0.49µs ± 0% -94.71% (p=0.000 n=9+9)
MyLua::Call2PHPConcat 10.3µs ± 4% 0.9µs ± 2% -91.36% (p=0.000 n=10+10)
MyLua::EvalPHPConcat 79.3µs ± 1% 7.5µs ± 0% -90.53% (p=0.000 n=10+9)
[Geo mean] 19.7µs 1.5µs -92.43%
Как видите, PHP FFI действительно не очень эффективен, и JIT здесь помочь пока не способен. Любопытно посмотреть, как изменится ситуация, когда JIT начнёт оптимизировать подобный код.
В KPHP вызовы FFI относительно быстрые. Можно даже не задумываться о накладных расходах, если только речь идёт не о простейших getter-функциях. Мы можем измерить затраты на вызов с помощью бенчмарка.
*/
private $lib;
/** @var ffi_cdata */
private $state;
public function __construct() {
if (KPHP_COMPILER_VERSION) {
FFI::load(__DIR__ . '/../src/lua.h');
}
$this->lib = FFI::scope('lua');
$this->state = $this->lib->luaL_newstate();
}
public function benchmarkGettop() {
return $this->lib->lua_gettop($this->state);
}
}
$ ./vendor/bin/ktest bench ./benchmarks/BenchmarkFFI
class: BenchmarkFFI
BenchmarkFFI::Gettop 689660 17.0 ns/op
ok BenchmarkFFI 260.789974ms
Около 17 наносекунд на вызов lua_gettop
. Неплохо, но можно лучше.
Дело в том, что компилятор KPHP генерирует критические секции для каждого FFI-вызова. Так он защищается от неприятностей, которые могут возникнуть в случае вызова произвольного нативного кода. Для простейших и безопасных функций вроде lua_gettop
мы можем применить в lua.h
-файле специальную аннотацию, которая отключит эти критические секции для выбранной функции.
+ // @kphp-ffi-signalsafe
int lua_gettop(lua_State *L);
Запустим бенчмарк ещё раз:
$ ./vendor/bin/ktest bench ./benchmarks/BenchmarkFFI
class: BenchmarkFFI
BenchmarkFFI::Gettop 862080 6.0 ns/op
ok BenchmarkFFI 253.012341ms
Примерно 6 наносекунд! Это хороший результат. Накладные расходы на критическую секцию близки к константе — около 10 наносекунд. В зависимости от паттернов использования это может быть много или мало. Чаще всего — капля в море. Для lua_gettop
считаю эту оптимизацию оправданной.
Производительность расширений по сравнению с FFI в PHP
Для сравнения посмотрим, что там у PHP:
$ ./vendor/bin/ktest bench-vs-php --preload preload.php\
./benchmarks/BenchmarkFFI.php
name PHP time/op KPHP time/op delta
FFI::Gettop 301ns ± 1% 6ns ± 0% -98.00% (p=0.000 n=8+10)
Текущая поддержка FFI в PHP имеет довольно высокие накладные расходы на взаимодействие между PHP и внешним кодом. Однако после выполнения вызова имеем ту же производительность, с которой работают нативные библиотеки.
Это ограничивает применимость, потому что какую-нибудь вспомогательную математическую библиотеку использовать будет уже не так приятно: больше половины времени исполнения может прийтись на вызов функции, а не на её работу. Однако расстраиваться по этому поводу рано, ведь проблема на радаре у нескольких людей и можно рассчитывать на улучшения.
Для некоторых ситуаций даже 300 наносекунд на вызов — не катастрофа. Например, если функция выполняет какую-то значительную работу, то мы даже не заметим эти лишние 0,0000003 секунды.
В нашем случае планируем исполнять скрипты на Lua. Высока вероятность, что даже не заметим влияния FFI на производительность. Предлагаю устроить сравнение с PHP-расширением и посмотреть, сможем ли мы увидеть разницу.
Мы будем запускать spectral norm с параметром N=25 (это очень мало). Для use_ffi_allocator=false
результаты будут следующими:
name ext time/op ffi time/op delta
LuaExtension::Eval 3.45ms ± 1% 3.47ms ± 1% +0.53% (p=0.002 n=10+10)
Считаю это идентичной производительностью. Обе реализации запускают Lua-интерпретатор и используют стандартный аллокатор (из glibc).
Можем сравнить и FFI-менеджер памяти:
name ext time/op ffi time/op delta
LuaExtension::Eval 3.45ms ± 1% 3.93ms ± 0% +13.81% (p=0.000 n=10+10)
Поддержка light userdata
Ранее мы обошли тип light userdata стороной. Реализовать его поддержку довольно сложно, но он может быть полезен. Предположим, у нас есть большой массив данных. Если нам потребуется перемещать его из PHP в Lua и обратно, то это будет копирование большого количества данных при каждом таком преобразовании. Light userdata позволяет нам написать представление, которое будет использоваться в обоих языках без неявной конвертации. Так мы полностью избегаем копирования.
Здесь нужно понимать, что массив будет храниться в виде C-данных, а не как PHP-массив. Это значит, что как минимум один раз придётся перекопировать данные при создании C-массива из нашего PHP-массива.
Для начала опишем наш вспомогательный класс для userdata:
class UserData {
/** @var ffi_scope */
public static $lib;
public static function init() {
self::$lib = FFI::cdef('
#define FFI_SCOPE "lua_userdata"
struct ContextData {
int important_data[100];
};
');
}
/** @return ffi_cdata */
public static function newContextData($important_data) {
$ctx = self::$lib->new('struct ContextData');
for ($i = 0; $i < count($ctx->important_data); $i++) {
ffi_array_set($ctx->important_data, $i, $i * 2);
}
return $ctx;
}
}
Теперь нужно доработать lua2php
, чтобы обрабатывался новый тип:
case self::TLIGHTUSERDATA:
$void_ptr = self::$lib->lua_touserdata(self::$state, $index);
return ffi_cast_ptr2addr($void_ptr);
lua2php
возвращает mixed
. В KPHP нельзя просто так совместить тип экземпляра класса и mixed
, поэтому mixed|CData
нам не подходит. Так что полученный указатель void*
мы превращаем в числовое значение типа int
, которое будет хранить адрес. Его потом можно будет использовать для восстановления указателя CData
.
KLua::registerFunction2('ctx_get', function ($ctx_addr, $index) {
// userdata-аргументы передаются как PHP int.
// Нам нужно получить указатель по этому адресу,
// для этого мы используем ffi_cast_addr2ptr.
$ptr = ffi_cast_addr2ptr((int)$vec_addr);
// Так как $ptr - это void*, нам нужно выполнить
// ещё один cast перед тем, как использовать этот указатель.
$ctx = UserData::$lib->cast('struct ContextData*', $ptr);
return ffi_array_get($ctx->important_data, $index);
});
Самый простой способ передать в Lua значение userdata — через глобальную переменную.
/**
* @param string $var_name
* @param ffi_cdata
*/
public static function setVarUserData($var_name, $ptr) {
self::$lib->lua_pushlightuserdata(self::$state, $ptr);
self::$lib->lua_setglobal(self::$state, $var_name);
}
UserData::init();
$ctx = UserData::newContextData();
MyLua::setVarUserData('global_ctx', FFI::addr($ctx));
Для Lua значения userdata непрозрачны, поэтому всё, что мы можем сделать с ними, это передавать их в функции, предоставляемые встраиваемым приложением.
MyLua::eval('
print(ctx_get(global_ctx, 0)) -- 0.0
print(ctx_get(global_ctx, 1)) -- 2.0
print(ctx_get(global_ctx, 2)) -- 4.0
');
Преобразование между Lua и PHP теперь практически бесплатное, никаких копирований массивов.
Как вы могли заметить, я использовал странные функции типа ffi_array_set
и ffi_cast_addr2ptr
. Они встроены в KPHP и реализуют некоторые вариации FFI-операций; в PHP они доступны через kphp-polyfills.
KLua
Пакет quasilyte/klua реализует всё то, о чём мы говорили выше, и даже больше. Работает как для PHP, так и для KPHP. Теперь содержимое библиотеки и её API не должны показаться вам чем-то необычным. Установить этот пакет можно через composer:
$ composer require quasilyte/klua
11
KLua тестировалась с Lua версий 5.2, 5.3 и 5.4.
Примеры использования библиотеки KLua:
- simple.php — базовый hello world.
- phpfunc.php — пример