Топ-советы: выявление уникальных концепций и подводных камней в Lua

API7.ai

October 12, 2022

OpenResty (NGINX + Lua)

В предыдущей статье мы изучили библиотечные функции, связанные с таблицами в LuaJIT. В дополнение к этим распространённым функциям, сегодня я познакомлю вас с некоторыми уникальными или менее известными концепциями Lua, а также с распространёнными ошибками в OpenResty.

Слабая таблица (Weak Table)

Первая концепция — это слабая таблица, уникальная для Lua, которая связана с сборкой мусора. Как и в других высокоуровневых языках, в Lua сборка мусора происходит автоматически, вам не нужно заботиться о её реализации или явно вызывать GC. Сборщик мусора автоматически освобождает память, на которую больше нет ссылок.

Однако простого подсчёта ссылок недостаточно, и иногда нам нужен более гибкий механизм. Например, если мы вставим объект Lua Foo (таблицу или функцию) в таблицу tb, это создаст ссылку на этот объект Foo. Даже если больше нет других ссылок на Foo, ссылка на него в tb будет существовать всегда, и GC не сможет освободить память, занимаемую Foo. В этом случае у нас есть только два варианта.

  • Первый — вручную освободить Foo.
  • Второй — оставить его в памяти навсегда.

Например, рассмотрим следующий код.

$ resty -e 'local tb = {} tb[1] = {red} tb[2] = function() print("func") end print(#tb) -- 2 collectgarbage() print(#tb) -- 2 table.remove(tb, 1) print(#tb) -- 1

Однако, я думаю, вы не хотите, чтобы память занималась объектами, которые вы не используете, особенно учитывая, что LuaJIT имеет ограничение в 2 ГБ памяти. Время для ручного освобождения определить непросто, и это добавляет сложности вашему коду.

Тут на помощь приходит слабая таблица. Посмотрите на её название — слабая таблица. Во-первых, это таблица, и все элементы в этой таблице являются слабыми ссылками. Концепция всегда абстрактна, поэтому давайте начнём с немного изменённого кода.

$ resty -e 'local tb = {} tb[1] = {red} tb[2] = function() print("func") end setmetatable(tb, {__mode = "v"}) print(#tb) -- 2 collectgarbage() print(#tb) -- 0 '

Как видите, объекты, которые не используются, освобождаются. Самая важная строка в этом коде — следующая.

setmetatable(tb, {__mode = "v"})

Знакомо? Это же операция с метатаблицей? Да, таблица становится слабой таблицей, когда в её метатаблице есть поле __mode.

  • Если значение __mode равно k, ключи таблицы являются слабыми ссылками.
  • Если значение __mode равно v, значения таблицы являются слабыми ссылками.
  • Конечно, вы также можете установить его в kv, что означает, что и ключи, и значения этой таблицы являются слабыми ссылками.

Любая из этих трёх слабых таблиц будет иметь свои ключи или значения освобождены, как только они будут освобождены.

В примере кода выше значение __mode равно v, tb — это массив, и значения массива — это таблица и функция, которые могут быть автоматически освобождены. Однако, если вы измените значение __mode на k, они не будут освобождены, как видно из следующего кода.

$ resty -e 'local tb = {} tb[1] = {red} tb[2] = function() print("func") end setmetatable(tb, {__mode = "k"}) print(#tb) -- 2 collectgarbage() print(#tb) -- 2 '

Мы продемонстрировали только слабые таблицы, где значение является слабой ссылкой, то есть слабые таблицы типа массива. Естественно, вы также можете создать слабую таблицу типа хэш-таблицы, используя объект в качестве ключа, например, как показано ниже.

$ resty -e 'local tb = {} tb[{color = red}] = "red" local fc = function() print("func") end tb[fc] = "func" fc = nil setmetatable(tb, {__mode = "k"}) for k,v in pairs(tb) do print(v) end collectgarbage() print("----------") for k,v in pairs(tb) do print(v) end '

После ручного вызова collectgarbage() для принудительной сборки мусора все элементы в таблице tb будут освобождены. Конечно, в реальном коде нам не нужно вызывать collectgarbage() вручную, она будет запускаться автоматически в фоновом режиме, и нам не нужно об этом беспокоиться.

Однако, поскольку мы упомянули функцию collectgarbage(), я скажу о ней ещё несколько слов. Эта функция может принимать несколько различных опций и по умолчанию использует collect, что означает полную сборку мусора. Ещё одна полезная опция — count, которая возвращает количество памяти, занимаемой Lua. Эта статистика помогает вам увидеть, есть ли утечка памяти, и напоминает нам не приближаться к верхнему пределу в 2 ГБ.

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

Замыкания и upvalue

Перейдём к замыканиям и upvalue. Как я уже подчеркивал ранее, все значения в Lua являются объектами первого класса, включая функции. Это означает, что функции могут храниться в переменных, передаваться в качестве аргументов и возвращаться как значения другой функции. Например, в примере кода выше, связанном со слабой таблицей, есть следующий фрагмент.

tb[2] = function() print("func") end

Это анонимная функция, которая хранится как значение таблицы.

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

local function foo() print("foo") end local foo = fuction() print("foo") end

Кроме того, Lua поддерживает написание функции внутри другой функции, то есть вложенные функции, как в следующем примере кода.

$ resty -e ' local function foo() local i = 1 local function bar() i = i + 1 print(i) end return bar end local fn = foo() print(fn()) -- 2 '

Вы можете видеть, что функция bar может читать локальную переменную i внутри функции foo и изменять её значение, даже если эта переменная не определена внутри bar. Эта особенность называется лексической областью видимости.

Эти особенности Lua являются основой для замыканий. Замыкание — это просто функция, которая имеет доступ к переменной в лексической области видимости другой функции.

По определению, все функции в Lua фактически являются замыканиями, даже если вы их не вкладываете. Это связано с тем, что компилятор Lua выносит код за пределы скрипта Lua и оборачивает его в другую функцию, например, main. Например, следующие простые строки кода.

local foo, bar local function fn() foo = 1 bar = 2 end

После компиляции это будет выглядеть так.

function main(...) local foo, bar local function fn() foo = 1 bar = 2 end end

И функция fn захватывает две локальные переменные функции main, поэтому она также является замыканием.

Конечно, мы знаем, что концепция замыканий существует во многих языках, и она не уникальна для Lua, поэтому вы можете сравнить и сопоставить, чтобы лучше понять её. Только когда вы поймёте замыкания, вы сможете понять то, что мы собираемся сказать об upvalue.

upvalue — это концепция, уникальная для Lua, которая представляет собой переменную вне лексической области видимости, захваченную в замыкании. Давайте продолжим с кодом выше.

local foo, bar local function fn() foo = 1 bar = 2 end

Вы можете видеть, что функция fn захватывает две локальные переменные, foo и bar, которые не находятся в их собственной лексической области видимости, и эти две переменные фактически являются upvalue функции fn.

Распространённые ошибки

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

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

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

Индексация начинается с 0 или 1?

Первая ошибка заключается в том, что индексация в Lua начинается с 1, как мы уже неоднократно упоминали.

Но я должен сказать, что это не вся правда. Потому что в LuaJIT массивы, созданные с помощью ffi.new, индексируются с 0:

local buf = ffi_new("char[?]", 128)

Поэтому, если вы хотите получить доступ к buf cdata в приведённом выше коде, помните, что индексация начинается с 0, а не с 1. Обязательно обратите на это особое внимание, когда вы используете FFI для взаимодействия с C.

Регулярные выражения

Вторая ошибка связана с регулярными выражениями, и в OpenResty есть два набора методов для сопоставления строк: библиотека строк Lua и API OpenResty ngx.re.*.

Регулярные выражения Lua имеют уникальный формат и записываются иначе, чем PCRE. Вот простой пример.

resty -e 'print(string.match("foo 123 bar", "%d%d%d"))'123

Этот код извлекает числовую часть из строки, и вы заметите, что он полностью отличается от знакомых нам регулярных выражений. Библиотека регулярных выражений Lua дорога в обслуживании и имеет низкую производительность — JIT не может её оптимизировать, а скомпилированные один раз шаблоны не кэшируются.

Поэтому, когда вы используете встроенную библиотеку строк Lua для find, match и т.д., не стесняйтесь использовать ngx.re OpenResty, если вам нужно что-то вроде регулярного выражения. При поиске фиксированной строки мы рассматриваем использование простого режима для вызова библиотеки строк.

Вот совет: в OpenResty мы всегда отдаём предпочтение API OpenResty, затем API LuaJIT и используем библиотеки Lua с осторожностью.

Кодирование JSON не различает массивы и словари

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

resty -e 'local cjson = require "cjson" local t = {} print(cjson.encode(t)) '

Например, приведённый выше код выводит {}, что показывает, что библиотека cjson OpenResty по умолчанию кодирует пустую таблицу как словарь. Конечно, мы можем изменить это глобальное значение по умолчанию с помощью функции encode_empty_table_as_object.

resty -e 'local cjson = require "cjson" cjson.encode_empty_table_as_object(false) local t = {} print(cjson.encode(t)) '

На этот раз пустая таблица кодируется как массив [].

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

Первый способ — присвоить userdata cjson.empty_array указанной таблице, чтобы она рассматривалась как пустой массив при кодировании в JSON.

$ resty -e 'local cjson = require "cjson" local t = cjson.empty_array print(cjson.encode(t)) '

Однако иногда мы не уверены, всегда ли указанная таблица пуста. Мы хотим кодировать её как массив, когда она пуста, поэтому мы используем функцию cjson.empty_array_mt, что является нашим вторым методом.

Она пометит указанную таблицу и будет кодировать её как массив, когда таблица пуста. Как видно из названия cjson.empty_array_mt, она устанавливается с помощью метатаблицы, как в следующем коде.

$ resty -e 'local cjson = require "cjson" local t = {} setmetatable(t, cjson.empty_array_mt) print(cjson.encode(t)) t = {123} print(cjson.encode(t)) '

Ограничение на количество переменных

Рассмотрим четвёртую ошибку — ограничение на количество переменных. В Lua есть верхний предел на количество локальных переменных и количество upvalue в функции, как видно из исходного кода Lua.

/* @@ LUAI_MAXVARS is the maximum number of local variables per function @* (must be smaller than 250). */ #define LUAI_MAXVARS 200 /* @@ LUAI_MAXUPVALUES is the maximum number of upvalues per function @* (must be smaller than 250). */ #define LUAI_MAXUPVALUES 60

Эти два порога жёстко заданы как 200 и 60 соответственно, и хотя вы можете вручную изменить исходный код, чтобы настроить эти значения, они могут быть установлены только до максимума в 250.

Обычно мы не превышаем этот порог. Однако при написании кода на OpenResty следует быть осторожным, чтобы не злоупотреблять локальными переменными и upvalue, а использовать do ... end как можно чаще, чтобы уменьшить количество локальных переменных и upvalue.

Например, рассмотрим следующий псевдокод.

local re_find = ngx.re.find function foo() ... end function bar() ... end function fn() ... end

Если только функция foo использует re_find, то мы можем изменить её следующим образом:

do local re_find = ngx.re.find function foo() ... end end function bar() ... end function fn() ... end

Итог

С точки зрения "задавать больше вопросов", откуда взялся порог в 250 в Lua? Это наш вопрос для размышления на сегодня. Вы можете оставить свои комментарии и поделиться этой статьёй с коллегами и друзьями. Мы будем общаться и улучшать вместе.