Forzar ~ Synchronous Node.js IPC

8

Tengo un servidor Node que crea un proceso secundario con el fork()uso de IPC. En algún momento, el niño envía los resultados al padre a aproximadamente 10Hz como parte de una tarea de larga duración. Cuando la carga útil que se pasa process.send()es pequeña, todo funciona bien: cada mensaje que envío se recibe ~ inmediatamente y el padre lo procesa.

Sin embargo, cuando la carga útil es 'grande' (no he determinado el límite de tamaño exacto) en lugar de ser recibida de inmediato por el padre, todas las cargas se envían primero, y solo una vez que el niño realiza su tarea de larga duración, el padre recibe y procesar los mensajes.

tl; dr visual:

Bueno (sucede con poca carga útil):

child:  send()
parent: receive()
child:  send()
parent: receive()
child:  send()
parent: receive()
...

Malo (sucede con gran carga útil):

child:  send()
child:  send()
child:  send()
(repeat many times over many seconds)
...
parent: receive()
parent: receive()
parent: receive()
parent: receive()
...
  1. ¿Es esto un error? (Editar: el comportamiento solo ocurre en OS X, no en Windows o Linux)
  2. ¿Hay alguna forma de evitar esto, aparte de tratar de mantener pequeña mi carga útil de IPC?

Edición 2 : el siguiente código de muestra utiliza el contador de tiempo y de iteración para seleccionar cuándo enviar una actualización. (En mi código actual también es posible enviar una actualización después de n iteraciones, o después de que el ciclo logre ciertos resultados). Como tal, reescribir el código para usar setInterval/ en setTimeoutlugar de un ciclo es un último recurso para mí, ya que me requiere para eliminar características.

Editar : Aquí hay un código de prueba que reproduce el problema. Sin embargo, solo se reproduce en OS X, no en Windows o Linux:

server.js

const opts = {stdio:['inherit', 'inherit', 'inherit', 'ipc']};
const child = require('child_process').fork('worker.js', [], opts);

child.on('message', msg => console.log(`parent: receive() ${msg.data.length} bytes`, Date.now()));

require('http').createServer((req, res) => {
   console.log(req.url);
   const match = /\d+/.exec(req.url);
   if (match) {
      child.send(match[0]*1);
      res.writeHead(200, {'Content-Type':'text/plain'});
      res.end(`Sending packets of size ${match[0]}`);
   } else {
      res.writeHead(404, {'Content-Type':'text/plain'});
      res.end('what?');
   }
}).listen(8080);

trabajador.js

if (process.send) process.on('message', msg => run(msg));

function run(messageSize) {
   const msg = new Array(messageSize+1).join('x');
   let lastUpdate = Date.now();
   for (let i=0; i<1e7; ++i) {
      const now = Date.now();
      if ((now-lastUpdate)>200 || i%5000==0) {
         console.log(`worker: send()  > ${messageSize} bytes`, now);
         process.send({action:'update', data:msg});
         lastUpdate = Date.now();
      }
      Math.sqrt(Math.random());
   }
   console.log('worker done');
}

Aproximadamente alrededor de 8k el problema ocurre. Por ejemplo, al consultar http://localhost:8080/15vshttp://localhost:8080/123456

/15
worker: send()  > 15 bytes 1571324249029
parent: receive() 15 bytes 1571324249034
worker: send()  > 15 bytes 1571324249235
parent: receive() 15 bytes 1571324249235
worker: send()  > 15 bytes 1571324249436
parent: receive() 15 bytes 1571324249436
worker done
/123456
worker: send()  > 123456 bytes 1571324276973
worker: send()  > 123456 bytes 1571324277174
worker: send()  > 123456 bytes 1571324277375
child done
parent: receive() 123456 bytes 1571324277391
parent: receive() 123456 bytes 1571324277391
parent: receive() 123456 bytes 1571324277393

Experimentado en Nodo v12.7 y v12.12.

Phrogz
fuente
1
En lugar de poner en cola los mensajes en un bucle de bloqueo, ¿por qué no usar a setInterval()?
Patrick Roberts
@PatrickRoberts ¿Te preguntas por qué run()tiene un whilebucle? ¿Estás sugiriendo que cambiar eso a setInterval()resolverá mi problema? Para responder a la pregunta, creo que se está preguntando: uso un whilebucle porque esa función es el único propósito de este proceso de trabajo y (con pequeñas cargas útiles de IPC) no causó ningún problema que pudiera ver.
Phrogz
1
Bloquear así no sirve para nada. El uso de un mecanismo de sincronización sin bloqueo como setInterval()libera el ciclo de eventos para realizar E / S en segundo plano. No estoy diciendo que definitivamente resolverá este problema, pero parece una elección extraña escribirlo de la manera que lo ha hecho, solo porque puede hacerlo.
Patrick Roberts
@PatrickRoberts Gracias por el aporte. No lo escribí de esa manera "solo porque puedo", sino porque originalmente el código estaba basado en la consola sin IPC. Un ciclo while que imprime periódicamente los resultados parecía razonable en ese momento, pero está experimentando este problema (solo en macOS).
Phrogz
Escribir un bucle de bloqueo que sondee la hora actual hasta que se cumpla una condición basada en el tiempo es un antipatrón en JavaScript, punto. No importa si tenía IPC antes o no. Siempre prefiera un enfoque sin bloqueo utilizando setTimeout()o setInterval(). El cambio aquí es trivial.
Patrick Roberts

Respuestas:

3

Tener un ciclo de larga duración y bloqueo en combinación con sockets o descriptores de archivos en el nodo siempre es una indicación de que algo está mal hecho.

Sin poder probar toda la configuración, es difícil saber si mi afirmación es realmente correcta, pero los mensajes cortos probablemente se pueden pasar directamente en un fragmento al sistema operativo que luego lo pasa al otro proceso. Con el nodo de mensajes más grandes, debería esperar hasta que el sistema operativo pueda recibir más datos, por lo que el envío se pone en cola y, dado que tiene un bloqueo, whileel envío es una cola hasta que finalice el tiempo loop.

Entonces, para su pregunta, no es que no sea un error.

A medida que usa una versión reciente de nodejs, usaría una awaity, en asynclugar de crear una no bloqueante while similar a la sleepde esta respuesta . El awaitpermitirá que el bucle de eventos nodo para interceptar si processSomevuelve a la espera de Promise.

Para su código que realmente no refleja un caso de uso real, es difícil saber cómo resolverlo correctamente. Si no hace nada asíncrono processSomeque permita que la E / S intercepte, entonces debe hacerlo manualmente de forma regular, por ejemplo con a await new Promise(setImmediate);.

async function run() {
  let interval = setInterval(() => {
    process.send({action:'update', data:status()});
    console.log('child:  send()');
  }, 1/10)

  while(keepGoing()) {
    await processSome();
  }

  clearInterval(interval)
}
t.niese
fuente
Gracias por esta respuesta Según mi edición de la pregunta, mi código real tiene múltiples condiciones para enviar una actualización, solo una de las cuales se basa en el tiempo. Parece que has movido el processSome()código fuera del whilebucle. (O tal vez me estoy perdiendo algo crucial relacionado con las promesas.)
Phrogz
1
@Phrogz ah ok no, accidentalmente leí las llaves de forma incorrecta. process.send({action:'update', data:status()});Actualicé la respuesta para que se ejecute cuando every10Hzes cierto y processSomepara cada iteración de while. El awaitdebe permitir que el EvenLoop de nodo para interceptar incluso si processSomees no devuelve una promesa. Pero la razón de su problema sigue siendo que el ciclo está bloqueando.
t.niese
Dos comentarios sobre esta respuesta tal cual. Si processSome()no devuelve una promesa, entonces este enfoque aún bloquea las E / S (las microtasks como la continuación producida por esta awaitdeclaración se procesan antes de IO). Además, esto hará que la iteración funcione mucho más lentamente debido a la microtask en cola en cada iteración.
Patrick Roberts
@PatrickRoberts sí, tienes razón, tiene que devolver una promesa no resuelta.
t.niese
2

En cuanto a tu primera pregunta

¿Es esto un error? (Editar: el comportamiento solo ocurre en OS X, no en Windows o Linux)

Esto definitivamente no es un error y podría reproducirlo en mi Windows 10 (para el tamaño 123456). Se debe principalmente al almacenamiento intermedio del núcleo subyacente y al cambio de contexto por parte del sistema operativo, ya que dos procesos separados (no separados) se comunican a través de un descriptor IPC.

En cuanto a tu segunda pregunta

¿Hay alguna forma de evitar esto, aparte de tratar de mantener pequeña mi carga útil de IPC?

Si entiendo el problema correctamente, está tratando de resolver, para cada solicitud http, cada vez que el trabajador devuelve un fragmento al servidor, desea que el servidor lo procese antes de obtener el siguiente fragmento. Así es como entiendo cuando dijiste procesamiento de sincronización

Hay una manera de usar promesas, pero me gustaría usar generadores en los trabajadores. Es mejor orquestar el flujo a través del servidor y el trabajador

Fluir:

  1. El servidor envía un número entero al trabajador, lo que sea que reciba de la solicitud http
  2. Luego, el trabajador crea y ejecuta el generador para enviar el primer fragmento
  3. El trabajador cede después de enviar el trozo
  4. Solicitudes del servidor para más
  5. El trabajador genera más, ya que el servidor solicitó más (solo si está disponible)
  6. Si no hay más, el trabajador envía el final de los trozos
  7. El servidor solo registra que el trabajador ha terminado y no solicita más

server.js

const opts = {stdio:['inherit', 'inherit', 'inherit', 'ipc'], detached:false};
const child = require('child_process').fork('worker.js', [], opts);

child.on('message', (msg) => {
   //FLOW 7: Worker is done, just log
   if (msg.action == 'end'){
      console.log(`child ended for a particular request`)
   } else {
      console.log(`parent: receive(${msg.data.iter}) ${msg.data.msg.length} bytes`, Date.now())
      //FLOW 4: Server requests for more
      child.send('more')
   }   

});

require('http').createServer((req, res) => {
   console.log(req.url);
   const match = /\d+/.exec(req.url);   
   if (match) {
      //FLOW 1: Server sends integer to worker
      child.send(match[0]*1);
      res.writeHead(200, {'Content-Type':'text/plain'});
      res.end(`Sending packets of size ${match[0]}`);
   } else {
      res.writeHead(404, {'Content-Type':'text/plain'});
      res.end('what?');
   }
}).listen(8080);

trabajador.js

let runner
if (process.send) process.on('message', msg => {   
   //FLOW 2: Worker creates and runs a generator to send the first chunk
   if (parseInt(msg)) {
      runner = run(msg)
      runner.next()
   }
   //FLOW 5: Server asked more, so generate more chunks if available
   if (msg == "more") runner.next()

});

//generator function *
function* run(messageSize) {
   const msg = new Array(messageSize+1).join('x');
   let lastUpdate = Date.now();
   for (let i=0; i<1e7; ++i) {
      const now = Date.now();
      if ((now-lastUpdate)>200 || i%5000==0) {
         console.log(`worker: send(${i})  > ${messageSize} bytes`, now);
         let j = i         
         process.send({action:'update', data:{msg, iter:j}});
         //FLOW 3: Worker yields after sending the chunk
         yield
         lastUpdate = Date.now();
      }
      Math.sqrt(Math.random());
   }
   //FLOW 6: If no more, worker sends end signal
   process.send({action:'end'});
   console.log('worker done');
}

Si conocemos el caso de uso exacto, podría haber mejores formas de programarlo. Esta es solo una forma de sincronizar el proceso secundario que retiene gran parte de su código fuente original.

manikawnth
fuente
1

Si necesita garantizar que se reciba un mensaje antes de enviar el siguiente, puede esperar a que el maestro confirme la recepción. Esto retrasará el envío del siguiente mensaje, por supuesto, pero dado que su lógica se basa tanto en el tiempo como en el número de iteración para determinar si se debe enviar un mensaje, entonces puede estar bien para su caso.

La implementación necesitará que cada trabajador cree una promesa para cada mensaje enviado y espere una respuesta del maestro antes de resolver la promesa. Esto también significa que debe identificar qué mensaje se reconoce en función de una identificación de mensaje o algo único si tiene más de un mensaje o trabajador simultáneamente.

aquí está el código modificado

server.js

const opts = {stdio:['inherit', 'inherit', 'inherit', 'ipc']};
const child = require('child_process').fork('worker.js', [], opts);

child.on('message', msg =>  {
    console.log(`parent: receive() ${msg.data.length} bytes`, Date.now())
    // reply to the child with the id
    child.send({ type: 'acknowledge', id: msg.id });
});

...

trabajador.js

const pendingMessageResolves = {};

if (process.send) process.on('message', msg => { 
    if (msg.type === 'acknowledge') {
        // call the stored resolve function
        pendingMessageResolves[msg.id]();
        // remove the function to allow the memory to be freed
        delete pendingMessageResolves[msg.id]
    } else {
        run(msg) 
    }
});

const sendMessageAndWaitForAcknowledge = (msg) => new Promise(resolve => {
    const id = new uuid(); // or any unique field
    process.send({ action:'update', data: msg, id });
    // store a reference to the resolve function
    pendingMessageResolves[id] = resolve;
})

async function run(messageSize) {
    const msg = new Array(messageSize+1).join('x');
    let lastUpdate = Date.now();
    for (let i=0; i<1e7; ++i) {
        const now = Date.now();
        if ((now-lastUpdate)>200 || i%5000==0) {
            console.log(`worker: send()  > ${messageSize} bytes`, now);
            await sendMessageAndWaitForAcknowledge(msg); // wait until master replies
            lastUpdate = Date.now();
        }
        Math.sqrt(Math.random());
    }
    console.log('worker done');
}

PD: No probé el código, por lo que podría necesitar algunos ajustes, pero la idea debería ser válida.

gafi
fuente
1

Si bien estoy de acuerdo con otros en que la solución óptima sería aquella en la que el proceso secundario puede ceder voluntariamente el control al final de cada ciclo, lo que permite que se ejecuten los procesos de descarga del búfer, hay una solución fácil / rápida / sucia que lo hace casi sincrónico comportamiento, y eso es hacer que el niño sendllame al bloqueo.

Use lo mismo server.jsque antes, y casi lo mismo worker.js, con solo una línea agregada:

trabajador.js

if (process.send) process.on('message', msg => run(msg));

// cause process.send to block until the message is actually sent                                                                                
process.channel.setBlocking(true);

function run(messageSize) {
   const msg = new Array(messageSize+1).join('x');
   let lastUpdate = Date.now();
   for (let i=0; i<1e6; ++i) {
      const now = Date.now();
      if ((now-lastUpdate)>200 || i%5000==0) {
         console.error(`worker: send()  > ${messageSize} bytes`, now);
         process.send({action:'update', data:msg});
         lastUpdate = Date.now();
      }
      Math.sqrt(Math.random());
   }
   console.log('worker done');
}

Salida:

/123456
worker: send()  > 123456 bytes 1572113820591
worker: send()  > 123456 bytes 1572113820630
parent: receive() 123456 bytes 1572113820629
parent: receive() 123456 bytes 1572113820647
worker: send()  > 123456 bytes 1572113820659
parent: receive() 123456 bytes 1572113820665
worker: send()  > 123456 bytes 1572113820668
parent: receive() 123456 bytes 1572113820678
worker: send()  > 123456 bytes 1572113820678
parent: receive() 123456 bytes 1572113820683
worker: send()  > 123456 bytes 1572113820683
parent: receive() 123456 bytes 1572113820687
worker: send()  > 123456 bytes 1572113820687
worker: send()  > 123456 bytes 1572113820692
parent: receive() 123456 bytes 1572113820692
parent: receive() 123456 bytes 1572113820696
worker: send()  > 123456 bytes 1572113820696
parent: receive() 123456 bytes 1572113820700
worker: send()  > 123456 bytes 1572113820700
parent: receive() 123456 bytes 1572113820703
worker: send()  > 123456 bytes 1572113820703
parent: receive() 123456 bytes 1572113820706
worker: send()  > 123456 bytes 1572113820706
parent: receive() 123456 bytes 1572113820709
worker: send()  > 123456 bytes 1572113820709
parent: receive() 123456 bytes 1572113820713
worker: send()  > 123456 bytes 1572113820714
worker: send()  > 123456 bytes 1572113820721
parent: receive() 123456 bytes 1572113820722
parent: receive() 123456 bytes 1572113820725
worker: send()  > 123456 bytes 1572113820725
parent: receive() 123456 bytes 1572113820727
Viejo pro
fuente
Definir la declaración de bloqueo directamente en el código fuente es una idea inteligente. Producirá un cuello de botella que no se puede solucionar. La razón es que el código fuente se almacena en el disco duro, lo que dificulta el uso de un motor de reglas para cambiar el comportamiento sobre la marcha.
Manuel Rodriguez