Ventajas y desventajas de `string` en OpenResty

API7.ai

December 8, 2022

OpenResty (NGINX + Lua)

En el último artículo, nos familiarizamos con las funciones de bloqueo comunes en OpenResty, que a menudo son mal utilizadas por los principiantes. A partir de este artículo, nos adentraremos en el núcleo de la optimización de rendimiento, lo que implicará muchas técnicas de optimización que pueden ayudarnos a mejorar rápidamente el rendimiento del código de OpenResty, así que no lo tomen a la ligera.

En este proceso, necesitaremos escribir más código de prueba para experimentar cómo usar estas técnicas de optimización y verificar su efectividad, para así poder utilizarlas correctamente.

Detrás de las técnicas de optimización de rendimiento

Las técnicas de optimización forman parte de la "práctica", así que antes de hacerlo, hablemos de la "teoría" de la optimización.

Los métodos de optimización de rendimiento cambiarán con las iteraciones de LuaJIT y OpenResty. Algunos métodos pueden ser directamente optimizados por la tecnología subyacente y ya no será necesario dominarlos; al mismo tiempo, surgirán nuevas técnicas de optimización. Por lo tanto, lo más importante es dominar el concepto constante detrás de estas técnicas de optimización.

Veamos algunas ideas clave sobre el rendimiento en la programación de OpenResty.

Teoría 1: El procesamiento de solicitudes debe ser corto, simple y rápido

OpenResty es un servidor web, por lo que a menudo maneja 1,000+, 10,000+, o incluso 100,000+ solicitudes de clientes simultáneamente. Por lo tanto, para lograr el mayor rendimiento general, debemos asegurarnos de que las solicitudes individuales se procesen rápidamente y que varios recursos, como la memoria, se recuperen.

  • El "corto" mencionado aquí significa que el ciclo de vida de la solicitud debe ser breve para no ocupar recursos durante mucho tiempo sin liberarlos; incluso para conexiones largas, se debe establecer un umbral de tiempo o número de solicitudes para liberar recursos regularmente.
  • El segundo "simple" se refiere a hacer solo una cosa en una API. Divida la lógica de negocio compleja en múltiples API y mantenga el código simple.
  • Finalmente, "rápido" significa no bloquear el hilo principal y no ejecutar demasiadas operaciones de CPU. Incluso si tiene que hacerlo, no olvide trabajar con otros métodos que presentamos en el último artículo.

Esta consideración arquitectónica no solo es adecuada para OpenResty, sino también para otros lenguajes y plataformas de desarrollo, así que espero que puedan entenderla y reflexionar sobre ella cuidadosamente.

Teoría 2: Evitar generar datos intermedios

Evitar datos inútiles en el proceso intermedio es, sin duda, la teoría de optimización más dominante en la programación de OpenResty. Veamos un pequeño ejemplo para explicar los datos inútiles en el proceso intermedio.

$ resty -e 'local s= "hello"
s = s .. " world"
s = s .. "!"
print(s)
'

En este fragmento de código, realizamos varias operaciones de concatenación en la variable s para obtener el resultado hello world!. Pero solo el estado final hello world! de s es útil. El valor inicial de s y las asignaciones intermedias son todos datos intermedios que deberían generarse lo menos posible.

La razón es que estos datos temporales traerán pérdidas de rendimiento en la inicialización y la recolección de basura (GC). No subestimen estas pérdidas; si esto aparece en código caliente como bucles, el rendimiento se degradará notablemente. También explicaré esto más adelante con un ejemplo de cadenas.

Las cadenas son inmutables

Ahora, volvamos al tema de este artículo, las cadenas (string). Aquí, destaco el hecho de que las cadenas son inmutables en Lua.

Por supuesto, esto no significa que las cadenas no se puedan concatenar, modificar, etc., pero cuando modificamos una cadena, no cambiamos la cadena original, sino que creamos un nuevo objeto de cadena y cambiamos la referencia a la cadena. Por lo tanto, naturalmente, si la cadena original no tiene otras referencias, será recuperada por el GC (recolección de basura) de Lua.

El beneficio evidente de las cadenas inmutables es que ahorran memoria. De esta manera, solo habrá una copia de la misma cadena en la memoria, y diferentes variables apuntarán a la misma dirección de memoria.

La desventaja de este diseño es que cuando se trata de agregar y recuperar cadenas, cada vez que agrega una cadena, LuaJIT tiene que llamar a lj_str_new para verificar si la cadena ya existe; si no, necesita crear una nueva cadena. Si hace esto con mucha frecuencia, tendrá un gran impacto en el rendimiento.

Veamos un ejemplo concreto de una operación de concatenación de cadenas como la de este ejemplo, que se encuentra en muchos proyectos de código abierto de OpenResty.

$ resty -e 'local begin = ngx.now()
local s = ""
-- Bucle `for`, usando `..` para realizar la concatenación de cadenas
for i = 1, 100000 do
    s = s .. "a"
end
ngx.update_time()
print(ngx.now() - begin)
'

Lo que hace este código de ejemplo es realizar 100,000 concatenaciones de cadenas en la variable s e imprimir el tiempo de ejecución. Aunque el ejemplo es un poco extremo, da una buena idea de la diferencia entre antes y después de la optimización de rendimiento. Sin optimización, este código se ejecuta en 0.4 segundos en mi portátil, lo cual sigue siendo relativamente lento. Entonces, ¿cómo deberíamos optimizarlo?

En los artículos anteriores, se dio la respuesta, que es usar table para hacer una capa de encapsulación, eliminando todas las cadenas intermedias temporales y manteniendo solo los datos originales y el resultado final. Veamos la implementación concreta del código.

$ resty -e 'local begin = ngx.now()
local t = {}
-- Bucle `for` que usa un array para mantener la cadena, contando la longitud del array cada vez
for i = 1, 100000 do
    t[#t + 1] = "a"
end
-- Concatenando cadenas usando el método `concat` de los arrays
local s =  table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'

Podemos ver que este código guarda cada cadena en turno con table, y el índice se determina por #t + 1, es decir, la longitud actual de table más 1. Finalmente, usa la función table.concat para concatenar cada elemento del array. Esto naturalmente salta todas las cadenas temporales y evita 100,000 llamadas a lj_str_new y GC.

Ese fue nuestro análisis de código, pero ¿cómo funciona la optimización? El código optimizado toma solo 0.007 segundos, lo que significa una mejora de rendimiento de más de 50 veces. En un proyecto real, la mejora de rendimiento podría ser aún más notable porque, en este ejemplo, solo agregamos un carácter a a la vez.

¿Cuál sería la diferencia de rendimiento si la nueva cadena tuviera una longitud de 10 veces a?

¿Son los 0.007 segundos de código lo suficientemente buenos para nuestro trabajo de optimización? No, todavía se puede optimizar. Modifiquemos una línea más de código y veamos el resultado.

$ resty -e 'local begin = ngx.now()
local t = {}
-- Bucle `for`, usando un array para mantener la cadena, manteniendo la longitud del array por sí mismo
for i = 1, 100000 do
    t[i] = "a"
end
local s =  table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'

Esta vez, cambiamos t[#t + 1] = "a" a t[i] = "a", y con solo una línea de código, podemos evitar 100,000 llamadas a funciones para obtener la longitud del array. ¿Recuerdan la operación para obtener la longitud de un array que mencionamos en la sección de table anterior? Tiene una complejidad de tiempo de O(n), una operación relativamente costosa. Entonces, aquí simplemente mantenemos nuestro índice de array para evitar la operación de obtener la longitud del array. Como dice el refrán, si no puedes permitírtelo, puedes evitarlo.

Por supuesto, esta es una forma más simple de escribirlo. El siguiente código ilustra más claramente cómo mantener el índice de un array por nosotros mismos.

$ resty -e 'local begin = ngx.now()
local t = {}
local index = 1
for i = 1, 100000 do
    t[index] = "a"
    index = index + 1
end
local s = table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'

Reducir otras cadenas temporales

Los errores de los que acabamos de hablar, cadenas temporales causadas por la concatenación de cadenas, son evidentes. Con algunos recordatorios del código de ejemplo anterior, creo que no cometeremos errores similares nuevamente. Sin embargo, algunas cadenas temporales más ocultas se generan en OpenResty, que son mucho menos fáciles de detectar. Por ejemplo, la función de manejo de cadenas que discutiremos a continuación se usa a menudo. ¿Pueden imaginar que también genera cadenas temporales?

Como sabemos, la función string.sub intercepta una parte específica de una cadena. Como mencionamos anteriormente, las cadenas en Lua son inmutables, por lo que interceptar una nueva cadena implica operaciones de lj_str_new y GC posteriores.

resty -e 'print(string.sub("abcd", 1, 1))'

La función del código anterior es obtener el primer carácter de la cadena e imprimirlo. Naturalmente, inevitablemente generará una cadena temporal. ¿Hay una mejor manera de lograr el mismo efecto?

resty -e 'print(string.char(string.byte("abcd")))'

Naturalmente, sí. Al ver este código, primero usamos string.byte para obtener el código numérico del primer carácter y luego usamos string.char para convertir el número al carácter correspondiente. Este proceso no genera ninguna cadena temporal. Por lo tanto, es más eficiente usar string.byte para hacer el análisis y escaneo relacionados con cadenas.

Aprovechar el soporte del SDK para el tipo table

Después de aprender cómo reducir las cadenas temporales, ¿están ansiosos por probarlo? Entonces, podemos tomar el resultado del código de ejemplo anterior y enviarlo al cliente como el contenido del cuerpo de la respuesta. En este punto, pueden pausar y tratar de escribir este código ustedes mismos primero.

$ resty -e 'local begin = ngx.now()
local t = {}
local index = 1
for i = 1, 100000 do
    t[index] = "a"
    index = index + 1
end
local response = table.concat(t, "")
ngx.say(response)
'

Si pueden escribir este código, ya están por delante de la mayoría de los desarrolladores de OpenResty. La API de Lua de OpenResty ya tiene en cuenta el uso de tables para la concatenación de cadenas, por lo que en ngx.say, ngx.print, ngx.log, cosocket:send y otras API que pueden tomar muchas cadenas, no solo acepta string como parámetro, sino que también acepta table como parámetro.

resty -e 'local begin = ngx.now()
local t = {}
local index = 1
for i = 1, 100000 do
    t[index] = "a"
    index = index + 1
end
ngx.say(t)
'

En este último fragmento de código, omitimos el paso de concatenación de cadenas local response = table.concat(t, "") y pasamos el table directamente a ngx.say. Esto traslada la tarea de concatenación de cadenas del nivel de Lua al nivel de C, evitando otra búsqueda, generación y GC de cadenas. Para cadenas largas, esto es otra ganancia significativa de rendimiento.

Resumen

Después de leer este artículo, podemos ver que gran parte de la optimización de rendimiento de OpenResty trata con varios detalles. Por lo tanto, necesitamos conocer bien LuaJIT y la API de Lua de OpenResty para lograr un rendimiento óptimo. Esto también nos recuerda que si hemos olvidado el contenido anterior, debemos revisarlo y consolidarlo a tiempo.

Finalmente, piensen en un problema: escriban las cadenas hello, world y ! en el registro de errores. ¿Podemos escribir un código de ejemplo sin concatenación de cadenas?

Además, no olviden la otra pregunta en el texto. ¿Cuál sería la diferencia de rendimiento en el siguiente código si las nuevas cadenas tuvieran una longitud de 10 veces a?

$ resty -e 'local begin = ngx.now()
local t = {}
for i = 1, 100000 do
    t[#t + 1] = "a"
end
local s =  table.concat(t, "")
ngx.update_time()
print(ngx.now() - begin)
'

También pueden compartir este artículo con sus amigos para aprender y comunicarse.