Tarantool – это in-memory СУБД с открытым исходным кодом, разрабатываемая VK Tech. Существует два способа разработки приложений для Tarantool. Как и к большинству СУБД, к Tarantool можно подключаться из внешнего приложения по TCP/IP. С этой целью для многих популярных языков программирования (включая Go, Python, C#, С++, Java и др.) разработаны соответствующие коннекторы. Это – первый способ.

Кроме этого, Tarantool обладает замечательной особенностью: он позволяет запускать бизнес-логику на встроенном сервере приложений. В этом случае пользовательский код исполняется в одном адресном пространстве с данными, что обеспечивает высокое быстродействие. Это – второй способ.

Если мы ведем разработку для встроенного сервера приложени�� Tarantool, то выбор языков программирования более ограничен. Основным языком программирования, в этом случае, является Lua. Также, часть логики может быть реализована на C/C++. Но набор доступных языков расширяется. Например, с некоторого времени, поддерживается Rust. Помимо этого, Tarantool может выполнять Wasm-приложения. Скоро код библиотеки для разработки на Wasm будет открыт.

Tarantool – высокопроизводительная СУБД часто используемая для построения highload-систем. Какой из вариантов разработки позволяет достичь максимального быстродействия (которое в таких системах будет не лишним)? В настоящей статье я хотел бы рассмотреть возможные способы вызова C-кода из Lua/LuaJIT/Tarantool (в режиме сервера приложений) и то, как это может ускорить и без того быструю СУБД.

Итак, основным средством разработки для Tarantool является Lua. А именно, его реализация, поддерживающая возможность динамической компиляции – LuaJIT. Среди основных преимуществ Lua можно выделить следующие:

  • простота изучения (человеку с опытом разработки достаточно 15 минут (!), чтобы начать программировать);

  • высокая скорость разработки приложений (динамическая типизация, «утиная» типизация, функции первого класса, высокоуровневые библиотеки для решения большинства основных задач);

  • высокая производительность: Lua быстр сам по себе, а использование LuaJIT делает его недосягаемым для других скриптовых языков;

  • простота взаимодействия с кодом, написанным на C.

Дополнительные аргументы в пользу Lua рассмотрены здесь: Why choose Lua?

Lua покрывает большинство потребностей разработчика и, если вы разрабатываете приложение для Tarantool с нуля, то, чаще всего, Lua – оптимальный выбор. Lua хорош и быстр. Но что, если хочется еще быстрее?

Каким бы быстрым ни был динамический язык программирования, даже при наличии JIT ему зачастую сложно сравниться со статически-типизированным языком, обладающим полноценным AOT-компилятором. Особенно когда речь идет о C.

Очевидно, что общая производительность системы зависит не только от языка программирования. Большое влияние оказывает выбранная архитектура, а также применяемые структуры данных и алгоритмы. Говоря об оптимизации за счет разработки на C, будем исходить из предположения, что приложение спроектировано правильно, код написан оптимально, произведено его профилирование и установлено, что та или иная его часть, реализованная на Lua, является узким местом.

Заметим, что при желании, можно, также, вести разработку на C++. Чтобы вызвать код на C++ из сервера приложений Tarantool, достаточно сделать для него обертку на C. Кроме производительности, есть как минимум еще одна причина, по которой вы можете захотеть использовать логику написанную на С/C++ в сервере приложений Tarantool – наличие готовой библиотеки, реализующей необходимую функциональность.

Как обратиться к коду на C из сервера приложений Tarantool? Есть три основных способа:

  • Lua C API;

  • LuaJIT FFI;

  • хранимые процедуры/пользовательские функции.

Рис. 1. Варианты взаимодействия с C из сервера приложений Tarantool
Рис. 1. Варианты взаимодействия с C из сервера приложений Tarantool

Ниже мы рассмотрим эти способы подробнее.

Подготовка к запуску примеров

Независимо от того, какой способ вы выберите, для того, чтобы из кода на C работать с данными, хранящимися в базе, вам потребуется Tarantool C API.

Для запуска приведенных ниже примеров в системе должны быть установлен компилятор C/C++ (в примерах я использую GCC, но подойдет и любой другой) и Tarantool. Кроме этого, понадобятся Make и CMake. Если Tarantool установлен не из исходных кодов, то необходимо, также, установить пакеты разработчика.

Для Ubuntu пакеты разработчика Tarantool устанавливаются так:

sudo apt install tarantool-dev

Для RedHat-совместимых дистрибутивов:

sudo dnf install tarantool-devel

Реализации на LuaJIT можно сравнить и со стандартным Lua. Для этого потребуется установить интерпретатор Lua и пакеты разработчика для него.

В Ubuntu это можно сделать так:

sudo apt install lua5.3
sudo apt install liblua5.3-dev

В RedHat-совместимых дистрибутивах:

sudo dnf install lua
sudo dnf config-manager --set-enabled powertools
sudo dnf install lua-devel

Для примера вызова C-функции из SQL потребуется библиотека MsgPuck:

git clone https://github.com/tarantool/msgpuck.git
cd msgpuck
mkdir build && cd build
cmake -DCMAKE_BUILD_TYPE=Release ..
make
sudo make install

Полный код примеров, рассмотренных в статье, можно найти на github:

git clone https://github.com/msiomkin/tnt-c-bench.git

Компиляция примеров с использованием GCC:

cd tnt-c-bench
make

Для LuaJIT-версий внутри Makefile выполняются примерно следующее:

gcc -O3 \
    -shared -o lib_name.so \
    -fPIC \
    -I/usr/include/tarantool \
    -I/usr/local/include/tarantool
    src_file_name.c

Тестирование с замерами времени производилось на сервере следующей конфигурации:

  • 16 CPU Intel Xeon Ice Lake 2 GHz (virtual cores)

  • 64 Gb RAM

  • High-IOPS SSD (10 000 IOPS read, 5 000 IOPS write)

Все тесты запускались многократно, указанные ниже значения времени – усредненные. Замеры затраченного (процессорного) времени выполнялись с помощью стандартной функции times.

Примеры на Lua запускались на LuaJIT с включенным JIT-компилятором (jit.on()), с отключенным (jit.off() jit.flush()), а также на стандартном интерпретаторе Lua версии 5.3.4.

Lua C API

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

Рассмотрим как регистрировать C-функции для последующего их вызова из Lua на примере задачи подсчета строк в файле. Сначала реализуем функцию на Lua.

Для работы с файлами в Tarantool существует собственный (неблокирующий) модуль fio, рекомендованный к использованию. Для примера мы будем использовать стандартный модуль Lua io, т.к. он позволяет нам написать код более похожий на тот, который будет в следующем примере на C. Функция подсчета строк будет выглядеть следующим образом:

local function count_lines(file_name)
    local buf_size = 65536
    local f = io.input(file_name)

    local counter = 0
    while true do
        local buf = f:read(buf_size)
        if buf == nil then
            break
        end

        local i = 0
        while true do
            i = buf:find("\n", i + 1)
            if i == nil then
                break
            end

            counter = counter + 1
        end
    end

    f:close()

    return counter
end

Полный код примера находится в файле lines_luaj.lua.

Сгенерировать файл с большим количеством строк можно с помощью random_csv_luaс.lua.

Генерируем файл для теста, содержащий 10 млн строк (около 1,5 Гб):

tarantool random_csv_luac.lua 10000000

Запускаем пример:

tarantool lines_luaj.lua

Таким способом сгенерированный CSV-файл обрабатывается за 0,7-0,8 с.

Реализуем аналогичную функциональность на C.

#include <stdio.h>
#include <string.h>

static int count_lines(const char* filename)
{
    FILE* file = fopen(filename, "r");

    if (file == NULL)
        return -1;

    #define BUF_SIZE 65536
    char buf[BUF_SIZE];
    int counter = 0;
    for(;;)
    {
        ssize_t n = fread(buf, 1, BUF_SIZE, file);
        if (ferror(file)) {
            counter = -1;
            break;
        }

        char* p = buf;
        while (n > 0) {
            char* q = (char*)memchr(p, '\n', n);
            if (q == 0)
                break;
            counter++;
            q++;
            n -= (ssize_t)(q - p);
            p = q;
        }

        if (feof(file))
            break;
    }

    fclose(file);

    return counter;
}

Чтобы вызвать функцию используя Lua C API, нам потребуется сделать обертку. Добавляем следующие заголовочные файлы:

#include <lua.h>
#include <lauxlib.h>

Lua C API использует стек для передачи параметров и получение результата. Функция-обертка выглядит следующим образом:

static int count_lines_L(lua_State* L)
{
    const char* filename = lua_tostring(L, 1); /* читаем первый аргумент */
    int n = count_lines(filename); /* подсчитываем количество строк */
    lua_pushnumber(L, n); /* возвращаем количество строк */

    return 1; /* один результат (возможен multireturn) */
}

Регистрируем функцию, как доступную для вызова через Lua C API:

/* массив функций для библиотеки */
static const struct luaL_Reg mylib[] = {
    /* в Lua функция будет называться count_lines */
    { "count_lines", count_lines_L },
    /* метка окончания массива регистрируемых функций */
    { NULL, NULL }
};

/* точка входа в Lua C библиотеку */
LUA_API int luaopen_lineslc(lua_State *L) {
    /* регистрируем функции библиотеки */
    luaL_register(L, "lineslc", mylib);
    return 1;
}

Полный код реализации на C будет выглядеть так: lines_luac.c.

Вызвать функцию из Tarantool-приложения довольно просто:

local mylib = require("lineslc")
local n = mylib.count_lines("test.csv")

Полный код примера вызова реализации на C из Lua находится в файле lines_luac.lua.

Запускаем пример:

./lines_luac.lua

Сравним, также, c другими версиями - c отключенным JIT и на стандартном Lua:

tarantool lines_luaj_off.lua
lua lines_luav.lua

Замеры времени на тестовом стенде:

Рис. 2. Подсчет количества строк в файле
Рис. 2. Подсчет количества строк в файле

Обработка на C происходит примерно в два раза быстрее, чем в первом примере (см. первые две строки на рис. 2). Последние два варианта еще медленнее.

Если посмотреть реализацию модуля io, то мы увидим, что он реализован на C (в частности, в примере выше f:read вызовет io_file_readlen, которая, в свою очередь, вызовет fread). Таким образом, мы сравниваем большое количество вызовов C-функций из Lua в цикле и один вызов C-функции с циклом внутри. Понятно, что в первом случае будет больше накладных расходов. Справедливо ли такое сравнение? Скорее – да. Так устроен Lua: большинство стандартных библиотек LuaJIT (и Lua) написаны на C. Поэтому каждый раз, когда мы просим сделать LuaJIT/Lua что-либо «полезное» мы, скорее всего, вызываем C-код.

Но что, если все-таки хочется сделать сравнительный бенчмарк C-кода и чистого Lua-кода без (C-вызовов)? Тогда можно реализовать, например, быструю сортировку.

Реализация на Lua:

local function partition(arr, left, right)
    local pivot_value = arr[right]
    local pivot = left - 1

    for i = left, right - 1 do
        if arr[i] < pivot_value then
            pivot = pivot + 1
            arr[i], arr[pivot] = arr[pivot], arr[i]
        end
    end

    pivot = pivot + 1
    arr[pivot], arr[right] = arr[right], arr[pivot]

    return pivot
end

local function quick_sort(arr, left, right)
    if left > 0 and left < right then
        local pivot = partition(arr, left, right)
        quick_sort(arr, left, pivot - 1)
        quick_sort(arr, pivot + 1, right)
    end
end

Проверяем байткод и видим, что в нем нет никаких вызовов функций, кроме наших собственных (т.е. C-вызовы не используются):

Proto #0

0000	FUNCV    2 
0001	FNEW     0   0      ; <main.lua>:1
0002	FNEW     1   1      ; <main.lua>:18
0003	UCLO     0 => 0004
0004	RET0     0   1

Proto #1

0000	FUNCF   11 
0001	TGETV    3   0   2
0002	SUBVN    4   1   0  ; 1
0003	MOV      5   1
0004	SUBVN    6   2   0  ; 1
0005	KSHORT   7   1
0006	FORI     5 => 0016
0007	TGETV    9   0   8
0008	ISGE     9   3
0009	JMP      9 => 0015
0010	ADDVN    4   4   0  ; 1
0011	TGETV    9   0   4
0012	TGETV   10   0   8
0013	TSETV   10   0   4
0014	TSETV    9   0   8
0015	FORL     5 => 0007
0016	ADDVN    4   4   0  ; 1
0017	TGETV    5   0   2
0018	TGETV    6   0   4
0019	TSETV    6   0   2
0020	TSETV    5   0   4
0021	RET1     4   2

Proto #2

0000	FUNCF    9 
0001	KSHORT   3   0
0002	ISGE     3   1
0003	JMP      3 => 0021
0004	ISGE     1   2
0005	JMP      3 => 0021
0006	UGET     3   0      ; partition
0007	MOV      5   0
0008	MOV      6   1
0009	MOV      7   2
0010	CALL     3   2   4
0011	UGET     4   1      ; quick_sort
0012	MOV      6   0
0013	MOV      7   1
0014	SUBVN    8   3   0  ; 1
0015	CALL     4   1   4
0016	UGET     4   1      ; quick_sort
0017	MOV      6   0
0018	ADDVN    7   3   0  ; 1
0019	MOV      8   2
0020	CALL     4   1   4
0021	RET0     0   1

Полный код примера: qsort_luaj.lua.

Реализация на C:

#define SWAP_INT(a, b) \
    { \
        int tmp = a; \
        a = b; \
        b = tmp; \
    }

static int partition(int* arr, int left, int right)
{
    int pivot_value = arr[right];
    int pivot_pos = left - 1;

    for (int i = left; i < right; i++)
        if (arr[i] < pivot_value) {
            pivot_pos++;
            SWAP_INT(arr[i], arr[pivot_pos]);
        }

    pivot_pos++;
    SWAP_INT(arr[right], arr[pivot_pos]);

    return pivot_pos;
}

static void quick_sort(int* arr, int left, int right)
{
    if (left < right) {
        int pivot_pos = partition(arr, left, right);
        quick_sort(arr, left, pivot_pos - 1);
        quick_sort(arr, pivot_pos + 1, right);
    }
}

Полный код реализации быстрой сортировки на C: qsort_luac.c. Вызов из Lua: qsort_luac.lua.

Примеры можно запускать так:

tarantool qsort_luaj.lua 10000000     # LuaJIT
tarantool qsort_luac.lua 10000000     # Lua C API
tarantool qsort_luaj_off.lua 10000000 # LuaJIT с отключенным JIT
lua qsort_luav.lua 10000000           # стандартный интерпретатор Lua

tarantool qsort_lua.lua 50000000      # LuaJIT
tarantool qsort_luac.lua 50000000     # Lua C API
tarantool qsort_lua.lua 50000000      # LuaJIT с отключенным JIT
lua qsort_luac.lua 50000000           # стандартный интерпретатор Lua

Замеры времени выполнения на тестовом стенде:

Рис. 3. Быстрая сортировка, 10 млн. элементов
Рис. 3. Быстрая сортировка, 10 млн. элементов
Рис. 4. Быстрая сортировка, 50 млн. элементов
Рис. 4. Быстрая сортировка, 50 млн. элементов

Все равно догнать реализацию на C не получается – вновь получаем разницу с LuaJIT примерно в два раза (см. первые две строки на рис. 3 и рис. 4).

Тем не менее LuaJIT очень хорош: исполнение кода стандартным интерпретатором Lua безнадежно отстает по времени (см. последнюю строку на рис. 3 и рис. 4).

В качестве примера библиотеки из реальной жизни, использующей Lua C API, можно обратиться к Tarantool SMTP client.

Плюсы Lua C:

  1. Механизм интегрирован в стандартный Lua, описан в официальной документации и является наиболее универсальным. При желании библиотеку можно будет использовать не только в Tarantool, но и из любого Lua-приложения.

  2. После добавления Lua C API обертки для функции на стороне C функцию можно «прозрачно» вызывать из программы на Lua. Дополнительная обертка на стороне Lua не требуется. Это позволяет в последующем использовать библиотеку Lua-разработчику не обладающему опытом разработки на C.

  3. При необходимости внутри C-функции можно вызвать код на Lua (см. lua_pcall).

  4. Поддерживается возможность возврата нескольких результатов из функции (multireturn), что привычно для Lua-разработчиков.

Минусы Lua C:

  1. В случае работы с готовой C-библиотекой ее придется доработать – добавить ко всем используемым функциям Lua C API обертки;

  2. Для передачи параметров функций используется Lua-стек – не самый быстрый способ передачи параметров.

LuaJIT FFI

LuaJIT FFI – интегрированный в LuaJIT механизм вызова внешних С-функций и использования C-структур данных из кода на Lua. LuaJIT FFI извлекает информацию для взаимодействия с библиотекой из стандартных C-объявлений. Поэтому нет необходимости дорабатывать саму библиотеку. Достаточно скопировать в Lua-скрипт определения нужных функций и типов.

Для доступа к C-структурам LuaJIT может генерировать код аналогичный тому, который выдает компилятор C, а передача параметров функций при работе через FFI может быть выполнена через C-стек или регистры процессора по ABI (Lua-стек не используется). Это должно положительно сказаться на быстродействии. Рассмотрим пример вызова C-кода через FFI и сравним время выполнения с версией на Lua C API.

Вызовем C-реализации подсчета строк в файле и быстрой сортировки через FFI. На этот раз код на C не должен содержать никаких оберток. Достаточно только реализации самой вызываемой функции: см. lines_ffi.c и qsort_ffi.c. Напротив, вызовы из Lua теперь будет несколько сложнее. Рассмотрим вызов через FFI на примере подсчета строк в файле.

Нам потребуется модуль ffi:

local ffi = require("ffi")

Добавляем в Lua-код C-определение функции:

ffi.cdef([[
    int count_lines(const char* filename);
]])

Подгружаем C-библиотеку с реализацией функции быстрой сортировки:

local mylib = ffi.load("./linesf.so")

Вызываем подсчет строк через FFI:

local n = mylib.count_lines("test.csv")

Полный код примера: lines_ffi.lua.

Аналогичным образом можно вызвать и C-реализацию быстрой сортировки. Полный код примера: qsort_ffi.lua.

Запуск примеров:

tarantool lines_ffi.lua
tarantool qsort_ffi.lua 10000000
tarantool qsort_ffi.lua 50000000

Замеры показывают такое же время, как и в примерах с Lua C API (см. рис. 2 – 4). Это ожидаемо, т.к. мы производим однократный вызов одних и тех же C-функций. Потенциальный выигрыш от использования FFI в этом случае лежит в пределах погрешности измерений.

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

Для вызова c использованием Lua C API нам вновь потребуется обертка – isalpha_luac.c.

Для вызова через FFI обертки не потребуется. А т.к. isalpha – стандартная функция, то и никакой собственной библиотеки реализовывать также не надо. Все делается на стороне LuaJIT:

local ffi = require("ffi")

ffi.cdef([[
    int isalpha(int ch);
]])

local C = ffi.C

local counter = 0
local n = 100 * 1000 * 1000
for i = 1, n do
    local c = i % 100
    if C.isalpha(c) ~= 0 then
        counter = counter + 1
    end
end

Полный код вызовов с использованием Lua C API и FFI: isalpa_luac.lua и isalpa_ffi.lua соответственно.

Запуск примеров:

tarantool isalpha_ffi.lua       # FFI, LuaJIT
tarantool isalpha_luac.lua      # Lua C API, LuaJIT
tarantool isalpha_luac_joff.lua # Lua C API, LuaJIT с отключенным JIT
lua isalpha_luav.lua            # Lua C API, Lua
tarantool isalpha_ffi_joff.lua  # FFI, LuaJIT с отключенным JIT

Замеры времени выполнения на тестовом стенде:

Рис. 5. Многократный вызов стандартной C-функции isalpha
Рис. 5. Многократный вызов стандартной C-функции isalpha

И вот тут уже разница очень заметна. Трассы в LuaJIT позволяют делать эффективные вызовы – разница с Lua C API почти на порядок (см. две первые строки на рис. 5).

Если посмотреть результирующий код вызова через FFI, то мы увидим, что JIT использовал регистры CPU для передачи параметров (отсюда и максимальная скорость):

...
mov     esi, 0x64
mov     edi, ebp
call    0x7f0941d5bd80    ; modulus operator
mov     edi, eax
call    0x7f0941de93b7    ; isalpha
test    eax, eax
jnz     0x7f7750c80020
...

Сравните с кодом, который генерирует gcc для версии на C++:

#include <ctype.h>

int main() {
    int counter = 0;
    for (int i = 0; i < 100000000; i++) {
        int c = i % 100;
        if (isalpha(c))
            counter++;
    }

    return 0;
}
...     ; modulus operator logic is inlined
mov     eax, DWORD PTR [rbp-12]
mov     edi, eax
call    isalpha
test    eax, eax
je      .L3
...

Предпоследние три строки, относящиеся непосредственно к вызову isalpha и получению результата совпадают.

На рис. 5 также можно увидеть и другие интересные факты. Для вызовов через Lua C API выбор режима jit.on/jit.off не критичен – время почти одинаковое (см. вторую и третью строки). А вот вызов через FFI без JIT работает крайне медленно (см. последнюю строку): инетпретатору приходится конвертировать аргументы в объекты cdata, затем значения из cdata помещать в регистры или C-стек. Для возвращения результата необходимо делать обратные преобразования. Также, объекты cdata должны обрабатываться GC. Все это требует дополнительного времени.

Здесь можно посмотреть примеры библиотек, использующих LuaJIT FFI: cron-parser, sqlparser.

Плюсы LuaJIT FFI:

  1. Объем предварительной работы значительно сокращается: скопировать нужные определения из заголовочных файлов на C проще, чем писать Lua C API обертку для каждой функции.

  2. Нет необходимости дорабатывать библиотеку на C.

  3. Можно работать с библиотекой, даже если она заранее скомпилирована и нет доступа к исходникам. Достаточно взять описание функций из документации или заголовочных файлов.

  4. Передача параметров функций может осуществляться по ABI (через регистры или C-стек), что является наиболее эффективным способом.

Минусы LuaJIT FFI:

  1. Этот механизм интегрирован в LuaJIT, из стандартного Lua его можно использовать только с помощью сторонних библиотек.

  2. Возрастает ответственность: разработчик на Lua теперь обязан уметь программировать и на C – неосторожное действие в Lua коде может вызвать ошибку сегментации (англ. segmentation fault). См. пример ниже.

  3. FFI-вызовы из интерпретатора (с отключенным JIT или если вызов не на трассе) работают крайне медленно.

  4. Внутри C-функции нельзя вызывать код на Lua.

  5. Multireturn не поддерживается – из функции нельзя вернуть несколько результатов.

Пример неосторожного обращения с указателями при использовании FFI:

local ffi = require("ffi")
ffi.cdef[[
    size_t strlen(const char *s);
]]
len = ffi.C.strlen(str) -- пара пара пам...
NULL dereference
NULL dereference

Хранимые процедуры / пользовательские функции

Как и многие СУБД, Tarantool позволяет использовать хранимые процедуры (англ. stored procedures). Хранимые процедуры создаются с помощью функции box.schema.func.create. После этого их можно вызывать как из встроенного сервера приложений так и из внешнего приложения.

Хранимые процедуры могут быть реализованы на Lua и C. При вызове хранимых процедур на C из встроенного сервера приложений используется механизм Lua C API, рассмотренный выше. Поэтому дополнительные замеры времени для этого варианта приводить не будем.

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

Функции созданные через box.schema.func.create можно вызывать из SQL-запросов. В этом случае, их более правильно называть «пользовательские функции» (англ. user-defined functions). Рассмотрим пример вызова пользовательских функций из SQL-запросов и сравним скорость работы функций, реализованных на C и Lua.

Допустим, мы хотим разработать функцию для SQL, которая подсчитывает сумму цифр целого положительного числа. Начнем с варианта на Lua:

function digit_sum(n)
    local s = 0
    while n > 0 do
        local r = n % 10
        s = s + r
        n = (n - r) / 10
    end

    return s
end

Для того, чтобы Lua-функцию можно было вызвать из SQL ее необходимо создать в БД следующим образом:

box.schema.func.create("DIGIT_SUM", {
    language = "LUA",
    exports = { "SQL" },
    param_list = { "unsigned" },
    body = [[
            function(n)
                local s = 0
                while n > 0 do
                    local r = n % 10
                    s = s + r
                    n = (n - r) / 10
                end

                return s
            end
        ]],
    returns = "unsigned",
    is_deterministic = true
})

Подробнее о указанных во втором параметре опциях можно прочитать здесь.

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

box.schema.func.drop("DIGIT_SUM", { if_exists = true })

Далее необходимо задать права на запуск функции:

box.schema.role.grant("public", "execute", "function", "DIGIT_SUM")

Для проверки работы функции нам потребуется создать space (аналог таблиц в других СУБД), в который можно поместить случайные числа, подаваемые на вход функции:

test_space = box.schema.create_space("test", {
    format = {
        { name = "id", type = "unsigned" },
        { name = "value", type = "unsigned" }
    },
    temporary = true
})

test_space:create_index("primary")

Заполняем space случайными числами:

local max_value = 2 ^ 32
for i = 1, n do
    local value = math.random(max_value)
    test_space:insert({ i, value })
end

Теперь все готово к запуску SQL-запроса, использующего созданную нами функцию. Для замера времени возьмем агрегирующий запрос:

SELECT COUNT(*)
FROM "test"
WHERE DIGIT_SUM("value") < 10

Т.к. сканировать space в Tarantool запрещено, чтобы запрос заработал добавим в него условие (фиктивное) по первичному индексу:

SELECT COUNT(*)
FROM "test"
WHERE "id" > 0 AND DIGIT_SUM("value") < 10

Для запуска SQL-запроса необходимо вызывать box.execute:

local res, err = box.execute([[
    SELECT COUNT(*)
    FROM "test"
    WHERE "id" > 0 AND DIGIT_SUM("value") < 10
]])

Полный код примера можно найти в файле digsum_lua.lua.

Рассмотрим реализацию на C.

Функция суммирования цифр полностью аналогична версии на Lua:

static uint64_t digit_sum(uint64_t n)
{
    uint64_t s = 0;
    while (n > 0) {
        int r = n % 10;
        s += r;
        n = n / 10;
    }

    return s;
}

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

Нам потребуется заголовочный файл для работы с Tarantool C API:

#include <module.h>

Функция-обертка на C должна иметь следующие типы параметров и результата:

int digit_sum(box_function_ctx_t* ctx, const char* args, const char* args_end)
{
    ...
}

Функция принимает некий контекст (который Tarantool передает в нее автоматически), а также указатели на начало и конец буфера, содержащего передаваемые аргументы. В качестве результата функция возвращает код (0 – в случае успеха).

Буфер, на который указывает второй параметр содержит аргументы вызова в виде MessagePack-массива. Для работы с MessagePack Tarantool использует библиотеку MsgPuck. Заголовочный файл MsgPuck:

#include <msgpuck.h>

Для десериализации аргументов сначала необходимо считать флаг массива и количество его элементов:

mp_decode_array(&args);

Tarantool проверяет количество элементов при вызове функций из SQL, поэтому дополнительная проверка не обязательна. Однако, типы аргументов не проверяются, поэтому добавим проверку типа первого (и единственного) аргумента:

enum mp_type value_type = mp_typeof(*args);
if (value_type != MP_UINT) {
    return box_error_raise(-1,
        "Invalid argument type, expecting an unsigned integer");
}

Далее считываем значение аргумента:

uint64_t n = mp_decode_uint(&args);

Вызываем функцию суммирования цифр, которую мы описали выше:

uint64_t s = digit_sum(n);

Результат необходимо снова поместить в MessagePack-массив (который мы потом преобразуем в кортеж). Создаем буфер размером 9 байт (1 байт под длину массива + 8 байт под результат):

char res_buf[1 + sizeof(uint64_t)];
Рис. 6. Формат результата (MessagePack)
Рис. 6. Формат результата (MessagePack)

Указатель на конец буфера:

char* res_end = res_buf;

Записываем размер массива, который равен количеству возвращаемых значений, т.е. единице:

res_end = mp_encode_array(res_end, 1);

Записываем результат:

res_end = mp_encode_uint(res_end, s);

Преобразуем массив в кортеж (англ. tuple – аналог записи в других СУБД):

struct tuple* res = box_tuple_new(box_tuple_format_default(),
    res_buf, res_end);

Помещаем кортеж в контекст и возвращаем результат этой операции в качестве кода результата функции:

return box_return_tuple(ctx, res);

Полный пример: digsum_udf.c.

Код вызова пользовательской C-функции схож с кодом вызова вызова пользовательской Lua-функции, но имеет некоторые отличия. При регистрации функции в БД теперь не нужно указывать тело функции:

box.schema.func.create("mylib.tnt_digit_sum", {
    language = "C",
    exports = { "SQL" },
    param_list = { "unsigned" },
    returns = "unsigned",
    is_deterministic = true
})

Наименование функции в SQL-запросе должно содержать имя библиотеки:

SELECT COUNT(*)
FROM "test"
WHERE "id" > 0 AND "myudf.digit_sum"("value") < 10

В остальном код на Lua не имеет отличий от предыдущего примера. Полный код: digsum_c.lua.

Запуск примеров:

tarantool digsum_lua.lua
tarantool digsum_c.lua

Результаты на 10 млн. строк: 12-13 с. для версии на Lua против 4 с. для версии на C. Разница – в три раза.

Рис. 7. Сумма цифр, SQL-запрос, 10 млн. строк
Рис. 7. Сумма цифр, SQL-запрос, 10 млн. строк

Здесь можно найти примеры использования Tarantool C API для обращения к БД из хранимых процедур / пользовательских функций.

Плюсы использования хранимых процедур / пользовательских функций:

  1. Можно вызывать из SQL-запросов.

  2. Можно вызывать из внешнего приложения.

Минусы использования хранимых процедур / пользовательских функций:

  1. В случае вызова непосредственно из Lua-приложения встроенного сервера используется Lua C API. Такой способ не лишен указанных выше недостатков Lua C API, при этом требует дополнительного кода на стороне Lua (вызовов box.schema.func.create и т.д.).

Заключение

Мы рассмотрели основные способы вызова кода на C из Lua/LuaJIT/Tarantool. Это может быть полезным как в целях оптимизации, так и в целях расширения функциональности приложения с помощью внешних библиотек. Tarantool за счет своих архитектурных особенностей обеспечивает высокую скорость работы приложений, разработанных на его основе. Если же вы хотите достичь экстремальной производительности, вы можете реализовать часть своего приложения на C/C++.