El disparo paralelo de 1k solicitudes HTTP se atascaría

10

La pregunta es ¿qué sucede realmente cuando desencadena solicitudes HTTP salientes 1k-2k? Veo que resolvería todas las conexiones fácilmente con 500 conexiones, pero avanzar desde allí parece causar problemas, ya que las conexiones se dejan abiertas y la aplicación Node se quedaría atascada allí. Probado con servidor local + ejemplo Google y otros servidores simulados.

Entonces, con algunos puntos finales de servidor diferentes , recibí una razón: lea ECONNRESET, que está bien, el servidor no pudo manejar la solicitud y arrojó un error. En el rango de solicitud 1k-2k, el programa simplemente se colgaría. Cuando verifica las conexiones abiertas lsof -r 2 -i -a, puede ver que hay una cantidad X de conexiones que siguen colgadas allí 0t0 TCP 192.168.0.20:54831->lk-in-f100.1e100.net:https (ESTABLISHED). Cuando agrega la configuración de tiempo de espera a las solicitudes, esto probablemente termine con un error de tiempo de espera, pero ¿por qué de lo contrario la conexión se mantiene para siempre y el programa principal terminaría en algún estado de limbo?

Código de ejemplo:

import fetch from 'node-fetch';

(async () => {
  const promises = Array(1000).fill(1).map(async (_value, index) => {
    const url = 'https://google.com';
    const response = await fetch(url, {
      // timeout: 15e3,
      // headers: { Connection: 'keep-alive' }
    });
    if (response.statusText !== 'OK') {
      console.log('No ok received', index);
    }
    return response;
  })

  try {
    await Promise.all(promises);
  } catch (e) {
    console.error(e);
  }
  console.log('Done');
})();
Risto Novik
fuente
1
¿Podría publicar el resultado de la npx envinfoejecución de su ejemplo en mi script Win 10 / nodev10.16.0 que termina en 8432.805ms
Łukasz Szewczak
Ejecuté el ejemplo en OS X y Alpine Linux (contenedor docker) y obtuve el mismo resultado.
Risto Novik
Mi Mac local ejecuta el script en 7156.797ms. ¿Estás seguro de que no hay firewalls que bloqueen las solicitudes?
John
Probado sin usar el firewall de la máquina local, pero ¿podría ser un problema con mi enrutador / red local? Intentaré ejecutar una prueba similar en Google Cloud o Heroku.
Risto Novik

Respuestas:

3

Para entender lo que estaba sucediendo con seguridad, necesitaba hacer algunas modificaciones a su script, pero aquí están.

Primero, puede que sepa cómo nodey cómo event loopfunciona, pero permítame hacer un resumen rápido. Cuando ejecuta un script, el nodetiempo de ejecución primero ejecuta la parte sincrónica del mismo, luego programa el promisesy timerspara que se ejecute en los siguientes bucles, y cuando se verifica que están resueltos, ejecute las devoluciones de llamada en otro bucle. Esta simple explicación lo explica muy bien, crédito a @StephenGrider:


const pendingTimers = [];
const pendingOSTasks = [];
const pendingOperations = [];

// New timers, tasks, operations are recorded from myFile running
myFile.runContents();

function shouldContinue() {
  // Check one: Any pending setTimeout, setInterval, setImmediate?
  // Check two: Any pending OS tasks? (Like server listening to port)
  // Check three: Any pending long running operations? (Like fs module)
  return (
    pendingTimers.length || pendingOSTasks.length || pendingOperations.length
  );
}

// Entire body executes in one 'tick'
while (shouldContinue()) {
  // 1) Node looks at pendingTimers and sees if any functions
  // are ready to be called.  setTimeout, setInterval
  // 2) Node looks at pendingOSTasks and pendingOperations
  // and calls relevant callbacks
  // 3) Pause execution. Continue when...
  //  - a new pendingOSTask is done
  //  - a new pendingOperation is done
  //  - a timer is about to complete
  // 4) Look at pendingTimers. Call any setImmediate
  // 5) Handle any 'close' events
}

// exit back to terminal

Tenga en cuenta que el bucle de eventos nunca terminará hasta que haya tareas pendientes del sistema operativo. En otras palabras, la ejecución de su nodo nunca terminará hasta que haya solicitudes HTTP pendientes.

En su caso, ejecuta una asyncfunción, ya que siempre devolverá una promesa, programará su ejecución en la siguiente iteración del bucle. En su función asíncrona, programa otras 1000 promesas (solicitudes HTTP) a la vez en esa mapiteración. Después de eso, está esperando que todo se resuelva para finalizar el programa. Funcionará, seguro, a menos que su función de flecha anónima mapno arroje ningún error . Si una de sus promesas arroja un error y no lo maneja, algunas de las promesas no recibirán su devolución de llamada para que el programa finalice pero no para salir , porque el bucle de eventos evitará que salga hasta que se resuelva todas las tareas, incluso sin devolución de llamada. Como dice en elPromise.all docs : se rechazará tan pronto como se rechace la primera promesa.

Por lo tanto, su ECONNRESETerror no está relacionado con el nodo en sí, es algo con su red que hizo que la búsqueda arroje un error y luego evite que el bucle de eventos finalice. Con esta pequeña solución, podrá ver que todas las solicitudes se resuelven de forma asíncrona:

const fetch = require("node-fetch");

(async () => {
  try {
    const promises = Array(1000)
      .fill(1)
      .map(async (_value, index) => {
        try {
          const url = "https://google.com/";
          const response = await fetch(url);
          console.log(index, response.statusText);
          return response;
        } catch (e) {
          console.error(index, e.message);
        }
      });
    await Promise.all(promises);
  } catch (e) {
    console.error(e);
  } finally {
    console.log("Done");
  }
})();
Pedro Mutter
fuente
Hola, Pedro, gracias por el esfuerzo de explicar. Soy consciente de que Promise.all se rechazará cuando aparezca el primer rechazo de la promesa, pero en la mayoría de los casos no hubo ningún error para rechazar, por lo que todo se quedaría inactivo.
Risto Novik
1
> Repara que el bucle de eventos nunca terminará hasta que haya tareas pendientes del sistema operativo. En otras palabras, la ejecución de su nodo nunca terminará hasta que haya solicitudes HTTP pendientes. Este parece un punto interesante, las tareas del sistema operativo se gestionan a través del libuv?
Risto Novik
Supongo que libuv maneja más cosas relacionadas con las operaciones (cosas que realmente necesitan subprocesamiento múltiple). Pero podría estar equivocado, necesito ver más en profundidad
Pedro Mutter