Saltar al contenido
Volver al archivo
Ingeniería5 min de lecturaPaso 14 de 22

Event loop o hilo por petición: cómo elegir

La elección entre los dos modelos no es de gusto ni de moda. La determina el perfil de tu carga, y hay una cuenta que la decide.

Existe una discusión recurrente sobre qué modelo de concurrencia es mejor, y suele conducirse como una preferencia estética. No lo es. La respuesta depende de una pregunta objetiva: ¿tu carga está dominada por la espera o por el cálculo?

De dónde viene el problema

El I/O bloqueante es el modelo simple: llamas a read, y el hilo se para hasta que llega el dato. Fácil de escribir, fácil de depurar, la pila de error tiene sentido.

El coste es que cada conexión necesita un hilo parado esperando.

Con cien conexiones, bien. Con diez mil, tienes diez mil hilos, cada uno con su pila reservada, y el planificador del sistema operativo gastando un tiempo significativo solo en cambiar de contexto.

Ese es literalmente el problema que se hizo conocido como C10K, a principios de los años 2000: cómo atender diez mil conexiones simultáneas en una sola máquina.

La solución fue invertir la lógica. En vez de un hilo por conexión preguntando "¿ya llegó?", registras todas las conexiones en un mecanismo del kernel (epoll en Linux, kqueue en BSD y macOS) y haces una única pregunta: "¿cuáles de estas diez mil están listas ahora?".

El kernel devuelve la lista. Un hilo atiende miles de conexiones, sin nadie parado.

Eso es lo que hay debajo del event loop de Node, del asyncio de Python, de Netty en Java, del runtime de Go. Cada uno lo envuelve de forma distinta; por debajo es la misma idea.

Hilo por petición: qué ganas y qué pierdes

Ganas: código lineal. Lo lees de arriba abajo. El depurador funciona. La pila de excepción muestra el camino real. Las bibliotecas bloqueantes funcionan sin adaptación.

Pierdes: escala limitada por el número de hilos.

Y hay una cuenta que da ese número, la Ley de Little:

hilos ocupados = rendimiento × latencia

Mil peticiones por segundo con cien milisegundos cada una = cien hilos ocupados todo el tiempo. Tranquilo.

Si la latencia sube a un segundo (porque una dependencia se degradó), hacen falta mil. Y ahí empieza a doler: memoria, cambio de contexto, y probablemente el pool se agota antes de eso.

Fíjate en lo que revela esa cuenta: no necesitas más tráfico para agotar el pool. Basta con que suba la latencia. Por eso una dependencia lenta derriba un servicio cuyo tráfico no cambió.

Event loop: qué ganas y qué pierdes

Ganas: escala maravillosamente para carga de I/O. Miles de conexiones simultáneas con uno o pocos hilos, memoria baja, sin coste de cambio de contexto.

Pierdes: un modo de fallo específico y brutal.

Si haces cualquier cosa larga de CPU dentro del bucle, bloqueas todo, para todos los clientes. No es una petición lenta: es el servidor entero parado.

Eso se llama event-loop lag, y en Node una función de hash o una serialización pesada mal colocada tumba la latencia del servicio entero. Es la métrica que necesitas monitorizar en esas plataformas, y casi nadie la monitoriza.

Y el código es más difícil: callbacks anidados, async/await por todas partes, pila de error que no cuenta la historia, y la necesidad de que toda la cadena de bibliotecas sea no bloqueante. Una sola llamada bloqueante escondida en una dependencia anula el modelo.

Hilo por petición

  • Código lineal: se lee de arriba abajo
  • El depurador funciona, la pila de error tiene sentido
  • La biblioteca bloqueante funciona sin adaptación
  • Escala limitada por el número de hilos
  • La latencia que sube agota el pool sin tráfico nuevo

Event loop

  • Miles de conexiones con pocos hilos
  • Memoria baja, sin coste de cambio de contexto
  • Cálculo pesado en el bucle bloquea el servidor entero
  • Toda la cadena de bibliotecas tiene que ser no bloqueante
  • Pila de error que no cuenta la historia
La pregunta no es qué modelo es mejor, sino si tu carga espera o calcula.

La regla de decisión

Carga dominada por la espera (llamar a la base, llamar a una API, leer un archivo, servir websockets): el event loop o los hilos ligeros ganan de calle. La CPU queda ociosa de todos modos; lo que quieres es no tener a nadie parado.

Carga dominada por el cálculo (procesar imagen, comprimir, cifrar, compilar, entrenar): necesitas paralelismo de verdad. Más núcleos y un pool de hilos o procesos. El event loop aquí es contraproducente.

Carga mixta, que es el caso común: event loop para el I/O, con un pool separado para las tareas de CPU. Es exactamente para eso que existen los worker threads de Node y el run_in_executor de Python.

Qué cambió en los últimos años

La discusión perdió buena parte de su relevancia por culpa de los hilos ligeros.

Goroutines (Go) e hilos virtuales (Java 21+) dan la ergonomía del código lineal con el coste del event loop. El runtime multiplexa miles de hilos ligeros sobre pocos hilos del sistema operativo, y cuando uno se bloquea en I/O, el runtime cambia a otro automáticamente.

Escribes código bloqueante simple y recibes escala de event loop.

Si empiezas un proyecto hoy en una de esas plataformas, esa es la opción por defecto, y la discusión se acabó.

Una salvedad: los hilos ligeros no resuelven la carga de CPU. Si tu trabajo es cálculo, sigues limitado por el número de núcleos, y sigues necesitando pensar en paralelismo de verdad.

Qué medir para decidir

Si tienes dudas sobre tu propio sistema, tres números lo resuelven:

1. Utilización de CPU durante el pico. Si está baja y el servicio va lento, estás limitado por la espera, y el event loop o los hilos ligeros ayudan.

2. Hilos ocupados contra el tamaño del pool. Calcúlalo con la Ley de Little y compáralo con lo configurado. Si el pool vive agotado, tienes el problema clásico.

3. Tiempo gastado en espera contra en cálculo. Un perfil lo resuelve. Si el noventa por ciento es espera, la respuesta está clara.

El error que atraviesa los dos modelos

Independientemente del modelo elegido, hay un error que aparece en ambos: no limitar la concurrencia con el mundo externo.

Un event loop que dispara diez mil peticiones simultáneas a una API que aguanta cien va a tumbar la API, y después a sí mismo, cuando las diez mil respuestas de error lleguen juntas.

El modelo te da capacidad de concurrencia. No te da permiso para usarla toda. Semáforo, bulkhead y límite explícito siguen siendo necesarios.

Lee esto después

Háblame

¿Dudas sobre el artículo? Escríbeme por WhatsApp

Sin formulario y sin lista de correo. Si no estás de acuerdo con algo que escribí, o quieres contarme cómo lo resolviste, la conversación es directa conmigo.

Abrir conversación