Skip to content

No Bloquear el Bucle de Eventos (ni el Grupo de Workers)

¿Deberías leer esta guía?

Si estás escribiendo algo más complicado que un breve script de línea de comandos, leer esto te ayudará a escribir aplicaciones de mayor rendimiento y más seguras.

Este documento está escrito pensando en los servidores Node.js, pero los conceptos también se aplican a aplicaciones complejas de Node.js. Donde los detalles específicos del sistema operativo varían, este documento se centra en Linux.

Resumen

Node.js ejecuta código JavaScript en el Bucle de Eventos (inicialización y callbacks), y ofrece un Grupo de Workers para manejar tareas costosas como la E/S de archivos. Node.js escala bien, a veces mejor que enfoques más pesados como Apache. El secreto de la escalabilidad de Node.js es que utiliza un pequeño número de hilos para manejar muchos clientes. Si Node.js puede arreglárselas con menos hilos, entonces puede dedicar más tiempo y memoria de tu sistema a trabajar en los clientes en lugar de pagar sobrecargas de espacio y tiempo para los hilos (memoria, cambio de contexto). Pero debido a que Node.js tiene solo unos pocos hilos, debes estructurar tu aplicación para usarlos sabiamente.

Aquí tienes una buena regla general para mantener tu servidor Node.js rápido: Node.js es rápido cuando el trabajo asociado con cada cliente en un momento dado es "pequeño".

Esto se aplica a los callbacks en el Bucle de Eventos y a las tareas en el Grupo de Workers.

¿Por qué debería evitar bloquear el bucle de eventos y el grupo de trabajadores?

Node.js utiliza un pequeño número de hilos para gestionar muchos clientes. En Node.js hay dos tipos de hilos: un bucle de eventos (también conocido como bucle principal, hilo principal, hilo de eventos, etc.) y un grupo de k trabajadores en un grupo de trabajadores (también conocido como grupo de hilos).

Si un hilo tarda mucho en ejecutar una devolución de llamada (bucle de eventos) o una tarea (trabajador), lo llamamos "bloqueado". Mientras un hilo está bloqueado trabajando en nombre de un cliente, no puede gestionar las peticiones de otros clientes. Esto proporciona dos motivaciones para no bloquear ni el bucle de eventos ni el grupo de trabajadores:

  1. Rendimiento: Si realizas regularmente actividades pesadas en cualquier tipo de hilo, el rendimiento (peticiones/segundo) de tu servidor se verá afectado.
  2. Seguridad: Si es posible que para ciertas entradas uno de tus hilos pueda bloquearse, un cliente malicioso podría enviar esta "entrada malvada", hacer que tus hilos se bloqueen y evitar que trabajen para otros clientes. Esto sería un ataque de denegación de servicio.

Un repaso rápido de Node

Node.js utiliza la Arquitectura Orientada a Eventos: tiene un Bucle de Eventos para la orquestación y un Pool de Trabajadores para tareas costosas.

¿Qué código se ejecuta en el Bucle de Eventos?

Cuando comienzan, las aplicaciones Node.js primero completan una fase de inicialización, require-iendo módulos y registrando callbacks para eventos. Las aplicaciones Node.js luego entran en el Bucle de Eventos, respondiendo a las solicitudes entrantes de los clientes ejecutando el callback apropiado. Este callback se ejecuta de forma síncrona y puede registrar solicitudes asíncronas para continuar el procesamiento después de que se complete. Los callbacks para estas solicitudes asíncronas también se ejecutarán en el Bucle de Eventos.

El Bucle de Eventos también cumplirá las solicitudes asíncronas no bloqueantes realizadas por sus callbacks, por ejemplo, E/S de red.

En resumen, el Bucle de Eventos ejecuta los callbacks de JavaScript registrados para eventos, y también es responsable de cumplir las solicitudes asíncronas no bloqueantes como la E/S de red.

¿Qué código se ejecuta en el Pool de Trabajadores?

El Pool de Trabajadores de Node.js se implementa en libuv (documentos), que expone una API general de envío de tareas.

Node.js usa el Pool de Trabajadores para manejar tareas "costosas". Esto incluye E/S para las cuales un sistema operativo no proporciona una versión no bloqueante, así como tareas particularmente intensivas en CPU.

Estas son las APIs de los módulos de Node.js que hacen uso de este Pool de Trabajadores:

  1. Intensivo en E/S
    1. DNS: dns.lookup(), dns.lookupService().
    2. Sistema de Archivos: Todas las APIs del sistema de archivos, excepto fs.FSWatcher() y aquellas que son explícitamente síncronas, utilizan el pool de hilos de libuv.
  2. Intensivo en CPU
    1. Crypto: crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes(), crypto.randomFill(), crypto.generateKeyPair().
    2. Zlib: Todas las APIs de zlib, excepto aquellas que son explícitamente síncronas, utilizan el pool de hilos de libuv.

En muchas aplicaciones Node.js, estas APIs son las únicas fuentes de tareas para el Pool de Trabajadores. Las aplicaciones y los módulos que usan un complemento C++ pueden enviar otras tareas al Pool de Trabajadores.

Para que sea completo, notamos que cuando llamas a una de estas APIs desde un callback en el Bucle de Eventos, el Bucle de Eventos paga algunos costos menores de configuración al ingresar a los enlaces de C++ de Node.js para esa API y enviar una tarea al Pool de Trabajadores. Estos costos son insignificantes en comparación con el costo general de la tarea, por lo que el Bucle de Eventos la está descargando. Al enviar una de estas tareas al Pool de Trabajadores, Node.js proporciona un puntero a la función C++ correspondiente en los enlaces C++ de Node.js.

¿Cómo decide Node.js qué código ejecutar a continuación?

En abstracto, el Bucle de Eventos y el Grupo de Trabajadores mantienen colas para eventos pendientes y tareas pendientes, respectivamente.

En realidad, el Bucle de Eventos no mantiene realmente una cola. En cambio, tiene una colección de descriptores de archivos que le pide al sistema operativo que supervise, utilizando un mecanismo como epoll (Linux), kqueue (OSX), puertos de eventos (Solaris) o IOCP (Windows). Estos descriptores de archivos corresponden a sockets de red, cualquier archivo que esté observando, y así sucesivamente. Cuando el sistema operativo dice que uno de estos descriptores de archivos está listo, el Bucle de Eventos lo traduce al evento apropiado e invoca la(s) devolución(es) de llamada asociada(s) con ese evento. Puede obtener más información sobre este proceso aquí.

En contraste, el Grupo de Trabajadores utiliza una cola real cuyas entradas son tareas que se procesarán. Un Trabajador saca una tarea de esta cola y trabaja en ella, y cuando termina, el Trabajador genera un evento de "Al menos una tarea ha terminado" para el Bucle de Eventos.

¿Qué significa esto para el diseño de aplicaciones?

En un sistema de un hilo por cliente como Apache, a cada cliente pendiente se le asigna su propio hilo. Si un hilo que maneja un cliente se bloquea, el sistema operativo lo interrumpirá y le dará un turno a otro cliente. Por lo tanto, el sistema operativo garantiza que los clientes que requieren una pequeña cantidad de trabajo no sean penalizados por los clientes que requieren más trabajo.

Debido a que Node.js maneja muchos clientes con pocos hilos, si un hilo se bloquea al manejar la solicitud de un cliente, es posible que las solicitudes de clientes pendientes no tengan turno hasta que el hilo termine su devolución de llamada o tarea. Por lo tanto, el trato justo de los clientes es responsabilidad de su aplicación. Esto significa que no debe realizar demasiado trabajo para ningún cliente en una sola devolución de llamada o tarea.

Esta es parte de la razón por la que Node.js puede escalar bien, pero también significa que usted es responsable de garantizar una programación justa. Las siguientes secciones hablan sobre cómo garantizar una programación justa para el bucle de eventos y para el grupo de trabajadores.

No bloquee el bucle de eventos

El bucle de eventos detecta cada nueva conexión de cliente y organiza la generación de una respuesta. Todas las solicitudes entrantes y las respuestas salientes pasan a través del bucle de eventos. Esto significa que si el bucle de eventos pasa demasiado tiempo en algún momento, todos los clientes actuales y nuevos no tendrán turno.

Debe asegurarse de no bloquear nunca el bucle de eventos. En otras palabras, cada una de sus devoluciones de llamada de JavaScript debe completarse rápidamente. Por supuesto, esto también se aplica a sus await, sus Promise.then, etc.

Una buena forma de garantizar esto es razonar sobre la "complejidad computacional" de sus devoluciones de llamada. Si su devolución de llamada toma un número constante de pasos sin importar cuáles sean sus argumentos, siempre dará a cada cliente pendiente un turno justo. Si su devolución de llamada toma un número diferente de pasos según sus argumentos, debe pensar cuánto podrían durar los argumentos.

Ejemplo 1: una devolución de llamada de tiempo constante.

js
app.get('/constant-time', (req, res) => {
  res.sendStatus(200)
})

Ejemplo 2: Una devolución de llamada O(n). Esta devolución de llamada se ejecutará rápidamente para n pequeño y más lentamente para n grande.

js
app.get('/countToN', (req, res) => {
  let n = req.query.n
  // n iteraciones antes de darle un turno a otra persona
  for (let i = 0; i < n; i++) {
    console.log(`Iter ${i}`)
  }
  res.sendStatus(200)
})

Ejemplo 3: Una devolución de llamada O(n^2). Esta devolución de llamada seguirá ejecutándose rápidamente para n pequeño, pero para n grande se ejecutará mucho más lentamente que el ejemplo anterior O(n).

js
app.get('/countToN2', (req, res) => {
  let n = req.query.n
  // n^2 iteraciones antes de darle un turno a otra persona
  for (let i = 0; i < n; i++) {
    for (let j = 0; j < n; j++) {
      console.log(`Iter ${i}.${j}`)
    }
  }
  res.sendStatus(200)
})

¿Qué tan cuidadoso debes ser?

Node.js utiliza el motor Google V8 para JavaScript, que es bastante rápido para muchas operaciones comunes. Las excepciones a esta regla son las expresiones regulares y las operaciones JSON, que se comentan a continuación.

Sin embargo, para tareas complejas, debes considerar acotar la entrada y rechazar las entradas que sean demasiado largas. De esa manera, incluso si tu callback tiene una gran complejidad, al acotar la entrada te aseguras de que el callback no pueda tardar más que el tiempo del peor caso en la entrada aceptable más larga. Luego, puedes evaluar el costo del peor caso de este callback y determinar si su tiempo de ejecución es aceptable en tu contexto.

Bloqueando el bucle de eventos: REDOS

Una forma común de bloquear el bucle de eventos de manera desastrosa es utilizando una expresión regular "vulnerable".

Evitar expresiones regulares vulnerables

Una expresión regular (regexp) compara una cadena de entrada con un patrón. Generalmente pensamos que una coincidencia de expresión regular requiere una sola pasada a través de la cadena de entrada --- O(n) donde n es la longitud de la cadena de entrada. En muchos casos, una sola pasada es de hecho todo lo que se necesita. Desafortunadamente, en algunos casos la coincidencia de expresión regular podría requerir un número exponencial de recorridos a través de la cadena de entrada --- O(2^n). Un número exponencial de recorridos significa que si el motor requiere x recorridos para determinar una coincidencia, necesitará 2*x recorridos si agregamos solo un carácter más a la cadena de entrada. Dado que el número de recorridos está linealmente relacionado con el tiempo requerido, el efecto de esta evaluación será bloquear el bucle de eventos.

Una expresión regular vulnerable es aquella en la que tu motor de expresiones regulares podría tardar un tiempo exponencial, exponiéndote a REDOS en "entrada maliciosa". Si tu patrón de expresión regular es vulnerable o no (es decir, si el motor de expresiones regulares podría tardar un tiempo exponencial en él) es en realidad una pregunta difícil de responder, y varía dependiendo de si estás usando Perl, Python, Ruby, Java, JavaScript, etc., pero aquí hay algunas reglas generales que se aplican a todos estos lenguajes:

  1. Evita los cuantificadores anidados como (a+)*. El motor de expresiones regulares de V8 puede manejar algunos de estos rápidamente, pero otros son vulnerables.
  2. Evita los OR con cláusulas superpuestas, como (a|a)*. Nuevamente, estos a veces son rápidos.
  3. Evita el uso de referencias inversas, como (a.*) \1. Ningún motor de expresiones regulares puede garantizar la evaluación de estos en tiempo lineal.
  4. Si estás haciendo una coincidencia de cadena simple, usa indexOf o el equivalente local. Será más barato y nunca tomará más de O(n).

Si no estás seguro de si tu expresión regular es vulnerable, recuerda que Node.js generalmente no tiene problemas para reportar una coincidencia incluso para una expresión regular vulnerable y una cadena de entrada larga. El comportamiento exponencial se activa cuando hay un desajuste, pero Node.js no puede estar seguro hasta que intenta muchos caminos a través de la cadena de entrada.

Un ejemplo de REDOS

Aquí hay un ejemplo de una expresión regular vulnerable que expone su servidor a REDOS:

js
app.get('/redos-me', (req, res) => {
  let filePath = req.query.filePath
  // REDOS
  if (filePath.match(/(\/.+)+$/)) {
    console.log('ruta válida')
  } else {
    console.log('ruta inválida')
  }
  res.sendStatus(200)
})

La expresión regular vulnerable en este ejemplo es una forma (¡mala!) de verificar una ruta válida en Linux. Coincide con cadenas que son una secuencia de nombres delimitados por "/", como "/a/b/c". Es peligrosa porque viola la regla 1: tiene un cuantificador doblemente anidado.

Si un cliente consulta con filePath ///.../\n (100 /'s seguidos de un carácter de nueva línea que el "." de la expresión regular no coincidirá), entonces el Event Loop tardará efectivamente para siempre, bloqueando el Event Loop. El ataque REDOS de este cliente hace que todos los demás clientes no tengan turno hasta que finalice la coincidencia de la expresión regular.

Por esta razón, debe desconfiar del uso de expresiones regulares complejas para validar la entrada del usuario.

Recursos Anti-REDOS

Hay algunas herramientas para verificar la seguridad de sus expresiones regulares, como

Sin embargo, ninguna de estas detectará todas las expresiones regulares vulnerables.

Otro enfoque es utilizar un motor de expresiones regulares diferente. Puede usar el módulo node-re2, que utiliza el motor de expresiones regulares RE2 de Google, increíblemente rápido. Pero tenga en cuenta que RE2 no es 100% compatible con las expresiones regulares de V8, así que verifique si hay regresiones si cambia el módulo node-re2 para manejar sus expresiones regulares. Y las expresiones regulares particularmente complicadas no son compatibles con node-re2.

Si está intentando hacer coincidir algo "obvio", como una URL o una ruta de archivo, busque un ejemplo en una biblioteca de expresiones regulares o use un módulo npm, por ejemplo, ip-regex.

Bloqueando el Bucle de Eventos: Módulos Core de Node.js

Varios módulos core de Node.js tienen APIs síncronas costosas, incluyendo:

Estas APIs son costosas, porque involucran computación significativa (cifrado, compresión), requieren I/O (I/O de archivos), o potencialmente ambas (proceso hijo). Estas APIs están pensadas para la conveniencia de scripts, pero no están pensadas para su uso en el contexto del servidor. Si las ejecutas en el Bucle de Eventos, tardarán mucho más en completarse que una instrucción típica de JavaScript, bloqueando el Bucle de Eventos.

En un servidor, no deberías usar las siguientes APIs síncronas de estos módulos:

  • Cifrado:
    • crypto.randomBytes (versión síncrona)
    • crypto.randomFillSync
    • crypto.pbkdf2Sync
    • También debes tener cuidado al proporcionar una entrada grande a las rutinas de cifrado y descifrado.
  • Compresión:
    • zlib.inflateSync
    • zlib.deflateSync
  • Sistema de archivos:
    • No uses las APIs síncronas del sistema de archivos. Por ejemplo, si el archivo al que accedes está en un sistema de archivos distribuido como NFS, los tiempos de acceso pueden variar mucho.
  • Proceso hijo:
    • child_process.spawnSync
    • child_process.execSync
    • child_process.execFileSync

Esta lista es razonablemente completa a partir de Node.js v9.

Bloqueo del bucle de eventos: DOS de JSON

JSON.parse y JSON.stringify son otras operaciones potencialmente costosas. Si bien estas son O(n) en la longitud de la entrada, para n grandes pueden tardar sorprendentemente mucho.

Si su servidor manipula objetos JSON, particularmente aquellos provenientes de un cliente, debe tener precaución sobre el tamaño de los objetos o cadenas con los que trabaja en el bucle de eventos.

Ejemplo: bloqueo de JSON. Creamos un objeto obj de tamaño 2^21 y JSON.stringify lo, ejecutamos indexOf en la cadena, y luego JSON.parse lo. La cadena JSON.stringify tiene 50 MB. Se tarda 0.7 segundos en convertir el objeto a string, 0.03 segundos en ejecutar indexOf en la cadena de 50 MB y 1.3 segundos en analizar la cadena.

js
let obj = { a: 1 }
let niter = 20
let before, str, pos, res, took
for (let i = 0; i < niter; i++) {
  obj = { obj1: obj, obj2: obj } // Se duplica en tamaño en cada iteración
}
before = process.hrtime()
str = JSON.stringify(obj)
took = process.hrtime(before)
console.log('JSON.stringify tardó ' + took)
before = process.hrtime()
pos = str.indexOf('nomatch')
took = process.hrtime(before)
console.log('Pure indexof tardó ' + took)
before = process.hrtime()
res = JSON.parse(str)
took = process.hrtime(before)
console.log('JSON.parse tardó ' + took)

Existen módulos npm que ofrecen API JSON asíncronas. Consulte, por ejemplo:

  • JSONStream, que tiene API de flujo.
  • Big-Friendly JSON, que también tiene API de flujo, así como versiones asíncronas de las API JSON estándar utilizando el paradigma de partición en el bucle de eventos que se describe a continuación.

Cálculos complejos sin bloquear el bucle de eventos

Supongamos que desea realizar cálculos complejos en JavaScript sin bloquear el bucle de eventos. Tiene dos opciones: particionar o descargar.

Particionamiento

Puede particionar sus cálculos para que cada uno se ejecute en el bucle de eventos pero ceda (dé turnos a) regularmente a otros eventos pendientes. En JavaScript es fácil guardar el estado de una tarea en curso en un cierre, como se muestra en el ejemplo 2 a continuación.

Para un ejemplo sencillo, suponga que desea calcular el promedio de los números del 1 al n.

Ejemplo 1: Promedio sin particionar, cuesta O(n)

js
for (let i = 0; i < n; i++) sum += i
let avg = sum / n
console.log('avg: ' + avg)

Ejemplo 2: Promedio particionado, cada uno de los n pasos asíncronos cuesta O(1).

js
function asyncAvg(n, avgCB) {
  // Guarda la suma en curso en un cierre de JS.
  let sum = 0
  function help(i, cb) {
    sum += i
    if (i == n) {
      cb(sum)
      return
    }
    // "Recursión asíncrona".
    // Programa la siguiente operación de forma asíncrona.
    setImmediate(help.bind(null, i + 1, cb))
  }
  // Inicia el auxiliar, con CB para llamar a avgCB.
  help(1, function (sum) {
    let avg = sum / n
    avgCB(avg)
  })
}
asyncAvg(n, function (avg) {
  console.log('promedio de 1-n: ' + avg)
})

Puede aplicar este principio a las iteraciones de arrays y demás.

Descarga

Si necesitas hacer algo más complejo, la partición no es una buena opción. Esto se debe a que la partición usa solo el Bucle de Eventos, y no te beneficiarás de los múltiples núcleos que casi seguramente están disponibles en tu máquina. Recuerda, el Bucle de Eventos debe orquestar las peticiones de los clientes, no satisfacerlas él mismo. Para una tarea complicada, traslada el trabajo del Bucle de Eventos a un Conjunto de Trabajadores.

Cómo descargar

Tienes dos opciones para un Conjunto de Trabajadores de destino al que descargar el trabajo.

  1. Puedes usar el Conjunto de Trabajadores integrado de Node.js desarrollando un complemento de C++. En versiones anteriores de Node, construye tu complemento de C++ usando NAN, y en versiones más recientes usa N-API. node-webworker-threads ofrece una forma solo de JavaScript para acceder al Conjunto de Trabajadores de Node.js.
  2. Puedes crear y gestionar tu propio Conjunto de Trabajadores dedicado a la computación en lugar del Conjunto de Trabajadores de Node.js con temas de E/S. Las formas más sencillas de hacer esto son usando Proceso Hijo o Clúster.

No deberías simplemente crear un Proceso Hijo para cada cliente. Puedes recibir peticiones de clientes más rápido de lo que puedes crear y gestionar hijos, y tu servidor podría convertirse en una bomba de forks.

Desventaja de la descarga La desventaja del enfoque de descarga es que incurre en una sobrecarga en forma de costes de comunicación. Solo al Bucle de Eventos se le permite ver el "espacio de nombres" (estado de JavaScript) de tu aplicación. Desde un Trabajador, no puedes manipular un objeto de JavaScript en el espacio de nombres del Bucle de Eventos. En cambio, tienes que serializar y deserializar cualquier objeto que desees compartir. Entonces el Trabajador puede operar en su propia copia de estos objetos y devolver el objeto modificado (o un "parche") al Bucle de Eventos.

Para las preocupaciones sobre la serialización, consulta la sección sobre JSON DOS.

Algunas sugerencias para la descarga

Es posible que desees distinguir entre tareas intensivas en CPU y tareas intensivas en E/S porque tienen características marcadamente diferentes.

Una tarea intensiva en CPU solo avanza cuando su Worker está programado, y el Worker debe ser programado en uno de los núcleos lógicos de tu máquina. Si tienes 4 núcleos lógicos y 5 Workers, uno de estos Workers no puede avanzar. Como resultado, estás pagando gastos generales (memoria y costos de programación) por este Worker y no obtienes ningún beneficio por ello.

Las tareas intensivas en E/S implican consultar a un proveedor de servicios externo (DNS, sistema de archivos, etc.) y esperar su respuesta. Mientras que un Worker con una tarea intensiva en E/S está esperando su respuesta, no tiene nada más que hacer y puede ser desprogramado por el sistema operativo, dando a otro Worker la oportunidad de enviar su solicitud. Por lo tanto, las tareas intensivas en E/S progresarán incluso mientras el hilo asociado no esté en ejecución. Los proveedores de servicios externos como las bases de datos y los sistemas de archivos se han optimizado mucho para manejar muchas solicitudes pendientes de forma concurrente. Por ejemplo, un sistema de archivos examinará un gran conjunto de solicitudes de escritura y lectura pendientes para fusionar actualizaciones conflictivas y recuperar archivos en un orden óptimo.

Si confías en un solo Worker Pool, por ejemplo, el Worker Pool de Node.js, entonces las diferentes características del trabajo con límite de CPU y con límite de E/S pueden perjudicar el rendimiento de tu aplicación.

Por esta razón, es posible que desees mantener un Computation Worker Pool separado.

Descarga: conclusiones

Para tareas simples, como iterar sobre los elementos de un array arbitrariamente largo, la partición podría ser una buena opción. Si tu cálculo es más complejo, la descarga es un mejor enfoque: los costos de comunicación, es decir, la sobrecarga de pasar objetos serializados entre el bucle de eventos y el grupo de trabajadores, se ven compensados por el beneficio de usar múltiples núcleos.

Sin embargo, si tu servidor se basa en gran medida en cálculos complejos, deberías considerar si Node.js es realmente una buena opción. Node.js destaca en el trabajo ligado a E/S, pero para cálculos costosos podría no ser la mejor opción.

Si adoptas el enfoque de descarga, consulta la sección sobre no bloquear el grupo de trabajadores.

No bloquees el grupo de trabajadores

Node.js tiene un grupo de trabajadores compuesto por k trabajadores. Si estás utilizando el paradigma de descarga discutido anteriormente, podrías tener un grupo de trabajadores de cálculo separado, al que se aplican los mismos principios. En cualquier caso, asumamos que k es mucho menor que el número de clientes que podrías estar manejando simultáneamente. Esto está en consonancia con la filosofía de "un hilo para muchos clientes" de Node.js, el secreto de su escalabilidad.

Como se discutió anteriormente, cada trabajador completa su tarea actual antes de pasar a la siguiente en la cola del grupo de trabajadores.

Ahora, habrá variación en el costo de las tareas necesarias para manejar las peticiones de tus clientes. Algunas tareas se pueden completar rápidamente (por ejemplo, leer archivos cortos o en caché, o producir un pequeño número de bytes aleatorios), y otras tardarán más (por ejemplo, leer archivos más grandes o sin caché, o generar más bytes aleatorios). Tu objetivo debería ser minimizar la variación en los tiempos de las tareas, y deberías usar la partición de tareas para lograr esto.

Minimizando la variación en los tiempos de las Tareas

Si la Tarea actual de un Trabajador es mucho más costosa que otras Tareas, entonces no estará disponible para trabajar en otras Tareas pendientes. En otras palabras, cada Tarea relativamente larga disminuye efectivamente el tamaño del Grupo de Trabajadores en uno hasta que se completa. Esto es indeseable porque, hasta cierto punto, cuantos más Trabajadores haya en el Grupo de Trabajadores, mayor será el rendimiento del Grupo de Trabajadores (tareas/segundo) y, por lo tanto, mayor será el rendimiento del servidor (solicitudes de clientes/segundo). Un cliente con una Tarea relativamente costosa disminuirá el rendimiento del Grupo de Trabajadores, lo que a su vez disminuirá el rendimiento del servidor.

Para evitar esto, debe intentar minimizar la variación en la duración de las Tareas que envía al Grupo de Trabajadores. Si bien es apropiado tratar los sistemas externos a los que acceden sus solicitudes de E/S (DB, FS, etc.) como cajas negras, debe ser consciente del costo relativo de estas solicitudes de E/S y debe evitar enviar solicitudes que pueda esperar que sean particularmente largas.

Dos ejemplos deberían ilustrar la posible variación en los tiempos de las tareas.

Ejemplo de variación: Lecturas de sistema de archivos de larga duración

Supongamos que tu servidor debe leer archivos para manejar algunas solicitudes de clientes. Después de consultar las API de Sistema de archivos de Node.js, optaste por usar fs.readFile() por simplicidad. Sin embargo, fs.readFile() (actualmente) no está particionado: envía una sola tarea fs.read() que abarca todo el archivo. Si lees archivos más cortos para algunos usuarios y archivos más largos para otros, fs.readFile() puede introducir una variación significativa en la duración de las tareas, en detrimento del rendimiento del grupo de trabajadores.

En el peor de los casos, supongamos que un atacante puede convencer a tu servidor para que lea un archivo arbitrario (esta es una vulnerabilidad de recorrido de directorio). Si tu servidor se ejecuta en Linux, el atacante puede nombrar un archivo extremadamente lento: /dev/random. Para todos los fines prácticos, /dev/random es infinitamente lento, y cada trabajador al que se le pide que lea desde /dev/random nunca terminará esa tarea. Entonces, un atacante envía k solicitudes, una para cada trabajador, y ninguna otra solicitud de cliente que use el grupo de trabajadores progresará.

Ejemplo de variación: operaciones criptográficas de larga duración

Supongamos que tu servidor genera bytes aleatorios criptográficamente seguros utilizando crypto.randomBytes(). crypto.randomBytes() no está particionado: crea una única tarea randomBytes() para generar tantos bytes como hayas solicitado. Si creas menos bytes para algunos usuarios y más bytes para otros, crypto.randomBytes() es otra fuente de variación en la duración de las tareas.

Partición de tareas

Las tareas con costos de tiempo variables pueden perjudicar el rendimiento del Worker Pool. Para minimizar la variación en los tiempos de las tareas, en la medida de lo posible, debes particionar cada tarea en subtareas de costo comparable. Cuando cada subtarea se complete, debe enviar la siguiente subtarea y, cuando se complete la subtarea final, debe notificar al remitente.

Para continuar con el ejemplo de fs.readFile(), deberías usar fs.read() (partición manual) o ReadStream (particionado automático).

El mismo principio se aplica a las tareas vinculadas a la CPU; el ejemplo asyncAvg podría ser inapropiado para el Event Loop, pero es muy adecuado para el Worker Pool.

Cuando particionas una tarea en subtareas, las tareas más cortas se expanden en un pequeño número de subtareas, y las tareas más largas se expanden en un número mayor de subtareas. Entre cada subtarea de una tarea más larga, el Worker al que fue asignada puede trabajar en una subtarea de otra tarea más corta, mejorando así el rendimiento general de tareas del Worker Pool.

Ten en cuenta que el número de subtareas completadas no es una métrica útil para el rendimiento del Worker Pool. En su lugar, preocúpate por el número de tareas completadas.

Evitar la partición de tareas

Recordemos que el propósito de la partición de tareas es minimizar la variación en los tiempos de las tareas. Si puede distinguir entre tareas más cortas y tareas más largas (por ejemplo, sumar una matriz frente a ordenar una matriz), podría crear un grupo de trabajadores para cada clase de tarea. Enrutar tareas más cortas y tareas más largas a grupos de trabajadores separados es otra forma de minimizar la variación en el tiempo de las tareas.

A favor de este enfoque, la partición de tareas genera sobrecarga (los costos de crear una representación de tarea de grupo de trabajadores y de manipular la cola del grupo de trabajadores), y evitar la partición le ahorra los costos de viajes adicionales al grupo de trabajadores. También evita que cometa errores al particionar sus tareas.

La desventaja de este enfoque es que los trabajadores en todos estos grupos de trabajadores incurrirán en sobrecargas de espacio y tiempo y competirán entre sí por tiempo de CPU. Recuerde que cada tarea vinculada a la CPU solo avanza mientras está programada. Como resultado, solo debe considerar este enfoque después de un análisis cuidadoso.

Grupo de trabajadores: conclusiones

Ya sea que utilice solo el Grupo de trabajadores de Node.js o mantenga uno o varios Grupos de trabajadores separados, debe optimizar el rendimiento de las tareas de su(s) Grupo(s).

Para ello, minimice la variación en los tiempos de las tareas utilizando la partición de tareas.

Los riesgos de los módulos npm

Si bien los módulos centrales de Node.js ofrecen bloques de construcción para una amplia variedad de aplicaciones, a veces se necesita algo más. Los desarrolladores de Node.js se benefician enormemente del ecosistema npm, con cientos de miles de módulos que ofrecen funcionalidad para acelerar su proceso de desarrollo.

Recuerde, sin embargo, que la mayoría de estos módulos están escritos por desarrolladores de terceros y generalmente se publican solo con las mejores garantías posibles. Un desarrollador que utiliza un módulo npm debería preocuparse por dos cosas, aunque esta última se olvida con frecuencia.

  1. ¿Respeta sus API?
  2. ¿Podrían sus API bloquear el bucle de eventos o un trabajador? Muchos módulos no hacen ningún esfuerzo por indicar el coste de sus API, en detrimento de la comunidad.

Para API simples, puede estimar el costo de las API; el costo de la manipulación de cadenas no es difícil de entender. Pero en muchos casos no está claro cuánto podría costar una API.

Si está llamando a una API que podría hacer algo costoso, verifique el costo. Pida a los desarrolladores que lo documenten, o examine el código fuente usted mismo (y envíe una PR documentando el costo).

Recuerde, incluso si la API es asíncrona, no sabe cuánto tiempo podría pasar en un trabajador o en el bucle de eventos en cada una de sus particiones. Por ejemplo, suponga que en el ejemplo asyncAvg dado anteriormente, cada llamada a la función auxiliar sumaba la mitad de los números en lugar de uno de ellos. Entonces, esta función seguiría siendo asíncrona, pero el costo de cada partición sería O(n), no O(1), lo que la haría mucho menos segura de usar para valores arbitrarios de n.

Conclusión

Node.js tiene dos tipos de hilos: un Event Loop y k Workers. El Event Loop es responsable de las devoluciones de llamada de JavaScript y las E/S no bloqueantes, y un Worker ejecuta tareas correspondientes al código C++ que completa una solicitud asíncrona, incluyendo E/S bloqueantes y trabajo intensivo en CPU. Ambos tipos de hilos trabajan en no más de una actividad a la vez. Si alguna devolución de llamada o tarea toma mucho tiempo, el hilo que la ejecuta se bloquea. Si tu aplicación realiza devoluciones de llamada o tareas bloqueantes, esto puede llevar a una degradación del rendimiento (clientes/segundo) en el mejor de los casos, y a una denegación total de servicio en el peor.

Para escribir un servidor web de alto rendimiento y más a prueba de DoS, debes asegurarte de que, tanto con entradas benignas como maliciosas, ni tu Event Loop ni tus Workers se bloqueen.