¿Cómo maneja realmente la concurrencia el event loop de un solo hilo de Node.js? Recorré sus fases
Node corre tu código en un solo hilo y aun así atiende miles de conexiones a la vez — las seis fases del event loop y su cola de microtasks son todo el truco.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo maneja realmente la concurrencia el event loop de un solo hilo de Node.js? Recorré sus fases
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Un solo hilo, miles de cosas pasando
JavaScript en Node.js ejecuta el código de la aplicación en un solo hilo. No hay un pool de hilos corriendo tus callbacks en paralelo, ni un mutex que tener en cuenta, ni una race condition entre dos de tus propias funciones. Y sin embargo, un solo proceso de Node maneja de rutina decenas de miles de conexiones concurrentes. El truco no es paralelismo a nivel de JavaScript — es que casi nunca hay código tuyo corriendo en un instante dado. Un handler de request lee un archivo, consulta una base de datos, o llama a una API downstream, y en vez de bloquear el hilo mientras esa I/O termina, registra un callback y retorna inmediatamente. El hilo queda libre para hacer otra cosa, y libuv —la librería en C debajo de Node— se encarga de la espera real usando los mecanismos de I/O asíncrona del sistema operativo (epoll en Linux, kqueue en macOS, IOCP en Windows) o, para trabajo que no se puede hacer async a nivel de SO (la mayoría de las llamadas al filesystem, dns.lookup, algunas funciones de crypto), un pool de hilos propio en segundo plano (UV_THREADPOOL_SIZE, 4 por defecto).
El event loop es el planificador que decide, una vez que tu código síncrono termina, cuál de los muchos callbacks pendientes corre a continuación. No es una sola cola — son varias, organizadas en fases ordenadas, cada una con su propia cola FIFO de callbacks, más un mecanismo de microtasks totalmente aparte que atraviesa todas ellas.
#Las seis fases
- timers — corre los callbacks agendados por
setTimeoutysetIntervalcuyo umbral ya pasó. "Ya pasó" no significa "corre exactamente en ese momento", significa "no corre antes de ese momento", ya que la fase sólo dispara cuando el loop llega a ella. - pending callbacks — ejecuta ciertos callbacks de nivel sistema diferidos de la iteración anterior del loop, como algunos errores TCP
ECONNREFUSED. - idle, prepare — de uso interno de libuv únicamente; no está expuesto al código de la aplicación.
- poll — la fase que hace el trabajo pesado: obtiene nuevos eventos de I/O y corre sus callbacks, que es donde disparan la mayoría de los callbacks de archivos, red y DNS. Si no hay timers vencidos y la cola de poll está vacía, el loop puede bloquearse acá esperando nuevos eventos; si hay un callback de
setImmediateen cola, el loop deja de bloquear y avanza de inmediato. - check — corre específicamente los callbacks de
setImmediate, justo después de la fase de poll. - close callbacks — corre los callbacks de eventos de cierre, como el handler del evento
'close'de un socket.
#Los microtasks atraviesan todas las fases
Después de que cada callback retorna —no sólo al final de una fase, sino después de cada callback individual— Node drena dos colas antes de hacer cualquier otra cosa: primero los callbacks de process.nextTick() (una cola específica de Node con prioridad sobre todo lo demás), y después la cola estándar de microtasks de Promise (continuaciones de .then, resoluciones de async/await, queueMicrotask). Ambas colas se drenan por completo, incluyendo cualquier entrada nueva que ellas mismas agenden, antes de que se le permita al loop continuar. Por eso una cadena recursiva de process.nextTick() puede, en principio, morir de hambre a todo el event loop indefinidamente — nunca deja que el loop avance a la siguiente fase, ni siquiera al siguiente callback de macrotask.
#setTimeout vs setImmediate: una trampa clásica de entrevista
En el nivel superior de un módulo, el orden entre setTimeout(fn, 0) y setImmediate(fn) técnicamente no está garantizado — depende de cuánto tiempo ya pasó desde que arrancó el proceso y de qué tan rápido se llega a la fase de timers en relación al umbral mínimo de 1ms de los timers, así que puede salir de cualquier manera. Pero dentro de un callback de I/O, el orden es determinístico: setImmediate siempre dispara antes que setTimeout(fn, 0), porque la fase de poll siempre pasa directo a la fase de check, y la fase de timers recién se visita de nuevo una iteración completa del loop después. Los entrevistadores usan esto para chequear si el candidato realmente entiende el orden de las fases o sólo está reconociendo el patrón de que "immediate suena más rápido que timeout".
#Por qué este modelo funciona para servidores I/O-bound y falla para trabajo CPU-bound
Como el event loop es de un solo hilo, cualquier cómputo síncrono y pesado en CPU —un parseo grande de JSON, un loop criptográfico ajustado, una transformación de imagen sin batching— bloquea ese único hilo por completo. No disparan timers, no corren callbacks de I/O, no se aceptan requests HTTP, hasta que ese cómputo retorna. Éste es el incidente de producción más común en Node: una regex síncrona con backtracking catastrófico, o un loop CPU-bound hecho a mano, congelando un proceso entero que hasta ese momento manejaba miles de conexiones concurrentes sin problema. La solución es mover el trabajo al thread pool de libuv vía un binding nativo, delegarlo a un worker_thread, o partirlo en pedazos que le devuelven el control al event loop entre uno y otro.