Hilo único de JavaScript frente al motor multiproceso del navegador
Explora por qué JavaScript se ejecuta en un solo hilo, mientras que los navegadores manejan simultáneamente las tareas de red, renderizado y temporizadores a través del mecanismo del bucle de eventos.
JavaScript se ejecuta en un único hilo.
Probablemente hayas leído esa afirmación más veces de las que puedes contar.
Pero cuando cargas una página web moderna, puedes ver que ocurren al mismo tiempo todas las siguientes cosas:
Hay una solicitud de red en tránsito.
Se está cargando y decodificando una imagen.
Una animación CSS se está reproduciendo sin problemas.
La página responde al instante a un clic.
El contenido se está dibujando en la pantalla.
Y tu código JavaScript sigue ejecutándose todo el tiempo.
Entonces, ¿qué está sucediendo realmente en el fondo?
Si JavaScript solo tiene un hilo, ¿quién se encarga de todo lo demás?
Responder a esa pregunta revela una de las ideas más importantes sobre cómo funcionan realmente los navegadores:
JavaScript y el navegador no son lo mismo.
JavaScript tiene un hilo principal
Cuando los desarrolladores describen a JavaScript como de un solo hilo, se refieren específicamente a la forma en que se ejecuta el código JavaScript.
Tu código se ejecuta en un único hilo principal de JavaScript.
Toma este fragmento como ejemplo:
console.log("One");
console.log("Two");
console.log("Three");
Cada línea se ejecuta estrictamente después de la anterior.
JavaScript no ejecutará estas tres instrucciones al mismo tiempo en el mismo hilo.
Existe una sola pila de llamadas con la que trabajar.
Solo un fragmento de JavaScript se ejecuta en cualquier instante dado.
Esa es la esencia de ser de un solo hilo en este contexto.
Pero aquí es donde las cosas se vuelven más complejas.
El propio navegador es responsable de mucho más que simplemente ejecutar tus scripts.
El navegador tiene tareas además de ejecutar JavaScript
Un navegador es una máquina mucho más grande que el motor de JavaScript por sí solo.
Tiene que gestionar tareas como:
- Obtener datos a través de la red
- Programar temporizadores
- Capturar entradas del teclado y del puntero
- Generar frames en la pantalla
- Decodificar imágenes
- Reproducir sonido
- Gestionar la reproducción de video
- Guardar datos en el disco
- Calcular el diseño de la página
- Dibujar píxeles durante la operación de dibujo
- Combinar capas durante el proceso de composición
- Varias otras tareas realizadas por el navegador y el sistema operativo
Tu código JavaScript no es el que lleva a cabo directamente todo esto.
En lugar de eso, JavaScript puede delegar el trabajo al navegador y dejar que él se encargue de los detalles.
Por ejemplo:
fetch("/api/users");
Tu script inicia la solicitud.
Pero tu JavaScript no abre manualmente un socket ni transmite bytes individuales desde el servidor en sí.
Esa responsabilidad corresponde al navegador y a los sistemas que lo sustentan.
Una vez que llega la respuesta, el navegador programa la ejecución de tu función de callback o continuación de promise en el hilo de JavaScript.
Esto es bastante diferente de pensar que:
"JavaScript se encarga absolutamente de todo."
Considere a JavaScript como un trabajador dentro de un sistema mucho más grande
Imagínese un restaurante como modelo mental.
JavaScript es un único servidor que recibe pedidos.
El navegador representa toda la operación del restaurante.
Existen muchos otros trabajadores encargados de tareas diferentes en segundo plano.
JavaScript podría decir:
"Necesito estos datos del servidor."
En ese momento, el navegador se hace cargo de la operación de red.
JavaScript no necesita detenerse y esperar a que llegue cada byte.
Puede continuar con otras tareas libremente.
Una vez que la operación finaliza y está lista para ser manejada por JavaScript, la tarea correspondiente se coloca en cola para el hilo de JavaScript.
Esta es la base del funcionamiento de las API asíncronas de los navegadores.
¿Y qué sucede con fetch()?
Tomemos este ejemplo:
console.log("Start");
fetch("/api/users")
.then(() => {
console.log("Users received");
});
console.log("End");
Los principiantes a menudo se imaginan algo similar a esto:
Start
↓
fetch()
↓
wait for server
↓
Users received
↓
End
Pero esa no es una representación precisa de lo que ocurre.
La ejecución de JavaScript no se detiene en la llamada a fetch() para esperar que llegue la respuesta.
Una representación más precisa sería esta:
JavaScript
│
├── Start fetch
│
▼
Browser handles network work
│
│
└───────────────┐
│
JavaScript │
continues │
│
▼ │
console.log("End") │
│
▼
Response becomes available
│
▼
Promise continuation
gets scheduled
│
▼
JavaScript runs it
Dado ese flujo, la salida de la consola suele verse así:
Start
End
Users received
El detalle clave aquí no es solo que fetch() funciona de forma asíncrona.
Lo que realmente importa es quién está esperando en realidad.
El hilo de JavaScript nunca se bloquea mientras espera en la red.
El bucle de eventos conecta las piezas
Es exactamente aquí donde entra en juego el bucle de eventos.
Puede imaginar que el entorno de JavaScript cuenta con un lugar donde se ejecuta el código, además de mecanismos de coordinación que deciden cuándo se permite reanudar el trabajo asíncrono.
Una versión simplificada de este sistema se ve así:
Browser
│
┌──────────┼───────────┐
│ │ │
Network Timers User Input
│ │ │
└──────────┼───────────┘
│
▼
Scheduling queues
│
▼
Event Loop
│
▼
Call Stack
│
▼
JavaScript
Tenga en cuenta que este diagrama es una simplificación.
La estructura interna real de los navegadores es considerablemente más compleja, y diferentes motores implementan estos mecanismos a su manera.
Aun así, captura la idea esencial:
Ejecutar JavaScript es solo una parte de un sistema mucho más grande.
Los temporizadores no ejecutan en secreto tu código en segundo plano
Toma este ejemplo:
setTimeout(() => {
console.log("Done");
}, 1000);
Una forma tentadora de imaginarlo es:
“JavaScript inicia un hilo separado que cuenta hacia abajo un segundo.”
Ese modelo mental te lleva por el mal camino.
Es el navegador, y no JavaScript en sí, quien implementa el comportamiento de los temporizadores. Una vez que ha pasado el retraso especificado, la función de callback se convierte en candidata para ser programada. Solo entonces JavaScript la ejecuta realmente, y solo cuando el hilo principal está disponible para hacerlo.
Lo que significa que código como este es una mala idea:
setTimeout(() => {
console.log("Done");
}, 1000);
while (true) {}
¿Por qué esto rompe las cosas?
Una vez que finaliza la demora, la función de callback se marca como lista para ejecutarse, pero el hilo principal queda atrapado en un bucle infinito y nunca se vuelve disponible. La función de callback no tiene forma de forzar su ejecución e interrumpir lo que JavaScript esté procesando en ese momento.
El navegador puede saber con certeza lo siguiente:
“El temporizador ha sonado.”
Pero saberlo no es suficiente: JavaScript aún necesita una oportunidad concreta para ejecutarse:
Main JavaScript thread
while (true) {
// never finishes
}
↓
Timer becomes ready
↓
Callback waits
↓
JavaScript never becomes available
Esta es una de las distinciones fundamentales que vale la pena interiorizar.
El hecho de ser asíncrono no implica que tu función de callback se ejecute en otro hilo.
¿Entonces por qué la página no se queda congelada constantemente?
Porque la ejecución de JavaScript es solo una de las muchas tareas que el navegador maneja en un momento dado.
No obstante, hay una limitación importante que cabe mencionar.
Gran parte del trabajo que determina la respuesta de tu página —incluyendo la ejecución de tu JavaScript y partes del proceso de renderizado— tiene lugar en ese mismo hilo principal.
Por eso, un código como este seguirá bloqueando la página:
const start = performance.now();
while (performance.now() - start < 5000) {
// expensive work
}
Durante cinco segundos completos, el hilo principal está ocupado ejecutando ese bucle. Mientras tanto, cualquier operación de manejo de interacciones o paso de renderizado que también necesite el hilo principal debe esperar su turno.
Esta es precisamente la situación a la que se refieren cuando dicen:
"JavaScript es de un solo hilo."
Concurrencia a nivel del navegador, un solo hilo a nivel de JavaScript
Este es el punto clave que hay que tener en cuenta.
Un navegador, como proceso completo, puede distribuir diferentes tipos de tareas entre múltiples hilos. Las especificaciones varían de un navegador y sistema operativo a otro, pero en esencia, los navegadores modernos están diseñados para manejar muchas tareas de forma concurrente.
En términos generales, las tareas podrían distribuirse de la siguiente manera:
Browser
│
├── JavaScript execution
├── Network activity
├── Rendering-related work
├── Image/media processing
├── Browser services
└── Other internal tasks
No obstante, nada de esto permite escribir algo como:
runThisFunctionOnAnotherBrowserThread();
y hacer que código JavaScript arbitrario pase a otro hilo.
El JavaScript habitual sigue ejecutándose según el modelo de ejecución single-threaded que siempre ha tenido.
Si necesitas específicamente trasladar cálculos intensivos en JavaScript fuera del hilo principal, para eso existen los Web Workers.
Conclusión
El JavaScript single-threaded no implica que el navegador en su totalidad esté limitado a un solo hilo.
Su código se ejecuta en un único hilo principal, mientras que el propio navegador se encarga de una amplia gama de otras tareas a través de su propia maquinaria interna.
Tareas como las llamadas a red, los temporizadores, el manejo de la entrada del usuario, la renderización y la decodificación de medios pueden llevarse a cabo de forma independiente de la ejecución de su JavaScript.
Cuando alguna de esas tareas en segundo plano está lista para continuar, el navegador organiza la función de callback o la continuación de la promesa correspondiente para que su JavaScript pueda hacerse cargo de ella.
Dicho esto, el hilo principal sigue teniendo una gran carga de trabajo.
El JavaScript que se ejecuta durante mucho tiempo en ese hilo retrasará todo lo demás que compite por él: interacciones, renderización y otras tareas pendientes por igual.
Así, lo que se obtiene es un navegador que en general es bastante concurrente, junto con una ejecución de JavaScript que permanece estrictamente single-threaded.
Comprender esa división explica tanto de qué es capaz el JavaScript basado en navegadores como dónde se encuentran sus limitaciones.
Puntos clave
Tenga presentes estos tres puntos:
- El propio JavaScript es de un solo hilo. Su código se ejecuta secuencialmente, paso a paso, en el hilo principal.
- El navegador es un entorno más grande y concurrente. Además de ejecutar su JavaScript, gestiona las tareas de red, temporizadores, renderizado, entrada y manejo de medios en paralelo.
- Asíncrono no significa ejecución multihilo de su función de callback. El navegador se encarga de la espera real y luego devuelve el control al JavaScript una vez que el hilo principal tiene espacio para ello.
Una forma útil de entenderlo:
Browser
│
├── JavaScript execution
├── Network activity
├── Timers
├── User input
├── Rendering
├── Media processing
└── Other browser systems
JavaScript es solo un componente que opera dentro de ese sistema más grande, no el sistema en sí.
Con ese marco establecido, conceptos como el bucle de eventos, las APIs asíncronas, la congelación de la interfaz de usuario y la concurrencia a nivel del navegador se vuelven mucho más fáciles de comprender.
Lecturas relacionadas
- Cómo process.nextTick() destruye silenciosamente el bucle de eventos de Node.js — Explica por qué las llamadas recursivas a process.nextTick() bloquean completamente la fase de monitoreo de libuv y cómo solucionar el problema del agotamiento del bucle de eventos utilizando setImmediate().
- 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.