Subprocesamiento en Node.js: módulo trabajador_procesos

El 18 de enero, se anunció la plataforma Node.js versión 11.7.0 . Entre los cambios notables en esta versión, se puede observar la conclusión de la categoría de módulos experimentales worker_threads, que apareció en Node.js 10.5.0 . Ahora no se necesita la bandera --experimental-worker para usarlo. Este módulo, desde su inicio, se ha mantenido bastante estable y, por lo tanto, se tomó la decisión , reflejada en Node.js 11.7.0.

El autor del material, cuya traducción estamos publicando, ofrece discutir las capacidades del módulo worker_threads, en particular, quiere hablar sobre por qué se necesita este módulo y cómo se implementa el subprocesamiento múltiple en JavaScript y Node.js por razones históricas. Aquí hablaremos sobre qué problemas están asociados con la escritura de aplicaciones JS multiproceso, sobre las formas existentes de resolverlos y sobre el futuro del procesamiento de datos en paralelo utilizando los llamados "hilos de trabajo", que a veces se llaman "hilos de trabajo" o simplemente "trabajadores".

La vida en un mundo de un solo hilo.


JavaScript fue concebido como un lenguaje de programación de un solo hilo que se ejecuta en un navegador. "Single-thread" significa que en el mismo proceso (en los navegadores modernos estamos hablando de pestañas separadas del navegador), solo se puede ejecutar un conjunto de instrucciones a la vez.

Esto simplifica el desarrollo de aplicaciones, facilita el trabajo de los programadores. Inicialmente, JavaScript era un lenguaje adecuado solo para agregar algunas características interactivas a las páginas web, por ejemplo, algo así como la validación de formularios. Entre las tareas para las que JS fue diseñado, no había nada particularmente complicado que requiera subprocesamiento múltiple.

Ryan Dahl , creador de Node.js, vio una oportunidad interesante en esta restricción de idioma. Quería implementar una plataforma de servidor basada en un subsistema de E / S asíncrono. Esto significaba que el programador no necesitaba trabajar con hilos, lo que simplifica enormemente el desarrollo para una plataforma similar. Al desarrollar programas diseñados para la ejecución de código paralelo, pueden surgir problemas que son muy difíciles de resolver. Supongamos que si varios subprocesos intentan acceder a la misma área de memoria, esto puede conducir al llamado "estado de carrera de proceso" que interrumpe el programa. Tales errores son difíciles de reproducir y corregir.

¿La plataforma Node.js tiene un solo subproceso?


¿Las aplicaciones Node.js tienen un solo subproceso? Sí, en cierto modo lo es. De hecho, Node.js le permite realizar ciertas acciones en paralelo, pero para esto, el programador no necesita crear hilos o sincronizarlos. La plataforma Node.js y el sistema operativo realizan operaciones paralelas de entrada / salida utilizando sus propios medios, y cuando llega el momento del procesamiento de datos utilizando nuestro código JavaScript, funciona en modo de subproceso único.

En otras palabras, todo excepto nuestro código JS funciona en paralelo. En bloques síncronos de código JavaScript, los comandos siempre se ejecutan uno a la vez, en el orden en que se presentan en el código fuente:

let flag = false function doSomething() {  flag = true  //    -  (     flag)...  //      ,     flag   true.  // -       ,  //      . } 

Todo esto es excelente, si todo nuestro código está ocupado con E / S asincrónicas. El programa consta de pequeños bloques de código síncrono que operan rápidamente en los datos, por ejemplo, enviados a archivos y transmisiones. El código de los fragmentos del programa es tan rápido que no bloquea la ejecución del código de sus otros fragmentos. Mucho más tiempo que la ejecución del código para esperar los resultados de las E / S asíncronas. Considere un pequeño ejemplo:

 db.findOne('SELECT ... LIMIT 1', function(err, result) { if (err) return console.error(err) console.log(result) }) console.log('Running query') setTimeout(function() { console.log('Hey there') }, 1000) 

Es posible que la consulta a la base de datos que se muestra aquí demore aproximadamente un minuto, pero el mensaje de Running query se enviará a la consola inmediatamente después de que se inicie esta consulta. En este caso, el mensaje Hey there se mostrará un segundo después de que se ejecute la solicitud, independientemente de si su ejecución se ha completado o no. Nuestra aplicación Node.js simplemente llama a la función que inicia la solicitud, mientras que la ejecución de su otro código no está bloqueada. Una vez completada la solicitud, se informará a la aplicación acerca de esto mediante la función de devolución de llamada, y luego recibirá una respuesta a esta solicitud.

Tareas intensivas de CPU


¿Qué sucede si nosotros, a través de JavaScript, necesitamos hacer computación pesada? Por ejemplo, ¿para procesar un gran conjunto de datos almacenados en la memoria? Esto puede llevar al hecho de que el programa contendrá un fragmento de código síncrono, cuya ejecución lleva mucho tiempo y bloquea la ejecución de otro código. Imagine que estos cálculos tardan 10 segundos. Si estamos hablando de un servidor web que procesa una determinada solicitud, esto significará que no podrá procesar otras solicitudes durante al menos 10 segundos. Este es un gran problema. De hecho, los cálculos que son más largos que 100 milisegundos ya pueden causar este problema.

JavaScript y la plataforma Node.js no se diseñaron originalmente para resolver tareas que utilizan los recursos del procesador de forma intensiva. En el caso de JS ejecutándose en el navegador, realizar tales tareas significa "frenos" en la interfaz de usuario. En Node.js, esto puede limitar la capacidad de solicitar a la plataforma que realice nuevas tareas de E / S asincrónicas y la capacidad de responder a eventos asociados con su finalización.

Volvamos a nuestro ejemplo anterior. Imagine que, en respuesta a una consulta a la base de datos, llegaron varios miles de registros cifrados que, en código JS sincrónico, deben descifrarse:

 db.findAll('SELECT ...', function(err, results) { if (err) return console.error(err) //      ,    . for (const encrypted of results) {   const plainText = decrypt(encrypted)   console.log(plainText) } }) 

Los resultados, después de recibirlos, están en la función de devolución de llamada. Después de eso, hasta el final de su procesamiento, no se puede ejecutar ningún otro código JS. Por lo general, como ya se mencionó, la carga en el sistema creada por dicho código es mínima, realiza rápidamente las tareas que se le asignan. Pero en este caso, el programa recibió los resultados de la consulta, que tienen una cantidad considerable, y aún necesitamos procesarlos. Algo como esto puede tomar unos segundos. Si estamos hablando de un servidor con el que trabajan muchos usuarios, esto significará que pueden continuar trabajando solo después de la finalización de una operación de uso intensivo de recursos.

¿Por qué JavaScript nunca tendrá hilos?


Dado lo anterior, puede parecer que para resolver problemas informáticos pesados ​​en Node.js necesita agregar un nuevo módulo que le permitirá crear hilos y administrarlos. ¿Cómo puedes prescindir de algo así? Es muy triste que aquellos que usan una plataforma de servidor madura, como Node.js, no tengan los medios para resolver los problemas asociados con el procesamiento de grandes cantidades de datos.

Todo esto es cierto, pero si agrega la capacidad de trabajar con secuencias en JavaScript, esto conducirá a un cambio en la naturaleza misma de este lenguaje. En JS, no puede simplemente agregar la capacidad de trabajar con hilos, por ejemplo, en forma de un nuevo conjunto de clases o funciones. Para hacer esto, debe cambiar el idioma en sí. En lenguajes que admiten subprocesos múltiples, el concepto de sincronización es ampliamente utilizado. Por ejemplo, en Java, incluso algunos tipos numéricos no son atómicos. Esto significa que si no se utilizan mecanismos de sincronización para trabajar con ellos desde diferentes subprocesos, todo esto puede resultar, por ejemplo, después de que un par de subprocesos intenten simultáneamente cambiar el valor de la misma variable, varios bytes de dicha variable se establecerán en uno flujo, y algunos otros. Como resultado, dicha variable contendrá algo incompatible con el funcionamiento normal del programa.

Solución primitiva al problema: iteración del bucle de eventos


Node.js no ejecutará el siguiente bloque de código en la cola de eventos hasta que se complete el bloque anterior. Esto significa que para resolver nuestro problema, podemos setImmediate(callback) en partes representadas por fragmentos de código síncrono y luego usar una construcción del formulario setImmediate(callback) para planificar la ejecución de estos fragmentos. El código especificado por la función de callback en esta construcción se ejecutará después de que se completen las tareas de la iteración actual (tick) del bucle de eventos. Después de eso, se usa el mismo diseño para poner en cola el siguiente lote de cálculos. Esto permite no bloquear el ciclo de eventos y, al mismo tiempo, resolver problemas volumétricos.

Imagine que tenemos una gran matriz que necesita ser procesada, mientras que el procesamiento de cada elemento de dicha matriz requiere cálculos complejos:

 const arr = [/*large array*/] for (const item of arr) { //         } // ,   ,      . 

Como ya se mencionó, si decidimos procesar toda la matriz en una llamada, tomará demasiado tiempo y evitará que se ejecute otro código de aplicación. Por lo tanto, setImmediate(callback) esta gran tarea en partes y usaremos la setImmediate(callback) :

 const crypto = require('crypto') const arr = new Array(200).fill('something') function processChunk() { if (arr.length === 0) {   // ,      } else {   console.log('processing chunk');   //  10         const subarr = arr.splice(0, 10)   for (const item of subarr) {     //           doHeavyStuff(item)   }   //       setImmediate(processChunk) } } processChunk() function doHeavyStuff(item) { crypto.createHmac('sha256', 'secret').update(new Array(10000).fill(item).join('.')).digest('hex') } //       , ,   , //       . let interval = setInterval(() => { console.log('tick!') if (arr.length === 0) clearInterval(interval) }, 0) 

Ahora, de una vez, procesamos diez elementos de la matriz, después de lo cual, usando setImmediate() , planificamos el próximo lote de cálculos. Y esto significa que si necesita ejecutar más código en el programa, puede ejecutarse entre operaciones en el procesamiento de fragmentos de la matriz. Para esto, aquí, al final del ejemplo, hay un código que usa setInterval() .

Como puede ver, dicho código parece mucho más complicado que su versión original. Y, a menudo, el algoritmo puede ser mucho más complejo que el nuestro, lo que significa que, cuando se implementa, no será fácil dividir los cálculos en pedazos y comprender dónde, para lograr el equilibrio correcto, debe establecer setImmediate() , planeando el siguiente cálculo. Además, el código ahora resultó ser asíncrono, y si nuestro proyecto depende de bibliotecas de terceros, es posible que no podamos dividir el proceso de resolver una tarea difícil en partes.

Procesos de fondo


Quizás el enfoque anterior con setImmediate() funcionará bien para casos simples, pero está lejos de ser ideal. Además, los hilos no se usan aquí (por razones obvias) y tampoco tenemos la intención de cambiar el idioma para esto. ¿Es posible hacer un procesamiento paralelo de datos sin usar hilos? Sí, es posible, y para esto necesitamos algún tipo de mecanismo para el procesamiento de datos en segundo plano. Se trata de comenzar una determinada tarea, pasarle datos, y para que esta tarea, sin interferir con el código principal, use todo lo que necesita, pase todo el tiempo que necesita y luego devuelva los resultados a código principal Necesitamos algo similar al siguiente fragmento de código:

 //  script.js   ,    . const service = createService('script.js') //          service.compute(data, function(err, result) { //      }) 

La realidad es que en Node.js puedes usar procesos en segundo plano. El punto es que es posible crear una bifurcación del proceso e implementar el esquema de trabajo descrito anteriormente utilizando el mecanismo de mensajería entre los procesos hijo y padre. El proceso principal puede interactuar con el proceso descendiente, enviándole eventos y recibiéndolos. La memoria compartida no se usa con este enfoque. Todos los datos intercambiados por los procesos son "clonados", es decir, cuando un proceso realiza cambios en una instancia de estos datos, estos cambios no son visibles para otro proceso. Esto es similar a una solicitud HTTP: cuando un cliente la envía al servidor, el servidor recibe solo una copia de la misma. Si los procesos no usan memoria compartida, esto significa que con su operación simultánea es imposible crear un "estado de carrera", y que no necesitamos cargarnos con el trabajo con hilos. Parece que nuestro problema ha sido resuelto.

Es cierto, en realidad esto no es así. Sí, frente a nosotros está una de las soluciones a la tarea de realizar cálculos intensivos, pero, una vez más, es imperfecta. Crear una bifurcación de un proceso es una operación que requiere muchos recursos. Lleva tiempo completarlo. De hecho, estamos hablando de crear una nueva máquina virtual desde cero y de aumentar la cantidad de memoria consumida por el programa, debido a que los procesos no usan memoria compartida. Dado lo anterior, es apropiado preguntar si es posible, después de completar una tarea, reutilizar la bifurcación del proceso. Puede dar una respuesta positiva a esta pregunta, pero aquí debe recordar que está previsto transferir la bifurcación del proceso a varias tareas intensivas en recursos que se realizarán en él de forma sincronizada. Se pueden ver dos problemas aquí:

  • Aunque con este enfoque, el proceso principal no se bloquea, el proceso descendiente puede realizar las tareas que se le transfieren solo secuencialmente. Si tenemos dos tareas, una de las cuales toma 10 segundos, y la segunda toma 1 segundo, y las vamos a completar en este orden, entonces es poco probable que nos guste la necesidad de esperar a que se complete la primera antes de la segunda. Dado que estamos creando horquillas de proceso, nos gustaría usar las capacidades del sistema operativo para planificar tareas y usar los recursos informáticos de todos los núcleos de nuestro procesador. Necesitamos algo que se parezca a trabajar en una computadora para una persona que escucha música y viaja a través de páginas web. Para hacer esto, puede crear dos procesos fork y organizar la ejecución paralela de tareas con su ayuda.
  • Además, si una de las tareas lleva al final del proceso con un error, todas las tareas enviadas a dicho proceso no se procesarán.

Para resolver estos problemas, necesitamos varios procesos de fork, no uno, pero tendremos que limitar su número, ya que cada uno de ellos requiere recursos del sistema y lleva tiempo crearlos. Como resultado, siguiendo el patrón de sistemas que admiten conexiones de bases de datos, necesitamos algo así como un conjunto de procesos listos para usar. El sistema de gestión de agrupación de procesos, al recibir nuevas tareas, utilizará procesos libres para ejecutarlas, y cuando un determinado proceso haga frente a la tarea, podrá asignarle una nueva. Existe la sensación de que dicho esquema de trabajo no es fácil de implementar y, de hecho, lo es. Usaremos el paquete trabajador-granja para implementar este esquema:

 //   const workerFarm = require('worker-farm') const service = workerFarm(require.resolve('./script')) service('hello', function (err, output) { console.log(output) }) // script.js //      - module.exports = (input, callback) => { callback(null, input + ' ' + world) } 

Módulo de hilos de trabajo


Entonces, ¿está resuelto nuestro problema? Sí, podemos decir que está resuelto, pero con este enfoque, se requiere mucha más memoria de la que sería necesaria si tuviéramos una solución multiproceso. Los subprocesos consumen muchos menos recursos en comparación con las horquillas de proceso. Es por eso que el módulo worker_threads apareció en worker_threads

Los subprocesos de trabajo se ejecutan en un contexto aislado. Intercambian información con el proceso principal utilizando mensajes. Esto nos salva del problema de "condición de carrera" al que están sujetos los entornos de subprocesos múltiples. Al mismo tiempo, existen flujos de trabajadores en el mismo proceso que el programa principal, es decir, con este enfoque, en comparación con el uso de horquillas de proceso, se usa mucha menos memoria.

Además, al trabajar con trabajadores, puede usar la memoria compartida. Entonces, específicamente para este propósito, se SharedArrayBuffer objetos del tipo SharedArrayBuffer . Deben usarse solo en aquellos casos en que el programa necesita realizar un procesamiento complejo de grandes cantidades de datos. Le permiten guardar los recursos necesarios para serializar y deserializar datos al organizar el intercambio de datos entre los trabajadores y el programa principal a través de mensajes.

Trabajador Flujos de trabajadores


Si usa la plataforma Node.js antes de la versión 11.7.0, para habilitar el trabajo con el módulo worker_threads , debe usar el --experimental-worker al iniciar --experimental-worker

Además, vale la pena recordar que crear un trabajador (así como crear una secuencia en cualquier idioma), aunque requiere muchos menos recursos que crear una bifurcación de un proceso, también crea una cierta carga en el sistema. Quizás en su caso, incluso esta carga puede ser demasiado. En tales casos, la documentación recomienda crear un grupo de trabajadores. Si necesita esto, por supuesto, puede crear su propia implementación de dicho mecanismo, pero tal vez debería buscar algo adecuado en el registro de NPM.

Considere un ejemplo de trabajo con hilos de trabajo. Tendremos un archivo principal, index.js , en el que crearemos un subproceso de trabajo y le pasaremos algunos datos para su procesamiento. La API correspondiente está basada en eventos, pero voy a usar una promesa aquí que se resuelve cuando llega el primer mensaje del trabajador:

 // index.js //    Node.js   11.7.0,  //      node --experimental-worker index.js const { Worker } = require('worker_threads') function runService(workerData) { return new Promise((resolve, reject) => {   const worker = new Worker('./service.js', { workerData });   worker.on('message', resolve);   worker.on('error', reject);   worker.on('exit', (code) => {     if (code !== 0)       reject(new Error(`Worker stopped with exit code ${code}`));   }) }) } async function run() { const result = await runService('world') console.log(result); } run().catch(err => console.error(err)) 

Como puede ver, usar el mecanismo de flujo de flujo de trabajo es bastante simple. Es decir, al crear un trabajador, debe pasar la ruta al archivo con el código y los datos del Worker diseñador del Worker . Recuerde que estos datos están clonados, no almacenados en la memoria compartida. Después de comenzar el trabajador, esperamos un mensaje de él, escuchando el evento del message .

Arriba, al crear un objeto de tipo Worker , le pasamos al constructor el nombre del archivo con el código de trabajador: service.js . Aquí está el código para este archivo:

 const { workerData, parentPort } = require('worker_threads') // , ,    , //    . parentPort.postMessage({ hello: workerData }) 

Hay dos cosas que nos interesan en el código del trabajador. Primero, necesitamos los datos transmitidos por la aplicación principal. En nuestro caso, están representados por la variable workerData . En segundo lugar, necesitamos un mecanismo para transmitir información a la aplicación principal. Este mecanismo está representado por el objeto parentPort , que tiene el método postMessage() , mediante el cual pasamos los resultados del procesamiento de datos a la aplicación principal. Así es como funciona todo.

Aquí hay un ejemplo muy simple, pero usando los mismos mecanismos puedes construir estructuras mucho más complejas. Por ejemplo, desde la transmisión de un trabajador, puede enviar muchos mensajes a la transmisión principal que contienen información sobre el estado del procesamiento de datos en caso de que nuestra aplicación necesite un mecanismo similar. Incluso del trabajador, los resultados del procesamiento de datos se pueden devolver en partes. Por ejemplo, algo como esto puede ser útil en una situación en la que un trabajador está ocupado, por ejemplo, procesando miles de imágenes, y usted, sin esperar a que se procesen todas, desea notificar a la aplicación principal la finalización del procesamiento de cada una de ellas.

Los detalles sobre el módulo worker_threads se pueden encontrar aquí .

Trabajadores web


Es posible que haya oído hablar de los trabajadores web. Están diseñados para su uso en un entorno de cliente, esta tecnología ha existido durante mucho tiempo y goza de un buen soporte para los navegadores modernos. La API para trabajar con trabajadores web es diferente de la que nos proporciona el módulo Node.js worker_threads , se trata de las diferencias en los entornos en los que trabajan. Sin embargo, estas tecnologías pueden resolver problemas similares. Por ejemplo, los trabajadores web pueden usarse en aplicaciones cliente para realizar cifrado y descifrado de datos, su compresión y descompresión. Con su ayuda, puede procesar imágenes, implementar sistemas de visión por computadora (por ejemplo, estamos hablando de reconocimiento facial) y resolver otros problemas similares en un navegador.

Resumen


worker_threads — Node.js. , , . , , , « ». , ? , worker_threads , Node.js worker-farm , worker_threads , Node.js .

Estimados lectores! Node.js-?

Source: https://habr.com/ru/post/437984/


All Articles