Inicio / Artículos / Explicación de la concurrencia en Node.js: libuv, el bucle de eventos y el pool de hilos

Explicación de la concurrencia en Node.js: libuv, el bucle de eventos y el pool de hilos

Aprenda cómo Node.js utiliza las primitivas del sistema operativo de libuv y el pool de hilos de trabajo para manejar E/S asíncrona, además de los problemas comunes en los pools de hilos y consejos para su ajuste.

2284 palabras

Casi todo desarrollador escucha la frase “Node.js es de un solo hilo” durante su primera semana aprendiendo la plataforma. Sin embargo, en la práctica, un proceso de Node.js puede leer cientos de archivos simultáneamente, buscar miles de registros DNS y manejar decenas de miles de conexiones a bases de datos abiertas, al tiempo que sigue ejecutando código sin detenerse.

Si el propio JavaScript solo funciona en un hilo, ¿cómo es posible que un servidor Node.js siga respondiendo a las solicitudes mientras descarga un archivo de varios gigabytes desde un disco en rotación?

El mecanismo detrás de esto es libuv, una biblioteca en C diseñada específicamente para Node.js que gestiona la entrada y salida asíncrona y sin bloqueos.

Comprender realmente cómo libuv distribuye las tareas fuera del hilo de JavaScript no es solo un conocimiento teórico. Es lo que explica por qué una consulta a una base de datos se comporta de manera diferente a una operación de hash en términos de rendimiento, por qué ajustar una sola variable de entorno puede cambiar significativamente la velocidad (o lentitud) con la que responde tu API en producción, y cómo puedes detectar y evitar cuellos de botella ocultos en tus servicios.

¿Qué es libuv?

Node.js no es un motor monolítico único: se trata de una pila formada por varias capas que colaboran entre sí:

+-------------------------------------------------------------+
|                      Your Application                       |
+-------------------------------------------------------------+
|                    Node.js Core (JS / C++)                  |
+------------------------------+------------------------------+
|     V8 Engine (Google)       |            libuv             |
|   (Executes JavaScript)      |   (Event Loop & Async I/O)   |
+------------------------------+------------------------------+
|                Operating System Kernel                      |
+-------------------------------------------------------------+
  • V8 (Google): Este motor compila y ejecuta tu JavaScript. Tiene exactamente una pila de llamadas y ejecuta el código de forma secuencial, únicamente en un hilo.
  • libuv: Una biblioteca multiplataforma escrita en C que se encarga del bucle de eventos, un grupo de hilos de trabajo, el acceso al sistema de archivos, los temporizadores, la creación de procesos hijos y la supervisión de sockets de red.
  • Cuando la gente describe a Node.js como de un solo hilo, lo que realmente quieren decir es que el contexto de ejecución de JavaScript se ejecuta en un único hilo principal. Sin embargo, libuv está escrita en C y es inherentemente multihilo. Se apoya en las capacidades de bajo nivel que ofrece el sistema operativo para ejecutar tareas de forma concurrente, todo sin bloquear la ejecución de JavaScript.

    Dos maneras en que libuv maneja el trabajo asíncrono

    Es común suponer que libuv dirige cada operación asíncrona a un hilo en segundo plano. Eso no es del todo correcto: libuv divide el trabajo entre dos estrategias distintas, dependiendo del tipo de tarea que sea:

    • Funcionalidades nativas del sistema operativo sin bloqueo (para entrada y salida de red)
    • Pool interno de hilos de libuv (para acceso al sistema de archivos, búsquedas DNS y criptografía)

    Comprender esta división es, sin duda, el modelo mental más útil para razonar sobre el rendimiento del backend de Node.js.

    Incoming Async Task
            │
            ├── Is it Network I/O? (TCP/UDP, HTTP sockets)
            │     └──> Handled directly by OS Kernel mechanisms (epoll / kqueue / IOCP)
            │          (Zero worker threads used)
            │
            └── Is it File I/O, DNS lookup, or CPU-bound crypto/compression?
                  └──> Handled by libuv Thread Pool (4 threads by default)
    

    1. Entrada y salida de red: Primitivas del sistema operativo

    Los sistemas operativos modernos vienen equipados con APIs dedicadas y sin bloqueo para manejar sockets de red:

    • epoll en Linux
    • kqueue en macOS y la familia BSD
    • IOCP (Puertos de finalización para entrada y salida) en Windows

    Cuando una aplicación Node.js abre un receptor TCP o envía una solicitud HTTPS saliente, libuv no la pasa a un hilo de trabajo. En su lugar, registra directamente el descriptor de archivo del socket con el kernel del sistema operativo, solicitando efectivamente ser notificado una vez haya datos entrantes en ese socket o cuando esté listo para aceptar más escrituras.

    A partir de ese momento, libuv simplemente espera. Es el kernel del sistema operativo quien supervisa el hardware de red.

    Una vez que los paquetes llegan realmente a la interfaz de red, el kernel genera un evento. libuv lo detecta durante la fase de sondeo de su bucle de eventos y luego coloca en cola la función de callback de JavaScript correspondiente para su ejecución. V8 finalmente la saca de la cola y la ejecuta en el hilo principal.

    Dado que no hay hilos de trabajo esperando inactivamente a que los bytes lleguen por la red, un único proceso de Node.js puede gestionar cómodamente decenas de miles de conexiones lentas o inactivas consumiendo muy poca memoria.

    2. Entrada/salida de archivos y operaciones del sistema: El grupo de hilos de trabajo

    Puesto que los sockets de red pueden ser manejados sin bloqueos a nivel del kernel, uno podría preguntarse por qué las lecturas y escrituras de archivos no pueden funcionar de la misma manera.

    La razón es que la mayoría de los sistemas operativos No cuentan con una API verdaderamente no bloqueante para el acceso al sistema de archivos. En sistemas basados en POSIX como Linux y macOS, las operaciones normales de archivos bloquean al hilo que las realiza hasta que el dispositivo de almacenamiento devuelve realmente los datos solicitados.

    Si Node.js intentara realizar una lectura de archivo directamente en el hilo principal de JavaScript, todo el entorno de ejecución se detendría hasta que el disco arrancara, recuperara los bloques relevantes y devolviera los bytes. Durante toda esa pausa, no se podría atender ninguna otra solicitud HTTP entrante.

    Para evitar este problema, libuv mantiene un pool de hilos de trabajo interno.

    Esto es lo que sucede cuando tu código llama a fs.readFile():

    • La llamada de JavaScript pasa a través de los enlaces internos de Node hasta llegar a libuv.
    • libuv empaqueta la solicitud de lectura de archivo como una unidad de trabajo y la coloca en una cola interna de tareas.
    • Uno de los hilos en segundo plano del pool retira esta solicitud de la cola.
    • Ese hilo luego realiza la llamada al sistema que causa bloqueo — read() o write() — de forma segura, alejado del hilo principal.
  • Una vez finalizada la lectura, el hilo de trabajo envía una señal al bucle de eventos a través de un mecanismo de notificación entre hilos.
  • Luego, el bucle de eventos programa la función de callback correspondiente en JavaScript para que se ejecute en el hilo principal, pasándole el buffer resultante.
  • ¿Qué se ejecuta realmente en el pool de hilos?

    Hay cuatro categorías principales de tareas que dependen del pool de hilos de libuv:

    • Llamadas al sistema de archivos: todos los métodos asíncronos de fs, como fs.readFile, fs.stat y fs.writeFile.
    • Búsquedas DNS: específicamente dns.lookup(), que depende de la función bloqueante en C getaddrinfo(). En contraste, dns.resolve() omite por completo el pool de hilos y se comunica directamente con la red mediante llamadas no bloqueantes.
    • Operaciones criptográficas costosas: funciones como crypto.pbkdf2(), crypto.scrypt() y rutinas de generación de claves.
    • Rutinas de compresión: los métodos asíncronos de zlib, como zlib.gzip().

    Observando el funcionamiento del pool de hilos

    Puede confirmar que libuv depende de un pool en segundo plano y ver su tamaño por defecto mediante un breve experimento:

    // thread-pool-test.js
    const crypto = require('crypto');
    
    const start = Date.now();
    const ITERATIONS = 6;for (let i = 1; i <= ITERATIONS; i++) {
      crypto.pbkdf2('password123', 'salt-value', 100000, 64, 'sha512', () => {
        const elapsed = Date.now() - start;
        console.log(`Task ${i} completed in ${elapsed}ms`);
      });
    }
    

    crypto.pbkdf2() es un buen caso de prueba porque realiza intencionadamente trabajos intensivos en la CPU para derivar un hash de contraseña, y ese trabajo se envía al pool de hilos.

    Ejecute el script desde su terminal:

    node thread-pool-test.js
    

    La salida tendrá un aspecto similar al siguiente:

    Task 2 completed in 218ms
    Task 1 completed in 220ms
    Task 4 completed in 224ms
    Task 3 completed in 226ms
    Task 5 completed in 435ms
    Task 6 completed in 437ms
    

    ¿Por qué las dos últimas tareas tardan el doble de tiempo?

    Observe atentamente los tiempos. Las primeras cuatro tareas finalizan aproximadamente al mismo momento, alrededor de 220 ms. Pero las tareas quinta y sexta tardan cerca de 435 ms, casi el doble.

    La razón es que el pool de hilos de libuv viene con un tamaño predeterminado de 4 hilos.

    En cuanto comienza el bucle, las primeras cuatro tareas se apoderan de los cuatro hilos de trabajo disponibles. Las tareas quinta y sexta quedan entonces en la cola interna de libuv, esperando. Solo cuando una de las cuatro tareas iniciales finaliza y libera un hilo, pueden comenzar a ejecutarse las tareas en cola.

    Ajuste del tamaño del pool con UV_THREADPOOL_SIZE

    Puede cambiar la cantidad de hilos de trabajo que inicia libuv estableciendo la variable de entorno UV_THREADPOOL_SIZE antes de iniciar el proceso de Node.js. Acepta valores desde 1 hasta 128.

    Pruebe nuevamente el mismo script, esta vez solicitando un pool de 8 hilos:

    # On Linux / macOS:
    UV_THREADPOOL_SIZE=8 node thread-pool-test.js
    
    # On Windows (PowerShell):
    $env:UV_THREADPOOL_SIZE=8; node thread-pool-test.js
    

    Los resultados ahora se ven diferentes:

    Task 1 completed in 240ms
    Task 3 completed in 242ms
    Task 2 completed in 245ms
    Task 6 completed in 249ms
    Task 4 completed in 250ms
    Task 5 completed in 252ms
    

    Con suficientes hilos de trabajo disponibles, las seis tareas se ejecutan simultáneamente en lugar de esperar en cola.

    Límite importante: no se puede cambiar el tamaño del pool desde dentro de su script escribiendo process.env.UV_THREADPOOL_SIZE = 8. libuv lee y bloquea este valor antes de que se ejecute el código JavaScript, por lo que la variable de entorno debe establecerse a nivel de shell o por el gestor de procesos que inicia Node, y no desde dentro de la aplicación misma.

    Un problema común en producción: hilos compartidos entre tareas no relacionadas

    Dado que las operaciones de archivo, las búsquedas DNS y el trabajo criptográfico utilizan por defecto el mismo pool de cuatro hilos, un uso intensivo en una categoría puede ralentizar silenciosamente actividades completamente distintas.

    Imagínese esta secuencia de eventos:

    • Ola de inicios de sesión que desencadena varias llamadas simultáneas a crypto.pbkdf2 para verificar contraseñas.
    • Todos los cuatro hilos de libuv están ahora completamente ocupados calculando hashes de contraseñas.
    • En ese mismo momento, otra parte de la aplicación llama a fs.readFile() para cargar un modelo de correo electrónico, o llama a dns.lookup() para resolver el nombre de host de su base de datos.
    • Ambas operaciones deben esperar su turno en la cola.

    Leer un archivo apenas utiliza CPU, pero aún así se retrasa porque cada hilo de trabajo está ocupado con los cálculos de hash. Desde el exterior, parece que el acceso al archivo o la conectividad a la base de datos se ha ralentizado, cuando en realidad el culpable es la competencia por los hilos de libuv.

    Cómo aliviar la competencia en el pool de hilos:

    • Dale más hilos al pool: si tu servicio realiza muchas operaciones de entrada/salida de archivos o trabajo criptográfico, aumentar UV_THREADPOOL_SIZE a 16 o 32 puede reducir la competencia por recursos, siempre y cuando la máquina subyacente cuente con suficiente capacidad de CPU para soportarlo.
    • Saca del pool las tareas propias que dependen exclusivamente de la CPU: para tareas que controlas, como generar informes o procesar imágenes, no intentes enrutarlas a través de libuv. En su lugar, utiliza el módulo worker_threads, que crea instancias separadas de V8 en hilos del propio sistema operativo.
    • Evita dns.lookup() cada vez que sea posible: prefiere dns.resolve4(), o crea pools de conexiones que utilicen direcciones IP explícitas, de modo que la resolución de nombres de red no consuma los limitados espacios de trabajo de libuv.

    Dónde cometen errores los desarrolladores con frecuencia

    Error uno: Suponer que async/await descarga automáticamente el trabajo

    Añadir el prefijo async a una función no crea un hilo en segundo plano para ella. async/await es simplemente una sintaxis más limpia que se superpone sobre Promises. Si el cuerpo de la función contiene un bucle síncrono o un cálculo intensivo, ese código sigue ejecutándose directamente en el hilo principal de JavaScript y seguirá bloqueando tu servidor mientras se ejecuta.

    Error dos: Confundir el pool de hilos de libuv con worker_threads

    • El pool de hilos de libuv es gestionado internamente por código nativo en C. Se encarga de operaciones integradas como fs, crypto y zlib. No tienes forma de insertar tus propias funciones JavaScript arbitrarias en este pool.
  • El módulo worker_threads, disponible desde Node.js 10.5, es una API a nivel de JavaScript. Permite ejecutar tu propio código en paralelo, con cada trabajador contando con su propio motor V8 e bucle de eventos independiente.
  • Errore tres: hacer que el grupo de hilos sea demasiado grande

    Es tentador establecer UV_THREADPOOL_SIZE=128 en todas partes y asumir que cuanto más, mejor. Pero los hilos conllevan un costo real: cada uno necesita memoria para su propia pila de ejecución, y cuando se tienen cientos de hilos compitiendo por solo dos o cuatro núcleos de CPU, el sistema operativo empieza a perder tiempo considerable solo cambiando entre ellos.

    Un punto de partida razonable es dimensionar el grupo de hilos de acuerdo con la cantidad de núcleos lógicos de CPU cuando la carga de trabajo está limitada por estos, como en operaciones criptográficas o de compresión; o bien usar dos a cuatro veces esa cantidad cuando el trabajo depende principalmente de operaciones de E/S en disco.

    Resumen

    El verdadero truco detrás de Node.js no es que evite por completo la concurrencia, sino que encapsula los detalles de los hilos de bajo nivel dentro de un modelo de programación simple basado en eventos.

    • La ejecución de JavaScript sigue siendo single-threaded: la lógica de la aplicación se ejecuta paso a paso, lo que evita las condiciones de carrera y la necesidad de bloqueos.
    • Las operaciones de red pasan por mecanismos a nivel del sistema operativo en libuv: los sockets son gestionados por los sistemas de monitoreo del kernel, como epoll, kqueue o IOCP, y no consumen ningún hilo de trabajo.
    • El acceso a archivos, las búsquedas DNS y los trabajos criptográficos dependen del pool de hilos: cuatro hilos en segundo plano escritos en C manejan estas llamadas bloqueantes para que el hilo principal permanezca libre y pueda seguir aceptando nuevas solicitudes.

    Una vez que sepa por cuál de estos dos caminos sigue una operación determinada, estará en una posición mucho mejor para detectar problemas de rendimiento, dimensionar correctamente sus servidores y crear servicios backend que resistan bien cargas elevadas.

    Lecturas relacionadas

  • Node.js Streams explicados: Cómo solucionar los fallos por falta de memoria al trabajar con archivos — Aprenda por qué cargar archivos completos en la memoria provoca fallos en los servidores Node.js y cómo las secuencias de lectura, escritura, dúplex y transformación solucionan este problema mediante la contrapresión.
  • Controlando la concurrencia en Node.js: Cómo evitar colapsos de la API con p-map y Bottleneck — Aprenda cómo combinar p-map y Bottleneck en Node.js para prevenir errores de límite de velocidad y sobrecarga del sistema al controlar la concurrencia y el tiempo de respuesta de las solicitudes.
  • Node.js 26: API Temporal, Map Upserts y Undici 8 explicados — Explica los principales cambios en el backend de Node.js 26, incluida la API Temporal estable, los métodos nativos de Map upsert, las mejoras de rendimiento en Undici 8, y los cambios que pueden causar problemas que es necesario auditar antes de actualizar.
  • Comprendiendo los cierres en JavaScript y el bucle de eventos — Aprende cómo los cierres conservan las variables externas y cómo el bucle de eventos organiza la pila de llamadas, las microtareas y las macrotareas con ejemplos de código prácticos.