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:
<?php // Для PHP выполняем load в preload-контексте. FFI::load(__DIR__ . '/lua.h');
main.php:
<?php require_once __DIR__ . '/vendor/autoload.php'; if (KPHP_COMPILER_VERSION) { // Для KPHP выполняем load в начале скрипта. FFI::load(__DIR__ . '/lua.h'); } // Чтобы было лаконичнее, не проверяем статусы операций ниже и не // обрабатываем ошибки. $lib = FFI::scope('lua'); $state = $lib->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<lua> */ public static $lib; /** @var ffi_cdata<lua, struct lua_State*> */ 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-функции для размещения этих данных в стеке:
| PHP-тип | Lua C API |
|---|---|
| null | lua_pushnil |
| bool | lua_pushboolean |
| int, float | lua_pushnumber |
| string | lua_pushlstring |
| array | lua_createtable, lua_rawset, lua_rawseti |
В наш заголовочный файл 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:
| Название модуля | Функция-загрузчик |
|---|---|
base (заполняет _G) |
luaopen_base |
package |
luaopen_package |
coroutine |
luaopen_coroutine |
table |
luaopen_table |
io |
luaopen_io |
os |
luaopen_os |
string |
luaopen_string |
math |
luaopen_math |
utf8 |
luaopen_utf8 |
debug |
luaopen_debug |
Уже известный нам 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:
<?php class BenchmarkMyLua { public function __construct() { if (KPHP_COMPILER_VERSION) { FFI::load(__DIR__ . '/lua.h'); } MyLua::init(); MyLua::registerFunction2('phpconcat', function ($x, $y) { return $x . $y; }); MyLua::registerFunction2('phpmin', function ($x, $y) { return min($x, $y); }); } public function benchmarkCall2PHPMin() { return MyLua::call('phpmin', 1, 2); } public function benchmarkCall2PHPConcat() { return MyLua::call('phpconcat', 'a', 'b'); } public function benchmarkEvalPHPConcat() { return MyLua::eval('return phpconcat("a", "b")'); } }
Эти бенчмарки можно запускать в нескольких режимах. Рассмотрим самые интересные.
Запуск через 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-функциях. Мы можем измерить затраты на вызов с помощью бенчмарка.
<?php class BenchmarkFFI { /** @var ffi_scope<lua> */ private $lib; /** @var ffi_cdata<lua, struct lua_State*> */ 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<lua_userdata> */ 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<lua_userdata, struct ContextData> */ 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<C, void*> */ 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
<?php require_once __DIR__ . '/vendor/autoload.php'; use KLua\KLua; use KLua\KLuaConfig; if (KPHP_COMPILER_VERSION) { KLua::loadFFI(); } KLua::init(new KLuaConfig()); KLua::eval(' function example(x) return x + 1 end '); var_dump(KLua::call('example', 10)); // => 11
KLua тестировалась с Lua версий 5.2, 5.3 и 5.4.
Примеры использования библиотеки KLua:
- simple.php — базовый hello world.
- phpfunc.php — пример с
registerFunction. - override_print.php — переопределение
printв Lua. - limited_stdlib.php — ограничение подгружаемой стандартной библиотеки.
- plugin_sandbox.php — загрузка плагинов с изоляцией.
- phpfunc_table.php — как обернуть PHP-функции в таблицу.
- userdata.php — примеры использования light userdata.
- memory_limit.php — как ограничивать память, доступную Lua-скриптам.
- time_limit.php — как ограничить время исполнения запускаемых Lua-скриптов.
Полезные источники
- KLua — FFI-библиотека для liblua5.
- t.me/kphp_chat — чатик open-source сообщества KPHP.
- awesome-kphp
- Статья «Используем SQLite в KPHP и PHP через FFI».
- Статья «Создаём игру на KPHP с помощью FFI и SDL».
- Статья «Заметки KPHP: тестирование и бенчмарки».
- Доклад про KPHP FFI.
- Официальная документация KPHP FFI.
ссылка на оригинал статьи https://habr.com/ru/company/vk/blog/681400/
Добавить комментарий