Foreign Function Interface — это перспективная альтернатива для традиционных PHP-расширений.


Сегодня мы будем разбирать FFI-библиотеку для работы с liblua5 из PHP, которая позволит исполнять скрипты на Lua из нашего приложения.



Мотивация


Для PHP уже есть расширение Lua с PECL. Тем не менее смысл в нашей задумке есть:


  1. Через FFI есть доступ к полному Lua API, поэтому у нас больше свободы.
  2. FFI-библиотеки не используют Zend API, им проще пережить мажорный релиз PHP.
  3. Читать PHP-код нам обычно проще, чем C в связке с внутренностями PHP.
  4. Легче распространять результат как composer пакет, ведь это обычный PHP-код.

Ещё одним бонусом FFI-библиотек является то, что они будут успешно запускаться и на KPHP, если мы правильно расставим типы через phpdoc.


Сейчас один из главных недостатков этого подхода — не очень высокая производительность. И хотя в KPHP использование FFI несёт минимальные накладные расходы, в PHP всё гораздо сложнее. Автор JIT-компиляции в ядре PHP, Дмитрий Стогов, планирует в отдалённом будущем «подружить» JIT и FFI, значительно увеличив производительность этого механизма.


В качестве занимательного факта: Дмитрий, помимо прочего, ещё и автор FFI для PHP.

Но зачем именно liblua? Есть две основные причины:


  1. Это уникальный и довольно сложный пример для FFI. Полезен в образовательных целях.
  2. В компилируемом 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.


Процесс вызова будет выглядеть примерно так:


  1. Кладём Lua-функцию на стек.
  2. Перемещаем все PHP-аргументы в стек через php2lua.
  3. Вызываем Lua-функцию через lua_pcallk.
  4. Результаты функции забираем со стека через 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. Обёртка будет делать следующее:


  1. Забирать со стека нужное количество аргументов через lua2php.
  2. Вызывать зарегистрированную PHP-функцию.
  3. Преобразовывать возвращённое значение через 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".


  1. Сначала преобразовали PHP-строки в Lua-строки через php2lua.
  2. Затем аргументы phpconcat из Lua-строк превратились в PHP-строки.
  3. Функция phpconcat приняла PHP-строку и вернула PHP-строку.
  4. Наша обёртка преобразовала результат из PHP-строки в Lua-строку.
  5. И в самом конце 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-скриптов.

Полезные источники


Комментарии (11)


  1. youROCK
    23.08.2022 18:54
    +1

    Ждем кросс-JIT между PHP FFI JIT и LuaJIT с инлайнингом и прочим.


  1. ZimM
    24.08.2022 00:04
    +6

    Мы встроили скриптовый язык в ваш скриптовый язык, чтобы вы могли писать скрипты, пока пишете скрипты


  1. shushu
    24.08.2022 02:37

    В плане общего развития - конечно интересно. Но ведь есть уже модуль для Lua: https://www.php.net/lua

    Я что-то упускаю?


    1. kirik
      24.08.2022 07:28
      +3

      Автор указал это в самом начале статьи в разделе "Мотивация".


    1. shushu
      25.08.2022 08:13
      +1

      Мда, спать больше надо!) Спасибо! Виноват, исправлюсь!


  1. koreychenko
    24.08.2022 10:48

    Всю статью я ждал ответ на вопрос "а нафига" и так и не нашел его.
    Можете привести примеры зачем понадобилось внедрять LUA в PHP в реальной жизни?
    У вас два скриптовых языка, в чем LUA лучше чем PHP, что требуется часть кода писать на нём?


    1. quasilyte Автор
      24.08.2022 11:00
      +3

      В разделе "мотивация" старался описать. Вот:

      В компилируемом KPHP полезно иметь возможность использовать динамические плагины.

      Скриптовый язык - термин не точный, а скорее жаргон. Язык редко бывает интерпретируемым или компилируемым, это свойство реализации. PHP чаще всего используется с интерпретатором, а KPHP имеет только компилируемую реализацию. На KPHP можно писать десктопные приложения, игры и прочие серьёзные штучки (на PHP тоже можно, но коробочный дистрибутив в виде одного почти полностью статически слинкованного бинарника распространять там гораздо сложнее).

      FFI Lua работает и там, и там. Если вы работали с компилируемыми языками, то понимаете, что там не так просто обеспечить расширяемость. Это или dlopen на отдельно собранные либы, либо встраивание какого-то интерпретатора. Вот FFI в PHP/KPHP - это как раз dlopen. И через него мы встраиваем Lua.

      Так можно собрать KPHP приложение в бинарь, отдать заказчику как чёрную коробку, а расширяемость обеспечить через Lua скрипты. Так заказчику не нужны исходники приложения. Всё, что нужно - положить в правильное место скрипты.

      Вообще примеров, где нативные приложения используют встроенные интерпретаторы много. Это почти все игровые движки, сервера приложений типа Nginx, софт для работы с изображениями и полно чего ещё.

      Зачем же тогда нам Lua в PHP? Чаще всего, приложения на KPHP приятно тестировать на PHP. А ещё это оставляет нам простор для того, чтобы, в случае чего, было проще переходить туда-сюда, ведь наш код всегда валиден и там, и там.

      Но это всё если отбросить образовательный момент. По FFI мало материалов, особенно таких нетривиальных. Неужели плохо, что их становится больше? Здесь и примеры как самому сделать, и готовая библиотека для подглядывания. :)

      Вообще статья и так вышла довольно крупной и сложной, поэтому мне не хотелось дополнительно добавлять много букв об этой детали. 1-го предложения достаточно для тех, кто когда-нибудь пробовал делать нативные приложения расширяемыми. А если этого опыта нет, то это уже почти тема для отдельной статьи, ведь там больше одного подхода.


    1. quasilyte Автор
      24.08.2022 11:15
      +2

      Есть ещё одна, менее очевидная причина.
      Я общался с автором FFI в PHP, мы обсуждали проблемы, дизайн, производительность.
      Желание такое: лучше понять применимость этой фичи, увеличить её распространение, а потом улучшить производительность за счёт JIT и некоторых других трюков.

      Если мы не будем говорить о FFI и делать на нём библиотеки, он всегда будет таким, какой есть.


      1. riky
        25.08.2022 13:47
        +1

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


    1. isden
      24.08.2022 19:03
      +1

      Всю статью я ждал ответ на вопрос "а нафига" и так и не нашел его.

      А я просто прочитал статью с интересом :)


  1. quasilyte Автор
    24.08.2022 18:45

    https://t.me/kphp_chat

    Продублирую и тут ссылочку. В этом чатике собирается сообщество KPHP.
    Разработчики компилятора там тоже есть.