Consejos principales: Identificación de conceptos únicos y trampas en Lua

API7.ai

October 12, 2022

OpenResty (NGINX + Lua)

En el artículo anterior, aprendimos sobre las funciones de la biblioteca relacionadas con tablas en LuaJIT. Además de estas funciones comunes, hoy les presentaré algunos conceptos únicos o menos comunes de Lua y las trampas comunes de Lua en OpenResty.

Tabla Débil

Primero, está la tabla débil, un concepto único en Lua, que está relacionado con la recolección de basura. Como otros lenguajes de alto nivel, Lua tiene recolección de basura automática, no tienes que preocuparte por la implementación, y no tienes que hacer GC explícitamente. El recolector de basura recogerá automáticamente el espacio que no está referenciado.

Pero el simple conteo de referencias no es suficiente, y a veces necesitamos un mecanismo más flexible. Por ejemplo, si insertamos un objeto Lua Foo (table o función) en la tabla tb, esto crea una referencia a ese objeto Foo. Incluso si no hay otras referencias a Foo, la referencia a él en tb siempre existirá, por lo que no hay forma de que el GC reclame la memoria ocupada por Foo. En este punto, solo tenemos dos opciones.

  • Una es liberar Foo manualmente.
  • La segunda es hacer que resida en la memoria.

Por ejemplo, el siguiente código.

$ 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

Sin embargo, creo que no quieres mantener la memoria ocupada por objetos que no usas, especialmente porque LuaJIT tiene un límite de memoria de 2G. El momento de la liberación manual no es fácil y añade complejidad a tu código.

Entonces es el momento de que la tabla débil entre en acción. Mira su nombre, tabla débil. Primero, es una tabla, y luego todos los elementos en esta tabla son referencias débiles. El concepto siempre es abstracto, así que comencemos mirando un código ligeramente modificado.

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

collectgarbage()
print(#tb) -- 0
'

Como puedes ver, los objetos que no se están usando son liberados. Lo más importante de esto es la siguiente línea de código.

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

¿Te resulta familiar? ¿No es esa la operación de una metatabla? Sí, una tabla es una tabla débil cuando tiene un campo __mode en su metatabla.

  • Si el valor de __mode es k, la clave de la tabla es una referencia débil.
  • Si el valor de __mode es v, entonces el valor de la tabla es una referencia débil.
  • Por supuesto, también puedes configurarlo como kv, lo que indica que tanto las claves como los valores de esta tabla son referencias débiles.

Cualquiera de estas tres tablas débiles tendrá todo su par clave-valor reclamado una vez que su clave o valor sea reclamado.

En el ejemplo de código anterior, el valor de __mode es v, tb es un array, y el valor del array es la tabla y el objeto función para que pueda ser reciclado automáticamente. Sin embargo, si cambias el valor de __mode a k, no se liberará, por ejemplo, si miras el siguiente código.

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

collectgarbage()
print(#tb) -- 2
'

Solo demostramos tablas débiles donde el valor es una referencia débil, es decir, tablas débiles del tipo array. Naturalmente, también puedes construir una tabla débil del tipo tabla hash usando un objeto como clave, por ejemplo, como sigue.

$ 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
'

Después de llamar manualmente collectgarbage() para forzar el GC, todos los elementos en la tabla tb habrán sido liberados. Por supuesto, en el código real, no necesitamos llamar a collectgarbage() manualmente, se ejecutará automáticamente en segundo plano, y no tenemos que preocuparnos por ello.

Sin embargo, ya que mencionamos la función collectgarbage(), diré algunas palabras más sobre ella. Esta función puede recibir varias opciones diferentes y por defecto es collect, que es un GC completo. Otra útil es count, que devuelve la cantidad de espacio de memoria ocupado por Lua. Esta estadística es útil para ver si hay una fuga de memoria y nos recuerda no acercarnos al límite superior de 2G.

El código relacionado con tablas débiles es más complicado de escribir en la práctica, menos fácil de entender, y correspondientemente, más errores ocultos. No hay que apresurarse. Más adelante, presentaré un proyecto de código abierto, usando tablas débiles que causan problemas de fuga de memoria.

Cierre y upvalue

Pasando a cierres y upvalue, como he enfatizado antes, todos los valores son ciudadanos de primera clase en Lua, al igual que las funciones incluidas. Esto significa que las funciones pueden almacenarse en variables, pasarse como argumentos y devolverse como valores de otra función. Por ejemplo, este código de ejemplo aparece en la tabla débil anterior.

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

Es una función anónima que se almacena como el valor de una tabla.

En Lua, la definición de las dos funciones en el siguiente código es equivalente. Sin embargo, ten en cuenta que la última asigna una función a una variable, un método que usamos a menudo.

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

Además, Lua admite escribir una función dentro de otra función, es decir, funciones anidadas, como el siguiente código de ejemplo.

$ 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
'

Puedes ver que la función bar puede leer la variable local i dentro de la función foo y modificar su valor, incluso si la variable no está definida dentro de bar. Esta característica se llama ámbito léxico.

Estas características de Lua son la base para los cierres. Un cierre es simplemente una función que accede a una variable en el ámbito léxico de otra función.

Por definición, todas las funciones en Lua son en realidad cierres, incluso si no las anidas. Esto se debe a que el compilador de Lua toma fuera del script de Lua y lo envuelve con otra capa de la función principal. Por ejemplo, las siguientes líneas simples de código.

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

Después de la compilación, se verá así.

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

Y la función fn captura dos variables locales de la función principal, por lo que también es un cierre.

Por supuesto, sabemos que el concepto de cierres existe en muchos lenguajes, y no es exclusivo de Lua, por lo que puedes comparar y contrastar para obtener una mejor comprensión. Solo cuando entiendas los cierres podrás entender lo que vamos a decir sobre upvalue.

upvalue es un concepto que es exclusivo de Lua, que es la variable fuera del ámbito léxico capturada en el cierre. Continuemos con el código anterior.

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

Puedes ver que la función fn captura dos variables locales, foo y bar, que no están en su propio ámbito léxico y que estas dos variables son, de hecho, el upvalue de la función fn.

Trampas Comunes

Después de introducir algunos conceptos en Lua, hablaré sobre las trampas relacionadas con Lua que encontré en el desarrollo de OpenResty.

En la sección anterior, mencionamos algunas de las diferencias entre Lua y otros lenguajes de desarrollo, como que el índice comienza en 1, variables globales por defecto, etc. En el desarrollo de código real de OpenResty, nos encontraremos con más problemas relacionados con Lua y LuaJIT, y hablaré sobre algunos de los más comunes a continuación.

Aquí hay un recordatorio de que incluso si conoces todas las trampas, inevitablemente tendrás que pasar por ellas tú mismo para que te impacten. La diferencia, por supuesto, es que podrás salir del agujero y encontrar el meollo del problema de una manera mucho mejor.

¿El índice comienza en 0 o en 1?

La primera trampa es que el índice de Lua comienza en 1, como hemos mencionado repetidamente antes.

Pero tengo que decir que esto no es toda la verdad. Porque en LuaJIT, los arrays creados con ffi.new comienzan en 0 nuevamente:

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

Entonces, si deseas acceder al buf cdata en el código anterior, recuerda que el índice comienza desde 0, no desde 1. Asegúrate de prestar especial atención a este lugar cuando uses FFI para interactuar con C.

Coincidencia de Patrones Regulares

La segunda trampa es el problema de la coincidencia de patrones regulares, y hay dos conjuntos de métodos de coincidencia de cadenas en paralelo en OpenResty: la biblioteca sting de Lua y la API ngx.re.* de OpenResty.

La coincidencia de patrones regulares de Lua es su formato único y se escribe de manera diferente a PCRE. Aquí hay un ejemplo simple.

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

Este código extrae la parte numérica de la cadena, y notarás que es completamente diferente a las expresiones regulares que conocemos. La biblioteca de coincidencia regular de Lua es costosa de mantener y de bajo rendimiento: JIT no puede optimizarla, y los patrones que se han compilado una vez no se almacenan en caché.

Entonces, cuando uses la biblioteca de cadenas incorporada de Lua para find, match, etc., no dudes en usar ngx.re de OpenResty si necesitas algo como una expresión regular. Al buscar una cadena fija, consideramos usar el modo simple para llamar a la biblioteca de cadenas.

Aquí hay una sugerencia: En OpenResty, siempre priorizamos la API de OpenResty, luego la API de LuaJIT, y usamos las bibliotecas de Lua con precaución.

La codificación JSON no distingue entre array y dict

La tercera trampa es que la codificación JSON no distingue entre array y dict; dado que Lua tiene solo una estructura de datos, table, cuando JSON codifica una tabla vacía, no hay forma de determinar si es un array o un diccionario.

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

Por ejemplo, el código anterior imprime {}, lo que muestra que la biblioteca cjson de OpenResty codifica una tabla vacía como un diccionario por defecto. Por supuesto, podemos cambiar este valor predeterminado global usando la función encode_empty_table_as_object.

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

Esta vez, la tabla vacía se codifica como un array [].

Sin embargo, esta configuración global tiene un impacto significativo, entonces, ¿podemos especificar las reglas de codificación para una tabla en particular? La respuesta es naturalmente sí, y hay dos formas de hacerlo.

La primera forma es asignar el userdata cjson.empty_array a la tabla especificada para que se trate como un array vacío cuando se codifique en JSON.

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

Sin embargo, a veces no estamos seguros de si la tabla especificada siempre está vacía. Queremos codificarla como un array cuando esté vacía, por lo que usamos la función cjson.empty_array_mt, que es nuestra segunda forma.

Marcará la tabla especificada y la codificará como un array cuando la tabla esté vacía. Como puedes ver por el nombre cjson.empty_array_mt, se configura usando una metatable, como en la siguiente operación de código.

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

Limitación en el número de variables

Veamos la cuarta trampa, el límite en el número de variables. Lua tiene un límite superior en el número de variables locales y el número de upvalues en una función, como puedes ver en el código fuente de Lua.

/*
@@ LUAI_MAXVARS es el número máximo de variables locales por función
@* (debe ser menor que 250).
*/
#define LUAI_MAXVARS            200


/*
@@ LUAI_MAXUPVALUES es el número máximo de upvalues por función
@* (debe ser menor que 250).
*/
#define LUAI_MAXUPVALUES        60

Estos dos umbrales están codificados en 200 y 60, respectivamente, y aunque puedes modificar manualmente el código fuente para ajustar estos dos valores, solo se pueden establecer a un máximo de 250.

Generalmente, no superamos este umbral. Aún así, al escribir código de OpenResty, debes tener cuidado de no abusar de las variables locales y upvalues, sino usar do ... end tanto como sea posible para reducir el número de variables locales y upvalues.

Por ejemplo, veamos el siguiente pseudocódigo.

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

Si solo la función foo usa re_find, entonces podemos modificarlo de la siguiente manera:

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

Resumen

Desde el punto de vista de "hacer más preguntas", ¿de dónde viene el umbral de 250 en Lua? Esta es nuestra pregunta de pensamiento para hoy. Te invitamos a dejar tus comentarios y compartir este artículo con tus colegas y amigos. Nos comunicaremos y mejoraremos juntos.