¿Cómo se elige un modelo de concurrencia para una carga de trabajo dada — ligada a CPU vs. ligada a E/S?
Hilo por solicitud, event loop y pool de workers no son 'mejores prácticas' que compiten entre sí: son compromisos distintos, y elegir el equivocado para tu carga se convierte en una caída bajo tráfico.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo se elige un modelo de concurrencia para una carga de trabajo dada — ligada a CPU vs. ligada a E/S?
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.#Empieza clasificando la carga de trabajo, no la tecnología
El error más común en este terreno es elegir un modelo de concurrencia porque está de moda o porque el equipo ya lo conoce, en lugar de porque encaja con cómo el trabajo realmente usa su tiempo. Cada unidad de trabajo que hace un backend cae en algún punto de un espectro entre dos extremos:
- Ligado a E/S (I/O-bound): la tarea pasa la mayor parte de su tiempo de reloj esperando —un viaje de ida y vuelta a la base de datos, una llamada HTTP a otro servicio, una lectura de disco, una resolución DNS. La CPU está ociosa durante esa espera; el recurso limitante es cuántas cosas puedes tener "en vuelo" a la vez, no cuánto cómputo puede hacer un núcleo.
- Ligado a CPU (CPU-bound): la tarea pasa la mayor parte de su tiempo realmente calculando —codificando video, comprimiendo un archivo, ejecutando un hash criptográfico, evaluando un modelo de machine learning, renderizando un PDF. El recurso limitante es genuinamente cuántos núcleos tienes, porque ninguna planificación por ingeniosa que sea logra que un núcleo haga dos multiplicaciones a la vez.
La mayoría de los servicios de backend reales son una mezcla, y lo práctico es identificar qué partes del camino de una solicitud son de cada tipo: las partes de serializar JSON, consultar la base de datos y revisar la caché de un endpoint CRUD típico son abrumadoramente ligadas a E/S; un endpoint de generación de miniaturas o renderizado de reportes al lado suyo está ligado a CPU, en lugar de asumir que todo el servicio es una cosa u otra.
#Los tres modelos
Hilo por solicitud (thread-per-request). Cada solicitud entrante recibe un hilo dedicado (un hilo del sistema operativo en contenedores de servlets clásicos de Java como Tomcat, o un hilo verde en algo como Puma de Ruby) que ejecuta el manejador de principio a fin como código síncrono y bloqueante ordinario: una llamada a la base de datos simplemente bloquea ese hilo hasta que regresa. El código se lee exactamente como un script, sin callbacks ni futures que razonar, lo cual es la mayor ventaja del modelo: los stack traces son lineales, depurar es directo, y no hay riesgo de bloquear accidentalmente un event loop compartido. Su techo lo fija cuán caro sea un hilo en ese runtime: los hilos del sistema operativo suelen reservar una pila fija (a menudo ~1MB por defecto en la JVM) y cuestan memoria real y overhead del planificador solo por estar ahí bloqueados, así que decenas de miles de conexiones lentas concurrentes pueden agotar la memoria o los límites de hilos del sistema operativo mucho antes de que la CPU sea el cuello de botella: el clásico problema C10K.
Event loop (asíncrono de un solo hilo). Un hilo (o un número pequeño y fijo de ellos) corre un bucle que despacha cualquier operación de E/S que se haya vuelto lista: Node.js, el asyncio de Python y los procesos worker de Nginx siguen esta forma. Nada puede bloquear ese hilo; en lugar de hacer una lectura bloqueante, el código registra un callback o espera una promesa/future, y el bucle sigue atendiendo otro trabajo listo mientras el kernel maneja la E/S real de forma asíncrona. Como una "conexión" bloqueada en E/S casi no cuesta nada mientras espera (sin hilo dedicado, sin pila dedicada), un solo event loop puede mantener abiertas decenas de miles de conexiones concurrentes de forma barata: su techo es la memoria por objeto de conexión, no el costo por hilo. Su punto débil es exactamente el reverso de esa eficiencia: cualquier trabajo ligado a CPU ejecutado en el hilo del bucle —un parseo de JSON grande, una operación criptográfica síncrona, un bucle ajustado— bloquea todas las demás solicitudes pendientes en ese hilo mientras dure, porque no hay apropiación de CPU (preemption).
Pool de workers (pool de hilos o de procesos para cómputo paralelo real). Un conjunto fijo o acotado de workers ejecuta realmente en paralelo entre núcleos: un pool de hilos del sistema operativo en un runtime con hilos paralelos de verdad (Java, Go, Rust, C#, Kotlin/JVM), o procesos separados del sistema operativo en un runtime con una restricción tipo GIL (multiprocessing de Python, o varios procesos worker de un solo hilo detrás de un balanceador de carga, que es cómo Gunicorn o Puma en modo clúster logran usar varios núcleos con Python o Ruby). Este es el único de los tres modelos que da paralelismo real de CPU, porque mapea el trabajo sobre varios contextos de ejecución planificados de forma independiente en lugar de multiplexarlo sobre uno solo. El compromiso es el costo de coordinación: mover trabajo a un pool de workers desde un hilo que maneja solicitudes implica algún tipo de traspaso (una cola, un canal de IPC, mensajes serializados entre procesos), y un pool más grande que el número de núcleos no aporta nada para cómputo puro, solo suma overhead de cambio de contexto, reflejando la misma matemática que gobierna el dimensionamiento de pools de hilos ligados a CPU en general.
Vale la pena nombrar una cuarta forma porque cada vez difumina más las dos primeras: corrutinas / hilos verdes M:N (las goroutines de Go, las corrutinas de Kotlin, los procesos de Erlang/Elixir). El código se escribe en el estilo síncrono y lineal de hilo por solicitud, pero el runtime multiplexa una cantidad muy grande de estas unidades ligeras sobre un pool pequeño de hilos del sistema operativo, estacionando una corrutina en lugar de su hilo del sistema operativo subyacente cuando se bloquea en E/S, dando la eficiencia de costo por conexión del event loop con la ergonomía de depuración del código síncrono, y además repartiéndose entre varios hilos del sistema operativo (el GOMAXPROCS de Go) para lograr ejecución paralela real también en segmentos ligados a CPU, algo que ni un thread-per-request puro ni un event loop de un solo hilo puro logran por sí solos.
#Comparación de un vistazo
| Modelo | Cómo maneja una solicitud | Mejor para | Techo de concurrencia | Modo de fallo principal |
|---|---|---|---|---|
| Hilo por solicitud | Hilo dedicado (SO o verde), bloquea de forma síncrona | Ligado a E/S, concurrencia moderada, se valora la simplicidad sobre la escala bruta | Acotado por número de hilos × costo de memoria/pila por hilo | Agotamiento del pool de hilos / OOM con muchas conexiones lentas |
| Event loop | Un hilo, E/S no bloqueante, callbacks/corrutinas | Ligado a E/S, cantidades muy altas de conexiones, costo bajo por conexión | Acotado por memoria por conexión, no por hilos | Una tarea ligada a CPU en el bucle detiene todas las demás solicitudes |
| Pool de workers (hilos o procesos) | Pool fijo ejecutando en paralelo de verdad | Cómputo ligado a CPU, o aislar trabajo pesado fuera del camino de la solicitud | Acotado por el número de núcleos para trabajo de CPU | Sobredimensionar más allá de los núcleos suma overhead sin ganancia; subdimensionar genera colas |
| Corrutinas / hilos verdes M:N | Unidades ligeras multiplexadas sobre pocos hilos del SO | Ligado a E/S a gran escala, escrito en estilo síncrono; también algo de paralelismo de CPU | Muy alto (acotado sobre todo por memoria, no por límites de hilos del SO) | Una syscall bloqueante no integrada con el runtime igual puede detener un hilo del SO |
#El marco de decisión
- Mide, no adivines. Determina, por cada camino de código, si el tiempo está dominado por espera o por cómputo.
- Puramente ligado a E/S, con necesidad de concurrencia muy alta (decenas de miles de conexiones simultáneas, por ejemplo un gateway de chat, un proxy, un API gateway): recurre a un event loop o a corrutinas M:N. El thread-per-request llegará mucho antes a límites de hilos/memoria para la misma cantidad de conexiones.
- Puramente ligado a CPU: el paralelismo está topado por el número de núcleos sin importar qué modelo elijas, así que dimensiona un pool de workers aproximadamente al número de núcleos y usa hilos paralelos reales si tu runtime los tiene (Java, Go, Rust, C#) o procesos separados si tiene una restricción tipo GIL (Python, e históricamente el MRI de Ruby).
- Mixto, que es el caso común para un backend web típico (ligado a E/S la mayor parte del tiempo, con picos ocasionales ligados a CPU como generación de reportes, procesamiento de imágenes o hashing de contraseñas): mantén el camino de atención de solicitudes en el modelo orientado a E/S que hayas elegido, y delega explícitamente los trozos ligados a CPU a un pool de workers o una cola de jobs separada, para que un cómputo lento nunca bloquee las conexiones que atienden todo lo demás. Este es el bug de concurrencia más común en servicios de event loop en producción: alguien corre una operación síncrona y pesada de CPU directamente en un manejador de solicitudes y eso detiene todas las demás solicitudes en curso en ese proceso.
- Sopesa la simplicidad operativa contra el techo de escala que realmente necesitas. Thread-per-request es el modelo más fácil de escribir, depurar y enseñar a nuevos ingenieros: elígelo cuando tus necesidades reales de concurrencia (cientos hasta unos pocos miles de conexiones) no requieran exprimir cada gota de eficiencia por conexión. Recurre a event loops o corrutinas M:N solo cuando de verdad necesites ese techo más alto, ya que exigen más disciplina (nunca bloquear el hilo compartido) a cambio de ese techo.
🧠 Check your understanding
0/2 · 0/2 answered1. Un servicio de backend maneja dos tipos de solicitudes: obtener perfiles de usuario (una única consulta rápida a la base de datos) y generar facturas en PDF (renderizado pesado en el propio proceso). Ambas corren actualmente en el mismo event loop de un solo hilo. Los usuarios reportan que obtener perfiles se vuelve lento cada vez que se generan muchas facturas a la vez. ¿Cuál es la causa raíz y la solución correcta?
2. Un servicio necesita mantener abiertas aproximadamente 50,000 conexiones concurrentes de larga duración que están inactivas casi todo el tiempo (por ejemplo, un gateway de notificaciones/streaming), con un trabajo de CPU insignificante por conexión. ¿Qué modelo es el más adecuado, y por qué?