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 鈥嬧媏n 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