Node.js

Node

Tutorial Node: Introducción a Event Loop

Aprende qué es el event loop en Node.js, su arquitectura y orden de ejecución para dominar la programación asíncrona eficiente.

Aprende Node y certifícate

Qué es Event Loop

El Event Loop es el mecanismo fundamental que permite a Node.js ejecutar operaciones asíncronas de manera eficiente, a pesar de que JavaScript es un lenguaje de un solo hilo. Este sistema es lo que hace posible que Node.js pueda manejar miles de conexiones concurrentes sin bloquear la ejecución del programa.

Para entender cómo funciona, imaginemos Node.js como un restaurante con un solo camarero. Aunque solo hay un camarero (un hilo), puede atender múltiples mesas tomando pedidos, llevándolos a la cocina, y mientras la cocina prepara la comida, puede seguir atendiendo otras mesas. El Event Loop funciona de manera similar: delega las tareas que requieren tiempo (como leer archivos o hacer peticiones de red) y continúa ejecutando otras operaciones.

Arquitectura del Event Loop

El Event Loop opera mediante un sistema de colas donde se organizan diferentes tipos de tareas según su prioridad y naturaleza. Cuando ejecutas código en Node.js, las operaciones síncronas se ejecutan inmediatamente, mientras que las asíncronas se envían a sus respectivas colas para ser procesadas cuando sea su turno.

console.log('Inicio del programa');

// Operación asíncrona - va a una cola
setTimeout(() => {
    console.log('Timeout ejecutado');
}, 0);

// Operación síncrona - se ejecuta inmediatamente
console.log('Fin del programa');

// Salida:
// Inicio del programa
// Fin del programa
// Timeout ejecutado

En este ejemplo, aunque el setTimeout tiene un retraso de 0 milisegundos, no se ejecuta inmediatamente porque debe pasar por el Event Loop. Primero se ejecutan todas las operaciones síncronas del hilo principal, y después el Event Loop procesa las tareas asíncronas pendientes.

Fases del Event Loop

El Event Loop funciona en ciclos continuos, donde cada ciclo pasa por diferentes fases específicas. Cada fase tiene su propia cola de callbacks que procesar:

Fase de Timers: Procesa callbacks de setTimeout() y setInterval() cuyo tiempo ha expirado.

setTimeout(() => {
    console.log('Timer 1');
}, 100);

setTimeout(() => {
    console.log('Timer 2');
}, 50);

// Timer 2 se ejecutará antes que Timer 1

Fase de I/O Callbacks: Ejecuta callbacks de operaciones de entrada/salida como lectura de archivos, peticiones HTTP, o conexiones de base de datos.

const fs = require('fs');

fs.readFile('archivo.txt', (err, data) => {
    console.log('Archivo leído');
});

console.log('Lectura iniciada');

Fase de Poll: Es la fase más importante donde el Event Loop espera por nuevos eventos de I/O. Si no hay callbacks pendientes, el Event Loop puede quedarse aquí esperando nuevas operaciones.

Microtareas vs Macrotareas

Node.js distingue entre dos tipos de tareas asíncronas que tienen diferentes prioridades en el Event Loop:

Microtareas tienen la máxima prioridad y se ejecutan antes que cualquier macrotarea. Incluyen Promise.resolve(), Promise.reject() y process.nextTick().

console.log('1');

process.nextTick(() => {
    console.log('2 - nextTick');
});

Promise.resolve().then(() => {
    console.log('3 - Promise');
});

setTimeout(() => {
    console.log('4 - setTimeout');
}, 0);

console.log('5');

// Salida:
// 1
// 5
// 2 - nextTick
// 3 - Promise
// 4 - setTimeout

Macrotareas incluyen setTimeout(), setInterval(), operaciones de I/O y otros eventos del sistema. Se procesan después de que todas las microtareas hayan sido completadas.

Event Loop en la práctica

Cuando desarrollas aplicaciones en Node.js, el Event Loop te permite escribir código que parece secuencial pero que en realidad es asíncrono:

const fs = require('fs').promises;

async function procesarArchivos() {
    console.log('Iniciando procesamiento');
    
    try {
        // Estas operaciones no bloquean el hilo principal
        const archivo1 = await fs.readFile('datos1.txt', 'utf8');
        const archivo2 = await fs.readFile('datos2.txt', 'utf8');
        
        console.log('Archivos procesados');
        return archivo1 + archivo2;
    } catch (error) {
        console.error('Error:', error.message);
    }
}

// El Event Loop permite que otras operaciones continúen
console.log('Programa iniciado');
procesarArchivos();
console.log('Programa continúa');

El Event Loop es lo que hace que Node.js sea especialmente eficiente para aplicaciones que manejan muchas operaciones de I/O, como servidores web o APIs. Mientras una operación espera respuesta del sistema de archivos o de una base de datos, el Event Loop puede procesar otras peticiones, maximizando el rendimiento del servidor.

Esta arquitectura permite que una sola instancia de Node.js pueda manejar miles de conexiones simultáneas sin necesidad de crear un hilo separado para cada una, lo que sería mucho más costoso en términos de memoria y recursos del sistema.

Orden de ejecución

El orden de ejecución en Node.js sigue un patrón específico que determina cuándo y en qué secuencia se procesan las diferentes operaciones. Comprender este orden es fundamental para predecir el comportamiento de tu código asíncrono y evitar errores comunes en el desarrollo.

Prioridad de ejecución

Node.js establece una jerarquía clara de prioridades que determina qué código se ejecuta primero. Esta jerarquía funciona de la siguiente manera:

1. Código síncrono del hilo principal

Todo el código síncrono se ejecuta inmediatamente y tiene la máxima prioridad. El Event Loop no puede procesar ninguna tarea asíncrona hasta que todo el código síncrono haya terminado.

console.log('A - Síncrono');

setTimeout(() => console.log('B - setTimeout'), 0);

console.log('C - Síncrono');

// Salida garantizada:
// A - Síncrono
// C - Síncrono
// B - setTimeout

2. Microtareas (process.nextTick y Promises)

Las microtareas tienen prioridad sobre las macrotareas y se ejecutan inmediatamente después del código síncrono. Dentro de las microtareas, process.nextTick() tiene prioridad sobre las Promises.

console.log('1');

Promise.resolve().then(() => console.log('2 - Promise'));
process.nextTick(() => console.log('3 - nextTick'));

console.log('4');

// Salida:
// 1
// 4
// 3 - nextTick
// 2 - Promise

3. Macrotareas (setTimeout, setInterval, I/O)

Las macrotareas se procesan después de que todas las microtareas hayan sido completadas. Entre las macrotareas, el orden depende de cuándo fueron programadas y de qué fase del Event Loop las procese.

Comportamiento de las colas

El Event Loop procesa las tareas siguiendo un patrón de vaciado completo de colas. Esto significa que antes de pasar a la siguiente fase, debe procesar todas las tareas pendientes de la fase actual.

console.log('Inicio');

// Programamos múltiples microtareas
process.nextTick(() => {
    console.log('nextTick 1');
    process.nextTick(() => console.log('nextTick anidado'));
});

process.nextTick(() => console.log('nextTick 2'));

Promise.resolve().then(() => {
    console.log('Promise 1');
    return Promise.resolve();
}).then(() => console.log('Promise 2'));

setTimeout(() => console.log('setTimeout'), 0);

console.log('Fin');

// Salida:
// Inicio
// Fin
// nextTick 1
// nextTick 2
// nextTick anidado
// Promise 1
// Promise 2
// setTimeout

Orden en operaciones de I/O

Las operaciones de I/O siguen un orden específico basado en cuándo se completan, no necesariamente en el orden en que se iniciaron. El sistema operativo determina cuándo están listas las operaciones.

const fs = require('fs');

console.log('Iniciando lecturas');

fs.readFile('archivo-grande.txt', () => {
    console.log('Archivo grande leído');
});

fs.readFile('archivo-pequeño.txt', () => {
    console.log('Archivo pequeño leído');
});

setTimeout(() => console.log('Timer'), 0);

console.log('Lecturas programadas');

// Posible salida:
// Iniciando lecturas
// Lecturas programadas
// Timer
// Archivo pequeño leído  (puede completarse primero)
// Archivo grande leído

Interacción entre diferentes tipos de tareas

Cuando se combinan diferentes tipos de operaciones asíncronas, el orden de ejecución puede parecer complejo, pero sigue reglas consistentes:

console.log('=== Inicio ===');

setTimeout(() => {
    console.log('Timer 1');
    process.nextTick(() => console.log('nextTick dentro de Timer'));
}, 0);

setImmediate(() => {
    console.log('Immediate 1');
});

process.nextTick(() => {
    console.log('nextTick 1');
    setTimeout(() => console.log('Timer dentro de nextTick'), 0);
});

Promise.resolve().then(() => {
    console.log('Promise 1');
    setImmediate(() => console.log('Immediate dentro de Promise'));
});

console.log('=== Fin ===');

// Salida típica:
// === Inicio ===
// === Fin ===
// nextTick 1
// Promise 1
// Timer 1
// Immediate 1
// nextTick dentro de Timer
// Timer dentro de nextTick
// Immediate dentro de Promise

Casos especiales de ordenación

Existen situaciones específicas donde el orden puede variar según el contexto de ejecución:

Timers vs setImmediate

El orden entre setTimeout(0) y setImmediate() puede variar dependiendo de si se ejecutan desde el hilo principal o desde dentro de una operación de I/O.

// Desde el hilo principal - orden puede variar
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));

// Desde dentro de I/O - orden predecible
const fs = require('fs');
fs.readFile(__filename, () => {
    setTimeout(() => console.log('setTimeout en I/O'), 0);
    setImmediate(() => console.log('setImmediate en I/O'));
    // setImmediate siempre se ejecuta primero aquí
});

Microtareas recursivas

Las microtareas que generan más microtareas pueden crear situaciones donde las macrotareas se retrasan indefinidamente:

function crearMicrotarea() {
    process.nextTick(() => {
        console.log('Microtarea ejecutada');
        crearMicrotarea(); // Crea otra microtarea
    });
}

setTimeout(() => console.log('Esta macrotarea nunca se ejecutará'), 0);
crearMicrotarea();

// Resultado: bucle infinito de microtareas

Debugging del orden de ejecución

Para depurar problemas relacionados con el orden de ejecución, puedes usar técnicas que te ayuden a visualizar el flujo:

function log(mensaje, tipo = 'sync') {
    const timestamp = Date.now();
    console.log(`[${timestamp}] ${tipo.toUpperCase()}: ${mensaje}`);
}

log('Código síncrono 1');

process.nextTick(() => log('NextTick callback', 'microtask'));
Promise.resolve().then(() => log('Promise callback', 'microtask'));

setTimeout(() => log('SetTimeout callback', 'macrotask'), 0);
setImmediate(() => log('SetImmediate callback', 'macrotask'));

log('Código síncrono 2');

Comprender el orden de ejecución te permite escribir código más predecible y evitar errores sutiles que pueden aparecer cuando las operaciones no se ejecutan en el orden esperado. Esta comprensión es especialmente importante cuando trabajas con múltiples operaciones asíncronas que dependen unas de otras.

Aprende Node online

Otras lecciones de Node

Accede a todas las lecciones de Node y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.

Accede GRATIS a Node y certifícate

Ejercicios de programación de Node

Evalúa tus conocimientos de esta lección Introducción a Event Loop con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.