E/S no bloqueante: la clave para mejorar el rendimiento de OpenResty
API7.ai
December 2, 2022
En el capítulo de Optimización de Rendimiento, te guiaré a través de todos los aspectos de la optimización de rendimiento en OpenResty y resumiré los fragmentos mencionados en los capítulos anteriores en una guía completa de codificación de OpenResty, para que puedas escribir código de OpenResty de mejor calidad.
Mejorar el rendimiento no es fácil. Debemos considerar la optimización de la arquitectura del sistema, la optimización de la base de datos, la optimización del código, las pruebas de rendimiento, el análisis de gráficos de llamadas (flame graphs) y otros pasos. Pero es fácil reducir el rendimiento, y como sugiere el título del artículo de hoy, puedes reducir el rendimiento por un factor de 10 o más simplemente añadiendo unas pocas líneas de código. Si estás usando OpenResty para escribir tu código, pero el rendimiento no ha mejorado, probablemente se deba a operaciones de E/S bloqueantes.
Por lo tanto, antes de entrar en los detalles de la optimización de rendimiento, veamos un principio importante en la programación de OpenResty: E/S no bloqueante primero.
Desde pequeños, nuestros padres y maestros nos han enseñado a no jugar con fuego y no tocar los enchufes, ya que son comportamientos peligrosos. El mismo tipo de comportamiento peligroso existe en OpenResty. Si tienes que realizar operaciones de E/S bloqueantes en tu código, causará una caída dramática en el rendimiento, y el propósito original de usar OpenResty para construir un servidor de alto rendimiento se verá frustrado.
¿Por qué no podemos usar operaciones de E/S bloqueantes?
Entender qué comportamientos son peligrosos y evitarlos es el primer paso en la optimización de rendimiento. Comencemos revisando por qué las operaciones de E/S bloqueantes pueden afectar el rendimiento de OpenResty.
OpenResty puede mantener un alto rendimiento simplemente porque toma prestado el manejo de eventos de NGINX y las corrutinas de Lua, por lo que:
- Cuando encuentras una operación como E/S de red que requiere que esperes una respuesta antes de continuar, llamas a la corrutina de Lua
yield
para suspenderte y luego registras una devolución de llamada en NGINX. - Después de que la operación de E/S se completa (o ocurre un tiempo de espera o un error), NGINX llama a
resume
para despertar la corrutina de Lua.
Este proceso asegura que OpenResty siempre pueda usar los recursos de la CPU de manera eficiente para procesar todas las solicitudes.
En este flujo de procesamiento, LuaJIT no cede el control al bucle de eventos de NGINX si no se utiliza un método de E/S no bloqueante como cosocket
, sino que se utiliza una función de E/S bloqueante para manejar la E/S. Esto resulta en que otras solicitudes esperen en cola a que se complete el evento de E/S bloqueante antes de obtener una respuesta.
En resumen, en la programación de OpenResty, debemos ser especialmente cuidadosos con las llamadas a funciones que puedan bloquear la E/S; de lo contrario, una sola línea de código de E/S bloqueante puede reducir el rendimiento de todo el servicio.
A continuación, presentaré algunos problemas comunes, algunas funciones de E/S bloqueantes que a menudo se usan incorrectamente; también experimentaremos cómo usar la forma más fácil de "estropear" y hacer que el rendimiento de tu servicio caiga 10 veces rápidamente.
Ejecutar comandos externos
En muchos escenarios, los desarrolladores no solo usan OpenResty como un servidor web, sino que le otorgan más lógica de negocio. En este caso, puede ser necesario llamar a comandos y herramientas externos para ayudar a completar algunas operaciones.
Por ejemplo, para matar un proceso.
os.execute("kill -HUP " .. pid)
O para operaciones más largas como copiar archivos, usar OpenSSL para generar claves, etc.
os.execute(" cp test.exe /tmp ")
os.execute(" openssl genrsa -des3 -out private.pem 2048 ")
En apariencia, os.execute
es una función incorporada en Lua, y en el mundo de Lua, es efectivamente la forma de llamar a comandos externos. Sin embargo, es importante recordar que Lua es un lenguaje de programación embebido y tendrá diferentes usos recomendados en otros contextos.
En el entorno de OpenResty, os.execute
bloquea la solicitud actual. Por lo tanto, si el tiempo de ejecución de este comando es particularmente corto, el impacto no es muy grande. Pero si el comando tarda cientos de milisegundos o incluso segundos en ejecutarse, habrá una caída drástica en el rendimiento.
Entendemos el problema, entonces, ¿cómo deberíamos resolverlo? En general, hay dos soluciones.
1. Si hay una biblioteca FFI
disponible, preferimos la forma FFI para llamarla
Por ejemplo, si usamos la línea de comandos de OpenSSL para generar la clave anterior, podemos cambiarlo para usar FFI
y llamar a la función C de OpenSSL para evitarlo.
Para matar un proceso, puedes usar lua-resty-signal
, una biblioteca que viene con OpenResty, para resolverlo de manera no bloqueante. La implementación del código es la siguiente. Por supuesto, aquí, lua-resty-signal
también se resuelve usando FFI
para llamar a funciones del sistema.
local resty_signal = require "resty.signal"
local pid = 12345
local ok, err = resty_signal.kill(pid, "KILL")
Además, el sitio web oficial de LuaJIT tiene una página especial que introduce varias bibliotecas de enlace FFI en diferentes categorías. Por ejemplo, cuando se trata de operaciones intensivas en CPU como el procesamiento de imágenes, cifrado y descifrado, puedes ir allí primero para ver si hay bibliotecas que ya han sido encapsuladas y pueden usarse directamente.
2. Usar la biblioteca lua-resty-shell
basada en ngx.pipe
Como se describió anteriormente, puedes ejecutar tus comandos en shell.run
, una operación de E/S no bloqueante.
$ resty -e 'local shell = require "resty.shell"
local ok, stdout, stderr, reason, status =
shell.run([[echo "hello, world"]])
ngx.say(stdout) '
E/S de disco
Veamos el escenario de manejo de E/S de disco. En una aplicación del lado del servidor, es una operación común leer un archivo de configuración local, como el siguiente código.
local path = "/conf/apisix.conf"
local file = io.open(path, "rb")
local content = file:read("*a")
file:close()
Este código usa io.open
para obtener el contenido de un archivo. Sin embargo, aunque es una operación de E/S bloqueante, no olvides que las cosas deben considerarse en un escenario real. Por lo tanto, si lo llamas en init
y init worker
, es una acción única que no afecta ninguna solicitud del cliente y es perfectamente aceptable.
Por supuesto, se vuelve inaceptable si cada solicitud de usuario desencadena una lectura o escritura en el disco. En ese momento, debes considerar seriamente la solución.
En primer lugar, podemos usar el lua-io-nginx-module
, un módulo C de terceros. Proporciona una API Lua de E/S no bloqueante para OpenResty, pero no puedes usarlo como lo harías con cosocket
. Porque el consumo de E/S de disco no desaparece sin razón, es solo una forma diferente de hacer las cosas.
Este enfoque funciona porque el lua-io-nginx-module
aprovecha el agrupamiento de hilos de NGINX para mover las operaciones de E/S de disco del hilo principal a otro hilo para procesarlas, de modo que el hilo principal no se bloquee por las operaciones de E/S de disco.
Necesitas recompilar NGINX cuando usas esta biblioteca, ya que es un módulo C. Se usa de la misma manera que la biblioteca de E/S de Lua.
local ngx_io = require "ngx.io"
local path = "/conf/apisix.conf"
local file, err = ngx_io.open(path, "rb")
local data, err = file: read("*a")
file:close()
En segundo lugar, intenta un ajuste arquitectónico. ¿Podemos cambiar nuestra forma de hacer este tipo de E/S de disco y dejar de leer y escribir en discos locales?
Déjame darte un ejemplo para que puedas aprender por analogía. Hace años, estaba trabajando en un proyecto que requería registrar en un disco local para fines estadísticos y de solución de problemas.
En ese momento, los desarrolladores usaban ngx.log
para escribir estos registros, como el siguiente.
ngx.log(ngx.WARN, "info")
Esta línea de código llama a la API Lua proporcionada por OpenResty, y parece que no hay problemas. Sin embargo, la desventaja es que no puedes llamarla muy a menudo. Primero, ngx.log
en sí es una llamada a función costosa; segundo, incluso con un búfer, las escrituras grandes y frecuentes en el disco pueden afectar seriamente el rendimiento.
Entonces, ¿cómo lo resolvemos? Volvamos a la necesidad original: estadísticas, solución de problemas, y escribir registros en el disco local habría sido solo uno de los medios para alcanzar el objetivo.
Por lo tanto, también puedes enviar registros a un servidor de registros remoto para usar cosocket
y hacer comunicación de red no bloqueante; es decir, lanzar la E/S de disco bloqueante al servicio de registros para evitar bloquear el servicio externo. Puedes usar lua-resty-logger-socket
para hacer esto.
local logger = require "resty.logger.socket"
if not logger.initted() then
local ok, err = logger.init{
host = 'xxx',
port = 1234,
flush_limit = 1234,
drop_limit = 5678,
}
local msg = "foo"
local bytes, err = logger.log(msg)
Como habrás notado, ambos métodos anteriores son iguales: si la E/S bloqueante es inevitable, no bloquees el hilo principal del trabajador; lánzalo a otros hilos o servicios externos.
luasocket
Finalmente, hablemos de luasocket
, una biblioteca incorporada de Lua que los desarrolladores usan fácilmente y a menudo confunden con el cosocket
proporcionado por OpenResty. luasocket
también puede realizar funciones de comunicación de red, pero no tiene la ventaja de ser no bloqueante. Como resultado, si usas luasocket
, el rendimiento cae drásticamente.
Sin embargo, luasocket también tiene sus escenarios de uso únicos. Por ejemplo, no sé si recuerdas que cosocket
no está disponible en varias fases, y generalmente podemos evitarlo usando ngx.timer
. También puedes usar luasocket
para funciones de cosocket
en fases únicas como init_by_lua*
y init_worker_by_lua*
. Cuanto más familiarizado estés con las similitudes y diferencias entre OpenResty y Lua, más soluciones interesantes como estas encontrarás.
Además, lua-resty-socket
es un envoltorio secundario para una biblioteca de código abierto que hace que luasocket y cosocket sean compatibles. Este contenido también merece un estudio más profundo. Si todavía estás interesado, he preparado materiales para que continúes aprendiendo.
Resumen
En general, en OpenResty, reconocer los tipos de operaciones de E/S bloqueantes y sus soluciones es la base de una buena optimización de rendimiento. Entonces, ¿alguna vez has encontrado operaciones de E/S bloqueantes similares en el desarrollo real? ¿Cómo las encuentras y las resuelves? No dudes en compartir tu experiencia conmigo en los comentarios, y siéntete libre de compartir este artículo.