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.

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:

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:

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}}\]

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:

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:

\[U_N = \min\left(1,\; N \cdot \frac{T_{cpu}}{T_{cpu} + T_{io}}\right)\]

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:

\[E(N) \approx \min\left(N,\; 1 + \frac{T_{io}}{T_{cpu}}\right)\]

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

¿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:

Total por solicitud HTTP:

Sobre los valores de latencia elegidos. El tiempo de I/O para una sola consulta SQL consiste 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:

\[N_{total} \approx C \cdot \frac{T_{io}}{T_{cpu}}\]

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:

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:

\[T_{cpu} \approx W \approx 34 \, \text{ms}\]

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:

\[T_{cpu} = 34 \text{ ms (framework + l&oacute;gica)}\] \[T_{io} = 20 \times 4 \text{ ms} = 80 \text{ ms (tiempo de espera BD)}\] \[W = T_{cpu} + T_{io} = 114 \text{ ms}\]

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:

\[N_{workers} = 8 \times \left(1 + \frac{80}{34}\right) = 8 \times 3,4 = 27\]
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):

Paso 5: Ley de Little – verificación a través del throughput

Verifiquemos el resultado para T_cpu = 5 ms:

\[\lambda = \frac{L}{W} = \frac{136}{0,085} = 1\,600 \text{ req/s}\]

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

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:

Es precisamente en tales escenarios donde las ventajas de la asincronía se demuestran más claramente.


Lectura adicional


Referencias y literatura