Inicio / Artículos

Cómo JavaScript ejecuta tu código: pila, montón, bucle de eventos y generadores

Recorrido guiado por el entorno de ejecución de JavaScript: pila de llamadas y frames, referencias del heap, el bucle de eventos de Node.js, el DOM, callbacks, promesas, async/await y generadores.

Editor: REACTAPP.TOP13 min de lectura2982 palabras

Fuentes: JavaScript

El JavaScript asíncrono a menudo se comporta de maneras que sorprenden a los desarrolladores: una línea de registro aparece “demasiado pronto”, un await parece saltar hacia adelante, un generador imprime solo la mitad de sus mensajes. Casi todas esas sorpresas desaparecen una vez que se cuenta con un modelo claro de cómo el entorno de ejecución procesa el código. Este artículo construye ese modelo poco a poco, desde la pila de llamadas y el montón hasta el bucle de eventos de Node.js y el DOM, pasando por promesas, async/await y generadores, para que pueda predecir la salida de cada fragmento antes incluso de leerlo.

Las partes del entorno de ejecución

JavaScript se ejecuta en un único hilo principal, pero los programas esperan a temporizadores, respuestas de red y archivos sin congelarse, porque el motor no actúa por sí solo:

  • la pila de llamadas, que registra las funciones que se están ejecutando en ese momento
  • el heap, donde residen los objetos y otros datos asignados dinámicamente
  • las APIs del sistema operativo (Web APIs en los navegadores, Node.js APIs en el servidor), que gestionan temporizadores, archivos y conexiones de red
  • colas de callbacks (tareas), donde el trabajo completado espera ser ejecutado
  • la cola de microtareas, utilizada principalmente por las promesas
  • el bucle de eventos, que decide qué se ejecutará a continuación
  • Tu código se ejecuta en la pila de llamadas mientras el entorno de ejecución maneja las operaciones lentas. Cuando una se completa, su callback se agrega a la cola, y el bucle de eventos lo coloca en la pila una vez que esta esté disponible.

    Un vistazo al bucle de eventos de Node.js

    El diagrama muestra el ciclo de Node.js. Primero se ejecuta el código síncrono, luego las microtareas en cola, y después las fases del bucle: temporizadores (setTimeout, setInterval), callbacks pendientes, la fase de revisión (donde se ejecutan la mayoría de los callbacks de archivos y red), la fase de verificación (setImmediate) y los callbacks de cierre.

    ┌───────────────────┐
    │    CALL STACK     │
    │    JavaScript     │
    └─────────┬─────────┘
              │
              │ Synchronous code finishes
              ▼
    ┌───────────────────┐
    │  MICROTASK QUEUE  │
    │                   │
    │  Promise.then()   │
    │  Promise.catch()  │
    │  queueMicrotask() │
    └─────────┬─────────┘
              │
              ▼
    ╔═════════════════════════════════════════════╗
    ║                EVENT LOOP                   ║
    ║                                             ║
    ║  ┌───────────────┐                          ║
    ║  │     TIMERS    │                          ║
    ║  └───────┬───────┘                          ║
    ║          ▼                                  ║
    ║  ┌───────────────┐                          ║
    ║  │    PENDING    │                          ║
    ║  │    CALLBACKS  │                          ║
    ║  └───────┬───────┘                          ║
    ║          ▼                                  ║
    ║  ┌───────────────┐                          ║
    ║  │     POLL      │                          ║
    ║  │      I/O      │                          ║
    ║  └───────┬───────┘                          ║
    ║          ▼                                  ║
    ║  ┌───────────────┐                          ║
    ║  │     CHECK     │                          ║
    ║  │  setImmediate │                          ║
    ║  └───────┬───────┘                          ║
    ║          ▼                                  ║
    ║  ┌───────────────┐                          ║
    ║  │     CLOSE     │                          ║
    ║  │    CALLBACKS  │                          ║
    ║  └───────┬───────┘                          ║
    ║          └───────────────────────────────┐  ║
    ║                                          │  ║
    ╚══════════════════════════════════════════╪══╝
                                               │
                                               ▼
                                      ┌───────────────────┐
                                      │    CALL STACK     │
                                      │    Execute JS     │
                                      └───────────────────┘
    

    Hay dos aspectos importantes que merecen atención. Las microtareas no se procesan solo una vez por bucle: Node vacía la cola de microtareas (y antes, la cola de process.nextTick) después de cada callback, por lo que el manejador .then de una promesa se ejecuta antes del siguiente callback del temporizador. Además, los navegadores siguen el principio de tareas más microtareas, pero no cuentan con fases setImmediate ni de libuv, y entrelazan el proceso de renderizado. Para conocer los detalles específicos de Node, consulte cómo funcionan juntos libuv, el bucle de eventos y el pool de hilos.

    La pila de llamadas

    La pila de llamadas registra qué función está en ejecución y cuáles funciones están esperando a que esta termine. Es una estructura LIFO, último en entrar, primero en salir: la función llamada más recientemente debe finalizar antes de que la siguiente pueda continuar.

    En el ejemplo siguiente, func1 llama a func2, que a su vez llama a func3, el cual registra un mensaje:

    function func1() {
      func2();
    }
    function func2() {
      func3();
    }
    function func3() {
      console.log("Hello");
    }
    func1();
    

    Las llamadas se acumulan en este orden:

    func1()
       ↓
    func2()
       ↓
    func3()
       ↓
    console.log()
    

    Como una pila de platos, console.log se ejecuta por último y sale primero, luego func3, func2 y func1 devuelven el control uno tras otro. Las funciones de callback en cola solo pueden ejecutarse una vez que la pila está vacía, y por eso un bucle síncrono largo bloquea todo lo demás.

    El heap

    El heap es la memoria destinada a los datos cuya vida útil no está vinculada a una sola llamada, como objetos, arrays y cierres.

    Al crear un literal de objeto, este se asigna allí:

    const user = {
      name: "Rayane",
      age: 20
    };
    

    La variable solo contiene una referencia al objeto almacenado en el heap:

    CALL STACK                  HEAP
    ┌───────────────┐          ┌────────────────────┐
    │ user variable │─────────▶│ {                  │
    │               │          │   name: "Rayane",  │
    └───────────────┘          │   age: 20          │
                               │ }                  │
                               └────────────────────┘
    

    Al asignar user a otra variable se copia la referencia, no el objeto, por lo que los cambios realizados en cualquiera de ellas son visibles en la otra. Una vez que no hay ninguna referencia accesible que apunte a un objeto, el recolector de basura puede recuperarlo; una cierre de larga duración que capture un objeto mantiene este vivo.

    Qué coordina el bucle de eventos

    El bucle de eventos vincula tu código con la finalización de tareas asíncronas. En Node.js, V8 ejecuta tu código; una llamada a una API de Node, como leer un archivo, pasa a libuv, la cual se lo transfiere al sistema operativo o a su piscina de hilos. Cuando la tarea finaliza, el callback queda listo y el bucle de eventos lo ejecuta eventualmente en la pila.

    JavaScript code
          │
          ▼
       V8 Engine
          │
          ▼
      Call Stack
          │
          ▼
    Node.js APIs
          │
          ▼
        libuv
          │
          ├───────────────┐
          ▼               ▼
         OS           Thread Pool
          │               │
          └───────┬───────┘
                  ▼
         Asynchronous operation
                  │
                  ▼
         Callback becomes ready
                  │
                  ▼
             Event Loop
                  │
                  ▼
           Callback Queue
                  │
                  ▼
             Call Stack
    

    La ruta depende de la operación: los sockets suelen ser manejados por el sistema operativo a través de epoll o kqueue, mientras que las tareas del sistema de archivos y algunas llamadas criptográficas o DNS utilizan la piscina de hilos.

    Marco de pila

    Cada llamada agrega un marco de pila que contiene lo necesario para que la función funcione mientras se ejecuta:

    • sus parámetros
    • sus variables locales
    • la dirección de retorno, es decir, dónde continúa la ejecución en la función que llamó
    • otros datos de control del motor

    Tomemos una función que suma dos números:

    function add(a, b) {
      const result = a + b;
      return result;
    }
    add(2, 3);
    

    Mientras se ejecuta add(2, 3), su marco contiene los argumentos, el resultado local y un enlace de regreso a la función que llamó:

         CALL STACK
    ┌─────────────────────┐
    │   stack frame: add  │
    │                     │
    │   a = 2             │
    │   b = 3             │
    │   result = 5        │
    │   return → caller   │
    └─────────────────────┘
    

    Una vez que devuelve, el marco se descarta:

         CALL STACK
    ┌─────────────────────┐
    │   frame removed     │
    └─────────────────────┘
    

    Todo su ciclo de vida es muy breve:

    Function call
          ↓
    Stack Frame created
          ↓
    Function executes
          ↓
    Function returns
          ↓
    Stack Frame removed
    

    La recursión excesivamente profunda acumula marcos hasta que el motor lanza un RangeError por superar el tamaño máximo de la pila de llamadas.

    El DOM

    El Modelo de Objetos de Documento es el árbol en tiempo real de nodos del navegador, creado a partir de un documento HTML. A través de él, JavaScript puede:

    • leer elementos
    • cambiar su contenido
    • insertar nuevos elementos
    • eliminar los existentes
    • modificar estilos y atributos

    El DOM es una API del navegador, no parte del lenguaje, y por eso document no existe en Node.js puro.

    Aquí hay un encabezado simple en HTML:

    <h1 id="title">Hello</h1>
    

    Un script puede obtener una referencia a ese elemento por su id:

    const title = document.getElementById("title");
    

    y luego reemplazar su texto:

    title.textContent = "Hello Rayane!";
    

    Antes del cambio, el nodo de texto hijo del elemento contiene la cadena original:

    document
       │
       ▼
    <h1 id="title">
       │
       ▼
    "Hello"
    

    Después de ejecutarse la misma asignación:

    title.textContent = "Hello Rayane!";
    

    el árbol refleja el nuevo texto:

    document
       │
       ▼
    <h1 id="title">
       │
       ▼
    "Hello Rayane!"
    

    Luego el navegador vuelve a dibujar la página. En código real, hay que verificar si getElementById devuelve null cuando no hay coincidencias; además, es preferible utilizar textContent en lugar de innerHTML con cadenas de fuentes no confiables, ya que este último nunca analiza el HTML.

    Callback

    Un callback es una función que se pasa a otra función para poder ser llamada posteriormente. En este ejemplo, greet se le pasa a execute, el cual la invoca con un nombre:

    function greet(name) {
      console.log(`Hello ${name}`);
    }
    function execute(callback) {
      callback("Ajami");
    }
    execute(greet);
    

    El programa imprime:

    Hello Ajami
    

    Dentro de execute, el parámetro llamado callback hace referencia a greet, y esta línea la llama:

    callback("Ajami");
    

    Un callback no es inherentemente asíncrono; este en particular se ejecuta de inmediato. Se vuelve asíncrono cuando el receptor lo activa después de un evento, como al expirar un temporizador.

    Promesas

    Una promesa representa el resultado final de una operación asíncrona. Se inicia la operación, se sigue ejecutando otro código y se maneja el resultado una vez que la promesa se resuelve.

    fetch es una API típica que devuelve promesas:

    const promise = fetch("https://api.example.com/data");
    

    Se reacciona al resultado asignando manejadores:

    • .then() para un resultado exitoso
    • .catch() en caso de error
    • .finally() para tareas de limpieza que se ejecutan en cualquier caso
    fetch("https://api.example.com/data")
      .then((response) => {
        console.log("Request finished");
      })
      .catch((error) => {
        console.log("Something went wrong:", error.message);
      })
      .finally(() => {
        console.log("Request completed");
      });
    

    El inicio de la solicitud no bloquea; los manejadores se ejecutan posteriormente como microtareas. Tenga en cuenta que fetch rechaza la promesa solo en caso de fallos de red. Un código 404 o 500 sigue cumpliendo la promesa, por lo que debe verificar personalmente response.ok.

    Estados de la promesa

    Una promesa siempre se encuentra en uno de tres estados:

    PENDING
       │
       ├───────────────┐
       ▼               ▼
    FULFILLED       REJECTED
    
    • Pendiente: la operación aún no se ha completado.
    • Completada: se completó con éxito y tiene un valor.
    • Rechazada: falló y existe una razón, generalmente un error.

    Una promesa se resuelve exactamente una vez, y los manejadores añadidos posteriormente siguen recibiendo el resultado ya resuelto.

    Async/await

    Una función async siempre devuelve una promesa, y await permite escribir pasos asíncronos de forma secuencial. Esta función registra información, espera una solicitud y vuelve a registrarla, con otro registro después de la llamada:

    async function leetfinish() {
      console.log("The function starts");
    const res = await fetch("https://api.example.com/finish");
      console.log("The request finished");
    }
    leetfinish();
    console.log("Let's review our code");
    

    Un resultado probable es:

    The function starts
    Let's review our code
    The request finished
    

    Por qué el registro exterior se ejecuta segundo

    La función imprime su primer mensaje y llega a esta línea:

    const res = await fetch("https://api.example.com/finish");
    

    fetch devuelve una promesa pendiente, y await suspende la función y devuelve el control al llamante sin congelar el hilo. El llamante imprime su mensaje; solo después de que llegue la respuesta, la función reanuda su ejecución. Por lo tanto:

    The function starts
    Let's review our code
    The request finished
    

    await pausa la función, no el hilo. Y “probablemente” es importante: si la solicitud falla, la última línea nunca se imprime y el error no capturado se convierte en un rechazo sin manejar.

    Manejo de errores con try, catch y finally

    Con async/await se utilizan los comunes try/catch/finally. Este ejemplo obtiene datos, valida la respuesta y el presupuesto, y siempre reinicia una bandera de carga. Sus comentarios señalan dos detalles: los errores HTTP no causan rechazo, y response.json() también devuelve una promesa.

    let isLoading = true;
    async function checkBudget() {
      try {
        // fetch() returns a Promise.
        // await pauses this function until the Promise settles.
        const response = await fetch(
          "https://api.example.com/khouribga/business/budget"
        );
        // fetch() does NOT reject automatically for HTTP errors
        // such as 404 or 500.
        if (!response.ok) {
          throw new Error("Failed to fetch business data");
        }
        // response.json() also returns a Promise.
        const business = await response.json();
        if (business.budget < 10) {
          throw new Error("Budget is too low");
        }
        console.log("Budget is sufficient:", business.budget);
      } catch (error) {
        console.log("Something went wrong:", error.message);
      } finally {
        // This runs whether the request succeeds or fails.
        isLoading = false;
        console.log("Loading finished:", isLoading);
      }
    }
    checkBudget();
    

    Paso a paso:

    1. fetch() envía la solicitud y await suspende la función.
    2. Cuando llega la respuesta, la función se reanuda.
    3. Un estado HTTP fallido se convierte en un error lanzado.
    4. El cuerpo JSON se analiza y el presupuesto se valida.
    5. Cualquier error en try, incluidos los fallos de red, termina en catch.
    6. finally siempre se ejecuta y elimina el estado de carga.

    Ejercicio: ejecutar funciones asíncronas en paralelo

    Promise.all.

    Generadores

    function*, y los puntos de pausa se marcan con yield:

    function* generatorAjami() {
      // Instructions ...
      yield i;
      // More instructions ...
      yield i;
      // More instructions ...
    }
    

    Compare yield con return:

    • return genera un valor y finaliza la función.
    • yield genera un valor y suspende la función, preservando su estado.

    Recorriendo un generador

    Este generador imprime un mensaje antes de cada una de las tres instrucciones yield:

    function* generatorAjami() {
      console.log("First born in casa");
      yield 10;
      console.log("Second move to Khouribga");
      yield 20;
      console.log("Third live in Khouribga");
      yield 30;
    }
    

    Al llamarlo no se ejecuta ninguna parte del cuerpo:

    const generator = generatorAjami();
    

    Se obtiene un objeto generador, un iterador del cual se extraen valores con .next():

    console.log(generator.next());
    

    La primera llamada se ejecuta hasta la primera yield, imprimiendo el primer mensaje:

    First born in casa
    { value: 10, done: false }
    

    La ejecución se detiene en esta instrucción:

    yield 10;
    

    y devuelve 10. Cada resultado de .next() tiene dos propiedades:

    • value, el valor que fue devuelto
  • done, indica si el generador ha finalizado
  • Por lo tanto, el resultado es el siguiente:

    { value: 10, done: false }
    

    significa “aquí hay 10, y aún hay más por venir”.

    Llamar a .next() una segunda vez:

    console.log(generator.next());
    

    produce:

    Second move to Khouribga
    { value: 20, done: false }
    

    El segundo mensaje aparece ahora porque el generador reanuda inmediatamente después del anterior yield.

    Una tercera llamada:

    console.log(generator.next());
    

    imprime el tercer mensaje y devuelve 30:

    Third live in Khouribga
    { value: 30, done: false }
    

    y una cuarta:

    console.log(generator.next());
    

    informa que no queda nada:

    { value: undefined, done: true }
    

    Sin un return explícito, el valor final es undefined.

    Qué es realmente un generador

    Un generador no se ejecuta varias veces: una ejecución comienza y termina una sola vez, pero puede ser suspendida y reanudada para entregar muchos valores. La primera llamada se ejecuta hasta yield 10:

    generator.next()
           ↓
    Execute instructions
           ↓
    yield 10
           ↓
    Pause and return { value: 10, done: false }
    

    La segunda se reanuda y se detiene en yield 20:

    generator.next()
           ↓
    Resume after yield 10
           ↓
    yield 20
           ↓
    Pause and return { value: 20, done: false }
    

    La tercera se reanuda y se detiene en yield 30:

    generator.next()
           ↓
    Resume after yield 20
           ↓
    yield 30
           ↓
    Pause and return { value: 30, done: false }
    

    La cuarta no encuentra más instrucciones y reporta que ha finalizado:

    generator.next()
           ↓
    No more instructions
           ↓
    Return { value: undefined, done: true }
    

    for...of y la sintaxis de dispersión llaman a .next() por usted y se detienen en done: true. Para conocer más patrones, consulte funciones pausables e iteración lazya con generadores.

    Práctica: un generador infinito de Fibonacci

    El problema 2648 de LeetCode es una opción adecuada. El generador emite el valor actual y luego actualiza ambos valores en ejecución. El bucle while (true) es seguro porque cada iteración se detiene en yield.

    var fibGenerator = function* () {
      let a = 0;
      let b = 1;
      while (true) {
        yield a;
        const next = a + b;
        a = b;
        b = next;
      }
    };
    

    El estado evoluciona de la siguiente manera:

    a = 0, b = 1
         ↓
    yield 0
         ↓
    a = 1, b = 1
         ↓
    yield 1
         ↓
    a = 1, b = 2
         ↓
    yield 1
         ↓
    a = 2, b = 3
         ↓
    yield 2
    

    Los valores se calculan de forma perezosa, uno por cada llamada a .next(); simplemente no se deben distribuir ni iterar sin un límite.

    Combinando el modelo

    En Node.js: V8 ejecuta tu código con su pila y montón de memoria, las APIs de Node transfieren el trabajo lento a libuv con su bucle de eventos y piscina de hilos, y el sistema operativo se encarga de las tareas de red reales.

    ┌─────────────────────────────────────────────┐
    │                  NODE.JS                    │
    │                                             │
    │   ┌─────────────┐                           │
    │   │     V8      │                           │
    │   │             │                           │
    │   │ Call Stack  │                           │
    │   │ Heap        │                           │
    │   └──────┬──────┘                           │
    │          │                                  │
    │          ▼                                  │
    │   ┌───────────────┐                         │
    │   │  Node.js APIs │                         │
    │   └───────┬───────┘                         │
    │           │                                 │
    │           ▼                                 │
    │   ┌───────────────┐                         │
    │   │     libuv     │                         │
    │   │               │                         │
    │   │  ┌─────────┐  │                         │
    │   │  │ Event   │  │                         │
    │   │  │  Loop   │  │                         │
    │   │  └─────────┘  │                         │
    │   │               │                         │
    │   │  Thread Pool  │                         │
    │   └───────┬───────┘                         │
    │           │                                 │
    └───────────┼─────────────────────────────────┘
                │
                ▼
          ┌───────────────┐
          │      OS       │
          │               │
          │ Networking    │
          │ Kernel        │
          │ epoll/kqueue  │
          └───────────────┘
    

    Puntos clave

    • Nada que esté en cola se ejecuta hasta que la pila de llamadas está vacía, por lo que el trabajo síncrono prolongado bloquea todo.
    • Las variables almacenan referencias a objetos del montón de memoria; los objetos inaccesibles son recogidos.
  • El tiempo de ejecución realiza tareas lentas; los manejadores de promise se ejecutan como microtareas antes del siguiente callback.
  • fetch rechaza únicamente en caso de errores de red, por lo que se debe verificar response.ok.
  • await suspende la función, no el hilo.
  • Los generadores se pausan en yield y continúan con .next(), lo que permite secuencias perezosas, incluso infinitas.
  • Lecturas relacionadas