Why Does lua-resty-core Perform Better?

API7.ai

September 30, 2022

OpenResty (NGINX + Lua)

Como dijimos en las dos lecciones anteriores, Lua es un lenguaje de desarrollo integrado que mantiene el núcleo corto y compacto. Puedes integrar Lua en Redis y NGINX para ayudarte a implementar lógica de negocio de manera flexible. Lua también te permite llamar funciones y estructuras de datos existentes en C para evitar reinventar la rueda.

En Lua, puedes usar la API de C de Lua para llamar funciones en C, y en LuaJIT, puedes usar FFI. Para OpenResty:

  • En el núcleo lua-nginx-module, la API para llamar funciones en C se realiza utilizando la API de C de Lua.
  • En lua-resty-core, algunas de las API ya presentes en lua-nginx-module se implementan utilizando el modelo FFI.

Probablemente te estés preguntando, ¿por qué necesitamos implementarlo con FFI?

No te preocupes. Tomemos como ejemplo ngx.base64_decode, una API sencilla, y veamos cómo difiere la implementación de la API de C de Lua con la de FFI. También puedes tener una comprensión intuitiva de su rendimiento.

Lua CFunction

Veamos cómo se implementa esto en lua-nginx-module utilizando la API de C de Lua. Buscamos decode_base64 en el código del proyecto y encontramos su implementación en ngx_http_lua_string.c.

lua_pushcfunction(L, ngx_http_lua_ngx_decode_base64);
lua_setfield(L, -2, "decode_base64");

El código anterior es molesto de ver, pero afortunadamente, no tenemos que profundizar en las dos funciones que comienzan con lua_ y el papel específico de sus argumentos; solo necesitamos saber una cosa: aquí se registra una CFunction: ngx_http_lua_ngx_decode_base64, y corresponde a ngx.base64_decode, que es la API expuesta al público.

Sigamos adelante y "sigamos el mapa", buscando ngx_http_lua_ngx_decode_base64 en este archivo C, que se define al principio del archivo en:

static int ngx_http_lua_ngx_decode_base64(lua_State *L);

Para aquellas funciones en C que pueden ser llamadas desde Lua, su interfaz debe seguir la forma requerida por Lua, que es typedef int (*lua_CFunction)(lua_State* L). Contiene un puntero L de tipo lua_State como argumento; su tipo de valor de retorno es un entero que indica el número de valores devueltos, no el valor de retorno en sí.

Se implementa de la siguiente manera (aquí he eliminado el código de manejo de errores).

static int
 ngx_http_lua_ngx_decode_base64(lua_State *L)
 {
     ngx_str_t p, src;

    src.data = (u_char *) luaL_checklstring(L, 1, &src.len);

     p.len = ngx_base64_decoded_length(src.len);

     p.data = lua_newuserdata(L, p.len);

     if (ngx_decode_base64(&p, &src) == NGX_OK) {
         lua_pushlstring(L, (char *) p.data, p.len);

     } else {
         lua_pushnil(L);
     }

     return 1;
 }

Lo principal en este código es ngx_base64_decoded_length y ngx_decode_base64, ambas son funciones en C proporcionadas por NGINX.

Sabemos que las funciones escritas en C no pueden pasar el valor de retorno al código Lua, sino que necesitan pasar los parámetros de llamada y devolver un valor entre Lua y C a través de la pila. Es por eso que hay mucho código que no podemos entender de un vistazo. Además, este código no puede ser rastreado por JIT, por lo que para LuaJIT, estas operaciones están en una caja negra y no pueden ser optimizadas.

LuaJIT FFI

A diferencia de FFI, la parte interactiva de FFI se implementa en Lua, que puede ser rastreada por JIT y optimizada; por supuesto, el código también es más conciso y fácil de entender.

Tomemos el ejemplo de base64_decode, cuya implementación FFI se distribuye en dos repositorios: lua-resty-core y lua-nginx-module, y veamos el código implementado en el primero.

ngx.decode_base64 = function (s)
     local slen = #s
     local dlen = base64_decoded_length(slen)

     local dst = get_string_buf(dlen)
     local pdlen = get_size_ptr()
     local ok = C.ngx_http_lua_ffi_decode_base64(s, slen, dst, pdlen)
     if ok == 0 then
         return nil
     end
     return ffi_string(dst, pdlen[0])
 end

Encontrarás que, en comparación con CFunction, el código de la implementación FFI es mucho más fresco, su implementación específica es ngx_http_lua_ffi_decode_base64 en el repositorio lua-nginx-module. Si estás interesado aquí, puedes verificar el rendimiento de esta función tú mismo. Es bastante sencillo, no publicaré el código aquí.

Sin embargo, si eres cuidadoso, ¿notaste algunas reglas de nomenclatura en el fragmento de código anterior?

Sí, todas las funciones en OpenResty tienen convenciones de nomenclatura, y puedes inferir su uso por su nombre. Por ejemplo:

  • ngx_http_lua_ffi_, la función Lua que usa FFI para manejar solicitudes HTTP de NGINX.
  • ngx_http_lua_ngx_, una función Lua que usa funciones en C para manejar solicitudes HTTP de NGINX.
  • Las otras funciones que comienzan con ngx_ y lua_ son funciones integradas para NGINX y Lua respectivamente.

Además, el código en C en OpenResty tiene una estricta especificación de código, y recomiendo leer la guía de estilo de código C oficial aquí. Este es un documento imprescindible para los desarrolladores que deseen aprender el código C de OpenResty y enviar PRs. De lo contrario, incluso si tu PR está bien escrito, te pedirán que lo cambies debido a problemas de estilo de código.

Para más API y detalles sobre FFI, te recomendamos leer los tutoriales oficiales de LuaJIT y la documentación. Las columnas técnicas no son un sustituto de la documentación oficial; solo puedo ayudarte a señalar el camino de aprendizaje en un tiempo limitado, con menos desvíos; los problemas difíciles aún deben ser resueltos por ti.

LuaJIT FFI GC

Al usar FFI, podemos estar confundidos: ¿quién gestionará la memoria solicitada en FFI? ¿Deberíamos liberarla manualmente en C, o LuaJIT la recuperará automáticamente?

Aquí hay un principio simple: LuaJIT solo es responsable de los recursos que asigna por sí mismo; ffi.

Por ejemplo, si solicitas un bloque de memoria usando ffi.C.malloc, necesitarás liberarlo con el ffi.C.free correspondiente. La documentación oficial de LuaJIT tiene un ejemplo equivalente.

local p = ffi.gc(ffi.C.malloc(n), ffi.C.free)
 ...
 p = nil -- La última referencia a p desaparece.
 -- GC eventualmente ejecutará el finalizador: ffi.C.free(p)

En este código, ffi.C.malloc(n) solicita una sección de memoria, y ffi.gc registra una función de devolución de llamada de destructor ffi.C.free, que luego se llamará automáticamente cuando un cdata p sea recolectado por LuaJIT para liberar la memoria a nivel de C. Y cdata es recolectado por LuaJIT. LuaJIT liberará automáticamente p en el código anterior.

Ten en cuenta que si deseas solicitar un gran bloque de memoria en OpenResty, recomiendo usar ffi.C.malloc en lugar de ffi.new. Las razones también son evidentes.

  1. ffi.new devuelve cdata, que es parte de la memoria gestionada por LuaJIT.
  2. El GC de LuaJIT tiene un límite superior de gestión de memoria, y LuaJIT en OpenResty no tiene la opción GC64 habilitada. Por lo tanto, el límite superior de memoria para un solo worker es solo 2G. Una vez que se supera el límite superior de la gestión de memoria de LuaJIT, causará un error.

Al usar FFI, también debemos prestar especial atención a las fugas de memoria. Sin embargo, todos cometemos errores, y siempre que los humanos escriban el código, habrá errores.

Aquí es donde entra en juego la robusta cadena de herramientas de prueba y depuración de OpenResty.

Hablemos primero de las pruebas. En el sistema de OpenResty, usamos Valgrind para detectar fugas de memoria.

El marco de pruebas que mencionamos en el curso anterior, test::nginx, tiene un modo especial de detección de fugas de memoria para ejecutar conjuntos de casos de prueba unitarios; necesitas establecer la variable de entorno TEST_NGINX_USE_VALGRIND=1. El proyecto oficial de OpenResty se registra completamente en este modo antes de lanzar la versión, y entraremos en más detalles en la sección de pruebas más adelante.

La CLI de OpenResty, resty, también tiene la opción --valgrind, que te permite ejecutar un código Lua solo, incluso si no has escrito un caso de prueba.

Veamos las herramientas de depuración.

OpenResty proporciona extensiones basadas en systemtap para realizar análisis dinámico en vivo de programas de OpenResty. Puedes buscar la palabra clave gc en el conjunto de herramientas de este proyecto, y verás dos herramientas, lj-gc y lj-gc-objs.

Para análisis fuera de línea como core dump, OpenResty proporciona un conjunto de herramientas GDB, y también puedes buscar gc en él y encontrar las tres herramientas lgc, lgcstat y lgcpath.

El uso específico de estas herramientas de depuración se cubrirá en detalle en la sección de depuración más adelante, para que puedas tener una idea primero. Después de todo, OpenResty tiene un conjunto dedicado de herramientas para ayudarte a localizar y resolver estos problemas.

lua-resty-core

De la comparación anterior, podemos ver que el enfoque FFI no solo es más limpio en el código, sino que también puede ser optimizado por LuaJIT, lo que lo convierte en la mejor opción. OpenResty ha dejado de usar la implementación de CFunction, y el rendimiento se ha eliminado del código base. Las nuevas API ahora se implementan en el repositorio lua-resty-core a través de FFI.

Antes de que OpenResty 1.15.8.1 se lanzara en mayo de 2019, lua-resty-core no estaba habilitado por defecto, lo que resultó en pérdidas de rendimiento y posibles errores, por lo que recomiendo encarecidamente que cualquiera que aún use la versión histórica habilite manualmente lua-resty-core. Solo necesitas agregar una línea de código en la fase init_by_lua.

require "resty.core"

Por supuesto, la directiva lua_load_resty_core se agregó en la versión 1.15.8.1, y lua-resty-core está habilitado por defecto.

Personalmente, siento que OpenResty sigue siendo demasiado cauteloso al habilitar lua-resty-core, y los proyectos de código abierto deberían establecer características similares para que se habiliten por defecto lo antes posible.

lua-resty-core no solo reimplementa algunas de las API del proyecto lua-nginx-module, como ngx.re.match, ngx.md5, etc., sino que también implementa varias API nuevas, como ngx.ssl, ngx.base64, ngx.errlog, ngx.process, ngx.re.split, ngx.resp.add_header, ngx.balancer, ngx.semaphore, etc., que cubriremos más adelante en el capítulo de API de OpenResty.

Resumen

Después de todo esto, me gustaría concluir que FFI, aunque es bueno, no es una bala de plata para el rendimiento. La razón principal por la que es eficiente es que puede ser rastreado y optimizado por JIT. Si escribes código Lua que no puede ser JIT y necesita ejecutarse en modo interpretado, entonces FFI será menos eficiente.

Entonces, ¿qué operaciones pueden ser JIT y cuáles no? ¿Cómo podemos evitar escribir código que no pueda ser JIT? Lo revelaré en la siguiente sección.

Finalmente, una tarea práctica: ¿Puedes encontrar una o dos API en lua-nginx-module y lua-resty-core, y luego comparar las diferencias en las pruebas de rendimiento? Puedes ver cuán significativa es la mejora de rendimiento de FFI.

Te invito a dejar un comentario, y compartiré tus pensamientos y logros, y te animo a compartir este artículo con tus colegas y amigos, para intercambiar y progresar juntos.