IO-Bound vs CPU-bound
Cuánta concurrencia o paralelismo proporciona una ganancia de rendimiento depende de la naturaleza de la carga de trabajo. En las aplicaciones de servidor, se distinguen típicamente dos tipos principales de tareas.
- IO-bound – tareas donde una parte significativa del tiempo se dedica a esperar operaciones de entrada/salida: solicitudes de red, consultas a bases de datos, lectura y escritura de archivos. Durante estos momentos, la CPU permanece inactiva.
- CPU-bound – tareas que requieren cálculos intensivos que mantienen al procesador ocupado casi constantemente: algoritmos complejos, procesamiento de datos, criptografía.
En los últimos años, la mayoría de las aplicaciones web se han desplazado hacia cargas de trabajo IO-bound.
Esto es impulsado por el crecimiento de microservicios, APIs remotas y servicios en la nube.
Enfoques como Frontend for Backend (BFF) y API Gateway, que agregan datos de múltiples fuentes,
amplifican este efecto.
Una aplicación de servidor moderna también es difícil de imaginar sin logging, telemetría y monitoreo en tiempo real. Todas estas operaciones son inherentemente IO-bound.
Eficiencia de las tareas IO-bound
La eficiencia de la ejecución concurrente de tareas IO-bound está determinada por
qué fracción de tiempo la tarea realmente utiliza la CPU
versus cuánto tiempo pasa esperando que se completen las operaciones de I/O.
Ley de Little
En la teoría de colas, una de las fórmulas fundamentales es la Ley de Little (Little’s Law):
\[L = \lambda \cdot W\]Donde:
L– el número promedio de tareas en el sistemaλ– la tasa promedio de solicitudes entrantesW– el tiempo promedio que una tarea pasa en el sistema
Esta ley es universal y no depende de la implementación específica del sistema: no importa si se utilizan hilos, corrutinas o callbacks asíncronos. Describe la relación fundamental entre carga, latencia y el nivel de concurrencia.
Al estimar la concurrencia para una aplicación de servidor, esencialmente estás resolviendo el problema de cuántas tareas deben estar en el sistema simultáneamente para que los recursos se utilicen eficientemente.
Para cargas de trabajo IO-bound, el tiempo promedio de procesamiento de solicitudes es grande
en comparación con el tiempo dedicado a cálculos activos.
Por lo tanto, para que la CPU no permanezca inactiva, debe haber
un número suficiente de tareas concurrentes en el sistema.
Esta es exactamente la cantidad que el análisis formal permite estimar, relacionando:
- tiempo de espera,
- throughput,
- y el nivel requerido de concurrencia.
Un enfoque similar se utiliza en la industria para calcular el tamaño óptimo del pool de hilos (ver Brian Goetz, “Java Concurrency in Practice”).
Los datos estadísticos reales para cada elemento de estas fórmulas (número de consultas SQL por solicitud HTTP, latencias de BD, throughput de frameworks PHP) están recopilados en un documento separado: Datos estadísticos para el cálculo de concurrencia.
Utilización básica de CPU
Para calcular qué fracción de tiempo el procesador realmente realiza trabajo útil al ejecutar una sola tarea, se puede usar la siguiente fórmula:
\[U = \frac{T_{cpu}}{T_{cpu} + T_{io}}\]T_cpu– el tiempo dedicado a realizar cálculos en la CPUT_io– el tiempo dedicado a esperar operaciones de I/O
La suma T_cpu + T_io representa el tiempo de vida total de una tarea
desde el inicio hasta la finalización.
El valor U varía de 0 a 1 e indica el grado
de utilización del procesador:
U → 1caracteriza una tarea computacionalmente pesada (CPU-bound)U → 0caracteriza una tarea que pasa la mayor parte del tiempo esperando I/O (IO-bound)
Así, la fórmula proporciona una evaluación cuantitativa de
cómo se está utilizando eficientemente la CPU
y si la carga de trabajo en cuestión es IO-bound o CPU-bound.
Impacto de la concurrencia
Al ejecutar múltiples tareas IO-bound concurrentemente, la CPU puede usar
el tiempo de espera de I/O de una tarea para realizar cálculos para otra.
La utilización de CPU con N tareas concurrentes se puede estimar como:
Aumentar la concurrencia mejora la utilización de la CPU,
pero solo hasta un cierto límite.
Límite de eficiencia
La ganancia máxima de la concurrencia está limitada por la relación
entre el tiempo de espera de I/O y el tiempo de cálculo:
En la práctica, esto significa que el número de tareas
concurrentes realmente útiles es aproximadamente igual a la relación T_io / T_cpu.
Concurrencia óptima
\[N_{opt} \approx 1 + \frac{T_{io}}{T_{cpu}}\]El uno en la fórmula tiene en cuenta la tarea que actualmente se ejecuta en la CPU.
Con una relación T_io / T_cpu grande (lo cual es típico para cargas IO-bound),
la contribución de uno es despreciable, y la fórmula a menudo se simplifica a T_io / T_cpu.
Esta fórmula es un caso especial (para un solo núcleo) de la clásica fórmula para el tamaño óptimo del pool de hilos propuesta por Brian Goetz en el libro “Java Concurrency in Practice” (2006):
\[N_{threads} = N_{cores} \times \left(1 + \frac{T_{wait}}{T_{service}}\right)\]La relación T_wait / T_service se conoce como el coeficiente de bloqueo.
Cuanto mayor sea este coeficiente, más tareas concurrentes
pueden ser utilizadas eficazmente por un solo núcleo.
A este nivel de concurrencia, el procesador pasa la mayor parte del tiempo realizando trabajo útil, y aumentar aún más el número de tareas ya no produce una ganancia notable.
Esta es precisamente la razón por la que los modelos de ejecución asíncrona
son más efectivos para cargas de trabajo web IO-bound.
Cálculo de ejemplo para una aplicación web típica
Consideremos un modelo simplificado pero bastante realista de una aplicación web promedio del lado del servidor.
Supongamos que el procesamiento de una sola solicitud HTTP implica principalmente la interacción con una base de datos
y no contiene operaciones computacionalmente complejas.
Suposiciones iniciales
- Se ejecutan aproximadamente 20 consultas SQL por solicitud HTTP
- El cálculo se limita al mapeo de datos, serialización de respuesta y logging
- La base de datos está fuera del proceso de la aplicación (I/O remoto)
¿Por qué 20 consultas? Esta es la estimación mediana para aplicaciones ORM de complejidad moderada. Para comparación:
- WordPress genera ~17 consultas por página,
- Drupal sin caché – de 80 a 100,
- y una aplicación típica Laravel/Symfony – de 10 a 30.
La principal fuente de crecimiento es el patrón N+1, donde el ORM carga entidades relacionadas con consultas separadas.
Estimación del tiempo de ejecución
Para la estimación, usaremos valores promediados:
- Una consulta SQL:
- Tiempo de espera I/O:
T_io ≈ 4 ms - Tiempo de cálculo CPU:
T_cpu ≈ 0,05 ms
- Tiempo de espera I/O:
Total por solicitud HTTP:
T_io = 20 × 4 ms = 80 msT_cpu = 20 × 0,05 ms = 1 ms
Sobre los valores de latencia elegidos. El tiempo de I/O para una sola consulta
SQLconsiste en la latencia de red (round-trip) y el tiempo de ejecución de la consulta en el servidor de BD. El round-trip de red dentro de un solo centro de datos es ~0,5 ms, y para entornos cloud (cross-AZ, RDS gestionado) – 1–5 ms. Teniendo en cuenta el tiempo de ejecución de una consulta moderadamente compleja, los resultantes 4 ms por consulta son una estimación realista para un entorno cloud. El tiempo de CPU (0,05 ms) cubre el mapeo de resultados ORM, la hidratación de entidades y la lógica de procesamiento básica.
Características de la carga de trabajo
La relación entre el tiempo de espera y el tiempo de cálculo:
\[\frac{T_{io}}{T_{cpu}} = \frac{80}{1} = 80\]Esto significa que la tarea es predominantemente IO-bound: el procesador pasa la mayor parte del tiempo inactivo, esperando que se completen las operaciones de I/O.
Estimación del número de corrutinas
El número óptimo de corrutinas concurrentes por núcleo de CPU es aproximadamente igual a la relación entre el tiempo de espera de I/O y el tiempo de cálculo:
\[N_{coroutines} \approx \frac{T_{io}}{T_{cpu}} \approx 80\]En otras palabras, aproximadamente 80 corrutinas por núcleo permiten ocultar virtualmente por completo la latencia de I/O mientras se mantiene una alta utilización de CPU.
Para comparación: Zalando Engineering
proporciona un ejemplo con un microservicio donde el tiempo de respuesta es 50 ms y el tiempo de procesamiento es 5 ms
en una máquina de doble núcleo: 2 × (1 + 50/5) = 22 hilos – el mismo principio, la misma fórmula.
Escalado por número de núcleos
Para un servidor con C núcleos:
Por ejemplo, para un procesador de 8 núcleos:
\[N_{total} \approx 8 \times 80 = 640 \text{ corrutinas}\]Este valor refleja el nivel útil de concurrencia, no un límite rígido.
Sensibilidad al entorno
El valor de 80 corrutinas por núcleo no es una constante universal, sino el resultado de suposiciones específicas sobre la latencia de I/O. Dependiendo del entorno de red, el número óptimo de tareas concurrentes puede diferir significativamente:
| Entorno | T_io por consulta SQL | T_io total (×20) | N por núcleo |
|---|---|---|---|
| Localhost / Unix-socket | ~0,1 ms | 2 ms | ~2 |
| LAN (centro de datos único) | ~1 ms | 20 ms | ~20 |
| Cloud (cross-AZ, RDS) | ~4 ms | 80 ms | ~80 |
| Servidor remoto / cross-region | ~10 ms | 200 ms | ~200 |
Cuanto mayor sea la latencia, más corrutinas se necesitan para utilizar completamente la CPU con trabajo útil.
PHP-FPM vs Corrutinas: Cálculo aproximado
Para estimar el beneficio práctico de las corrutinas, comparemos dos modelos de ejecución en el mismo servidor con la misma carga de trabajo.
Datos iniciales
Servidor: 8 núcleos, entorno cloud (cross-AZ RDS).
Carga de trabajo: endpoint típico de API Laravel – autorización, consultas Eloquent con eager loading, serialización JSON.
Basado en datos de benchmarks de Sevalla y Kinsta:
| Parámetro | Valor | Fuente |
|---|---|---|
| Throughput de API Laravel (30 vCPU, DB localhost) | ~440 req/s | Sevalla, PHP 8.3 |
| Número de workers PHP-FPM en el benchmark | 15 | Sevalla |
| Tiempo de respuesta (W) en el benchmark | ~34 ms | L/λ = 15/440 |
| Memoria por worker PHP-FPM | ~40 MB | Valor típico |
Paso 1: Estimación de T_cpu y T_io
En el benchmark de Sevalla, la base de datos se ejecuta en localhost (latencia <0,1 ms). Con ~10 consultas SQL por endpoint, el I/O total es menos de 1 ms.
Dado:
- Throughput: λ ≈ 440 req/s
- Número de solicitudes servidas simultáneamente (workers PHP-FPM): L = 15
- Base de datos en localhost, por lo que T_io ≈ 0
Por la Ley de Little:
\[W = \frac{L}{\lambda} = \frac{15}{440} \approx 0,034 \, \text{s} \approx 34 \, \text{ms}\]Dado que en este benchmark la base de datos se ejecuta en localhost
y el I/O total es menos de 1 ms,
el tiempo de respuesta promedio resultante refleja casi completamente
el tiempo de procesamiento CPU por solicitud:
Esto significa que bajo condiciones de localhost, casi todo el tiempo de respuesta (~34 ms) es CPU:
framework, middleware, ORM, serialización.
Movamos el mismo endpoint a un entorno cloud con 20 consultas SQL:
Coeficiente de bloqueo:
\[\frac{T_{io}}{T_{cpu}} = \frac{80}{34} \approx 2,4\]Paso 2: PHP-FPM
En el modelo PHP-FPM, cada worker es un proceso del SO separado.
Durante la espera de I/O, el worker se bloquea y no puede procesar otras solicitudes.
Para utilizar completamente 8 núcleos, se necesitan suficientes workers
para que en cualquier momento dado, 8 de ellos estén realizando trabajo CPU:
| Métrica | Valor |
|---|---|
| Workers | 27 |
| Memoria (27 × 40 MB) | 1,08 GB |
| Throughput (27 / 0,114) | 237 req/s |
| Utilización de CPU | ~100% |
En la práctica, los administradores a menudo establecen pm.max_children = 50--100,
que está por encima del óptimo. Los workers adicionales compiten por CPU,
aumentan el número de cambios de contexto del SO
y consumen memoria sin aumentar el throughput.
Paso 3: Corrutinas (event loop)
En el modelo de corrutinas, un solo hilo (por núcleo) sirve muchas solicitudes. Cuando una corrutina espera I/O, el planificador cambia a otra en ~200 nanosegundos (ver base de evidencia).
El número óptimo de corrutinas es el mismo:
\[N_{coroutines} = 8 \times 3,4 = 27\]| Métrica | Valor |
|---|---|
| Corrutinas | 27 |
| Memoria (27 × ~2 MiB) | 54 MiB |
| Throughput | 237 req/s |
| Utilización de CPU | ~100% |
El throughput es el mismo – porque la CPU es el cuello de botella. Pero la memoria para concurrencia: 54 MiB vs 1,08 GB – una diferencia de ~20x.
Sobre el tamaño del stack de corrutinas. El consumo de memoria de una corrutina en PHP está determinado por el tamaño reservado del C-stack. Por defecto es ~2 MiB, pero puede reducirse a 128 KiB. Con un stack de 128 KiB, la memoria para 27 corrutinas sería solo ~3,4 MiB.
Paso 4: ¿Qué pasa si la carga de CPU es menor?
El framework Laravel en modo FPM consume ~34 ms de CPU por solicitud,
lo que incluye la reinicialización de servicios en cada solicitud.
En un runtime con estado (que es lo que es True Async), estos costos se reducen significativamente:
las rutas están compiladas, el contenedor de dependencias está inicializado,
los pools de conexiones se reutilizan.
Si T_cpu baja de 34 ms a 5 ms (lo cual es realista para el modo con estado),
el panorama cambia drásticamente:
| T_cpu | Coef. bloqueo | N (8 núcleos) | λ (req/s) | Memoria (FPM) | Memoria (corrutinas) |
|---|---|---|---|---|---|
| 34 ms | 2,4 | 27 | 237 | 1,08 GB | 54 MiB |
| 10 ms | 8 | 72 | 800 | 2,88 GB | 144 MiB |
| 5 ms | 16 | 136 | 1 600 | 5,44 GB | 272 MiB |
| 1 ms | 80 | 648 | 8 000 | 25,9 GB | 1,27 GiB |
Con T_cpu = 1 ms (handler liviano, overhead mínimo):
- PHP-FPM necesitaría 648 procesos y 25,9 GB RAM – poco realista
- Las corrutinas requieren las mismas 648 tareas y 1,27 GiB – ~20x menos
Paso 5: Ley de Little – verificación a través del throughput
Verifiquemos el resultado para T_cpu = 5 ms:
Para lograr el mismo throughput, PHP-FPM necesita 136 workers. Cada uno ocupa ~40 MB:
\[136 \times 40 \text{ MB} = 5,44 \text{ GB solo para workers}\]Corrutinas:
\[136 \times 2 \text{ MiB} = 272 \text{ MiB}\]Los ~5,2 GB liberados pueden dirigirse hacia cachés, pools de conexiones de BD o el manejo de más solicitudes.
Resumen: Cuándo las corrutinas proporcionan un beneficio
| Condición | Beneficio de las corrutinas |
|---|---|
| Framework pesado, BD localhost (T_io ≈ 0) | Mínimo – la carga es CPU-bound |
| Framework pesado, BD cloud (T_io = 80 ms) | Moderado – ~20x ahorro de memoria al mismo throughput |
| Handler liviano, BD cloud | Máximo – aumento de throughput hasta 13x, ~20x ahorro de memoria |
| Microservicio / API Gateway | Máximo – I/O casi puro, decenas de miles de req/s en un servidor |
Conclusión: cuanto mayor sea la proporción de I/O en el tiempo total de solicitud y más ligero sea el procesamiento CPU, mayor será el beneficio de las corrutinas. Para aplicaciones IO-bound (que son la mayoría de los servicios web modernos), las corrutinas permiten utilizar la misma CPU varias veces más eficientemente, consumiendo órdenes de magnitud menos memoria.
Notas prácticas
- Aumentar el número de corrutinas por encima del nivel óptimo rara vez proporciona un beneficio, pero tampoco es un problema: las corrutinas son ligeras, y el overhead de corrutinas “extra” es incomparablemente pequeño comparado con el costo de los hilos del SO
- Las limitaciones reales se convierten en:
- pool de conexiones de base de datos
- latencia de red
- mecanismos de back-pressure
- límites de descriptores de archivo abiertos (ulimit)
- Para tales cargas de trabajo, el modelo event loop + corrutinas resulta ser significativamente más eficiente que el modelo clásico de bloqueo
Conclusión
Para una aplicación web moderna típica donde predominan las operaciones de I/O, el modelo de ejecución asíncrona permite:
- ocultar efectivamente la latencia de I/O
- mejorar significativamente la utilización de CPU
- reducir la necesidad de un gran número de hilos
Es precisamente en tales escenarios donde las ventajas de la asincronía se demuestran más claramente.
Lectura adicional
- Swoole en la práctica: Mediciones reales – casos de producción (Appwrite +91%, IdleMMO 35M req/día), benchmarks independientes con y sin BD, TechEmpower
- Python asyncio en la práctica – Duolingo +40%, Super.com -90% costos, benchmarks uvloop, contraargumentos
- Base de evidencia: Por qué funcionan las corrutinas single-threaded – mediciones del costo de cambio de contexto, comparación con hilos del SO, investigación académica y benchmarks industriales
Referencias y literatura
- Brian Goetz, Java Concurrency in Practice (2006) – fórmula para el tamaño óptimo del pool de hilos:
N = cores × (1 + W/S) - Zalando Engineering: How to set an ideal thread pool size – aplicación práctica de la fórmula de Goetz con ejemplos y derivación mediante la Ley de Little
- Backendhance: The Optimal Thread-Pool Size in Java – análisis detallado de la fórmula considerando la utilización objetivo de CPU
- CYBERTEC: PostgreSQL Network Latency – mediciones del impacto de la latencia de red en el rendimiento de PostgreSQL
- PostgresAI: What is a slow SQL query? – directrices para latencias aceptables de consultas SQL en aplicaciones web