Multihilos en Node.js con worker threads: tutorial de worker_threads
James Walker
25 de octubre de 2022
0 minutos de lecturaNode.js ofrece un entorno de ejecución de JavaScript de un solo hilo que impide que el código ejecute varias operaciones en paralelo. Si tu aplicación suele usar una ejecución síncrona, es posible que se bloquee durante las operaciones de larga duración.
Sin embargo, Node.js en sí es una aplicación multihilo. Esto se nota cuando usas uno de los métodos asíncronos de la biblioteca estándar para realizar operaciones de E/S, como leer un archivo o hacer una solicitud de red. Estas tareas se delegan a un grupo independiente de hilos que Node crea y mantiene mediante la biblioteca C libuv. Aunque parece multihilo, las funciones asíncronas aún pueden bloquear el bucle de eventos principal.
Pero ¿qué pasa si quieres crear tus propios hilos independientes para tu aplicación Node.js? El multihilo puede mejorar considerablemente el rendimiento de los flujos de trabajo que requieren mucho uso de CPU, ya que permite ejecutar tareas arbitrarias en paralelo. Aunque Node.js no ofrece multihilo real, puedes crear algo similar con el módulo worker_threads. En este artículo explicaremos qué hace y mostraremos cómo usarlo en algunas aplicaciones del mundo real.
¿Qué son los worker threads?
El módulo worker_threads implementa una forma de threading que te permite agregar paralelismo a tu propia aplicación. El código que se ejecuta en un worker thread corre en un proceso hijo independiente, lo que evita que bloquee la aplicación principal.
Los worker threads no son hilos reales en el sentido tradicional. Son procesos distintos, lo que significa que no pueden acceder directamente al contexto de ejecución de sus procesos principales. La comunicación entre los worker threads y tu aplicación se realiza mediante un sistema de mensajería basado en eventos.
Aunque los worker threads no convierten Node.js en un lenguaje realmente multihilo, en muchos escenarios del mundo real la diferencia es meramente académica. Ofrecen un mecanismo práctico para ejecutar varios «hilos» de ejecución de forma concurrente y sacar las tareas intensivas del bucle principal.
Casos de uso de los worker threads
Puedes usar worker threads siempre que realices operaciones costosas que requieran mucho uso de CPU. No son adecuados para acelerar tareas de E/S debido a la sobrecarga que implica cada hilo. Las utilidades de E/S asíncronas integradas en Node.js serán más rápidas y eficientes para las tareas del sistema de archivos y de red.
Hay muchos casos en los que los worker threads pueden ser útiles. Estos son algunos de los más comunes en los que podrías aprovecharlos:
Redimensionamiento de imágenes: Redimensionar imágenes grandes puede tomar varios segundos, y las demoras se acumulan rápidamente si necesitas generar varios tamaños. Esto es común en las aplicaciones que convierten las fotos cargadas en miniaturas y en formatos pequeños y grandes. Podrías usar tres worker threads para empezar a generar todos los tamaños al mismo tiempo y reducir la duración total del proceso.
Compresión de video: La compresión de video es una de las tareas de computación más exigentes. Los worker threads pueden acelerarla al procesar varios fotogramas en paralelo y luego enviar los resultados al hilo principal.
Cifrado de archivos y otras operaciones criptográficas: Las operaciones criptográficas son complejas por diseño. Cifrar y descifrar archivos, generar claves secretas y verificar firmas pueden causar demoras perceptibles en un programa cuando se ejecutan en el hilo principal.
Ordenamiento y búsqueda de grandes cantidades de datos: Filtrar y ordenar datos requiere muchas iteraciones para comparar cada valor. Puedes acelerar estas tareas con worker threads que examinen varios conjuntos de datos en paralelo.
Operaciones matemáticas complejas: Los cálculos matemáticos —como generar números primos, factorizar números grandes y realizar análisis complejos de datos— requieren mucho uso de CPU por naturaleza. Ejecutar parte del trabajo en un hilo independiente puede liberar el bucle principal para que se ocupe de otras tareas.
En todas estas operaciones, la lentitud se debe a que la CPU pasa mucho tiempo ejecutando código, en lugar de leer datos del disco o de la red. Son tareas iterativas, así que la mejor forma de mejorar el rendimiento es aumentar el número de iteraciones que se realizan en paralelo. Los worker threads permiten lograrlo.
Uso del módulo worker threads
Para crear worker threads, importa el módulo worker_threads de Node y llama al constructor new Worker(). Se iniciará un nuevo proceso para ejecutar el archivo JavaScript que especifiques. Puedes intercambiar mensajes con el worker para recibir notificaciones cuando ocurran eventos, haya datos listos para procesar o se produzca un error. Si has usado Web Workers en un navegador, el concepto de worker thread te resultará familiar.
Este es el ejemplo más sencillo de cómo ejecutar un archivo JavaScript en un worker thread:
Copia este fragmento de código y guárdalo en un archivo llamado worker-demo.js en tu directorio de trabajo. Cuando ejecutes el código con Node.js, deberías obtener el siguiente resultado:
Este código sencillo demuestra los fundamentos de los worker threads. El código fuente puede funcionar como hilo principal o como worker thread. El módulo worker_threads ofrece una exportación llamada isMainThread que te permite comprobar si el código se está ejecutando como hilo principal.
Cuando el archivo se ejecuta en el hilo principal —es decir, cuando lo inicias desde la terminal—, se ejecuta la primera rama de la instrucción if. Esto crea una nueva instancia de Worker.
El primer parámetro que se pasa al constructor Worker es la ruta al archivo JavaScript que se ejecutará en el worker. En este ejemplo, puedes usar la variable global __filename, porque el mismo archivo contiene el código del hilo principal y del worker thread. El segundo parámetro del hilo Worker es un objeto de opciones. La propiedad workerData de este objeto te permite pasar valores al worker thread.
El resto del código de la sección del hilo principal configura escuchas de eventos en la instancia de Worker. Estas te permiten responder a mensajes, errores y finalizaciones del proceso que ocurran dentro del worker.
Cuando el código de ejemplo se ejecuta en un worker thread, la variable isMainThread tendrá el valor false y se ejecutará la rama else. Esta usa la exportación workerData del módulo worker_threads para acceder a los datos que se pasaron desde el hilo principal. La exportación parentPort proporciona una interfaz con el hilo principal, mientras que la función postMessage() te permite enviar datos de vuelta al hilo principal, donde se mostrarán como un evento message.
Esto significa que al ejecutar worker-demo.js se produce la siguiente secuencia de acciones:
Se crea un nuevo worker y se le pasan los datos
hello.El código que se ejecuta en el worker accede a sus datos y envía un nuevo mensaje cordial al hilo principal.
La escucha de eventos del hilo principal recibe el mensaje enviado desde el worker thread y lo muestra en la consola.
El worker thread ya no tiene más código que ejecutar, así que se cierra. El hilo principal recibe una notificación mediante el evento
exit.
Ahora puedes probar algunos ejemplos de worker threads del mundo real.
Uso de worker threads para redimensionar imágenes
Este código convierte una imagen a tres tamaños distintos en paralelo. Es mucho más rápido que procesar cada tamaño de forma secuencial.
Primero, crea el código para redimensionar la imagen. Guárdalo en resize-worker.js:
Crea el código del hilo principal en un archivo nuevo llamado resize-main.js:
Usa npm para instalar el módulo sharp, que ofrece la función para redimensionar imágenes:
Luego, coloca una imagen grande en tu directorio de trabajo y llámala image.jpg. Puedes usar esta colorida foto de hilos de Unsplash. Ejecuta el código con el siguiente comando:
Deberías ver los tres mensajes «Resizing» aparecer al instante. El hilo principal recorre los tamaños solicitados, crea un nuevo hilo para cada uno y espera a que terminen. Así, los tamaños se generan en paralelo sin bloquear el hilo principal.
Uso de worker threads para redimensionar videos
Este fragmento de código muestra cómo redimensionar un video en un worker thread con [FFmpeg. Esto libera el hilo principal para que siga realizando otras tareas mientras termina el proceso intensivo de redimensionamiento del video.
Guarda el código del worker en un archivo nuevo llamado video-worker.js:
Ahora guarda el código del hilo principal en un archivo nuevo llamado video-main.js:
Instala las dependencias de npm que requiere el código:
El ejemplo usa el popular codificador ffmpeg. El paquete fluent-ffmpeg es un wrapper de Node.js para las bibliotecas ffmpeg existentes en tu sistema. Tendrás que [instalar ffmpeg antes de poder ejecutar el código. La mayoría de los entornos Linux ya lo incluyen, pero es posible que quienes usan Windows y Mac tengan que instalarlo manualmente.
Puedes comprobar si ffmpeg está disponible ejecutando este comando:
Si aparece el número de versión, significa que ya está instalado. Si recibes un mensaje similar a «command ‘ffmpeg’ not found», sigue las instrucciones del sitio web de ffmpeg para descargar la biblioteca correcta para tu sistema. Ffmpeg también está disponible en los repositorios de paquetes de la mayoría de las distribuciones populares de Linux y en Homebrew para macOS:
Ahora puedes probar a redimensionar un archivo de video en segundo plano. Si no tienes un archivo propio, puedes descargar este clip de Pexels. Guárdalo como video.mp4 en tu directorio de trabajo.
Luego, ejecuta el script para reducir el tamaño del clip a 1280x720:
Este ejemplo funciona de manera un poco distinta al anterior. Usa el concepto de «grupo de workers» para mejorar la eficiencia. A diferencia del ejemplo anterior, este no termina automáticamente el proceso cuando finaliza el redimensionamiento. El worker thread sigue activo y escuchando mensajes, así que tendrás que terminarlo presionando Ctrl+C en la terminal.
Los grupos de workers pueden ayudar a reducir el consumo de recursos. Crear un worker agrega sobrecarga cada vez, así que es recomendable iniciar una cantidad fija de hilos y reutilizarlos cuando sea posible. Poolifier, Piscina y otras bibliotecas similares facilitan el uso de worker threads de esta manera. Se encargan de crear hilos, hasta el límite que especifiques, para atender nuevas tareas. Las tareas siguientes se asignan a los hilos existentes a medida que quedan disponibles.
Uso de workers para cifrar archivos
Este ejemplo muestra cómo combinar worker_threads con el módulo crypto de Node.js para cifrar un archivo en un hilo independiente.
Primero, guarda el código del worker thread en un archivo nuevo llamado encrypt-worker.js:
Copia este código del hilo principal en un archivo nuevo llamado encrypt-main.js:
Crea un archivo de texto sencillo en tu directorio de trabajo para cifrarlo. Usa el siguiente comando en la terminal:
Ahora ejecuta el código para cifrar el archivo:
En este ejemplo, el worker thread envía un mensaje para indicarle al usuario la ruta del archivo de salida y la clave secreta que se generó.
Guía rápida de worker threads
Node.js es un entorno de ejecución de un solo hilo. Esto se debe a que JavaScript es un lenguaje síncrono y bloqueante que ejecuta todo en un solo hilo. Los componentes asíncronos de la biblioteca estándar de Node.js parecen no bloquear la ejecución porque usan un grupo independiente de hilos.
El módulo worker_threads ofrece una solución similar que puedes incluir en tu propio código. No es multihilo real, pero en muchas situaciones es lo suficientemente parecido y te permite ejecutar código en paralelo fuera del hilo principal.
En este artículo explicamos qué son los hilos de trabajo, cuándo puedes usarlos y cómo empezar a incorporarlos en tu proyecto. En la próxima entrega de esta serie analizaremos las ventajas y desventajas de los hilos de trabajo y los compararemos con las implementaciones de subprocesos múltiples de otros lenguajes.
