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 entrantes
  • W -- 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 CPU
  • T_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 → 1 caracteriza una tarea computacionalmente pesada (CPU-bound)
  • U → 0 caracteriza 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:

$$ 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

  • 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

Total por solicitud HTTP:

  • T_io = 20 × 4 ms = 80 ms
  • T_cpu = 20 × 0,05 ms = 1 ms

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:

EntornoT_io por consulta SQLT_io total (×20)N por núcleo
Localhost / Unix-socket~0,1 ms2 ms~2
LAN (centro de datos único)~1 ms20 ms~20
Cloud (cross-AZ, RDS)~4 ms80 ms~80
Servidor remoto / cross-region~10 ms200 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ámetroValorFuente
Throughput de API Laravel (30 vCPU, DB localhost)~440 req/sSevalla, PHP 8.3
Número de workers PHP-FPM en el benchmark15Sevalla
Tiempo de respuesta (W) en el benchmark~34 msL/λ = 15/440
Memoria por worker PHP-FPM~40 MBValor 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:

$$ 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ó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étricaValor
Workers27
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étricaValor
Corrutinas27
Memoria (27 × ~2 MiB)54 MiB
Throughput237 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_cpuCoef. bloqueoN (8 núcleos)λ (req/s)Memoria (FPM)Memoria (corrutinas)
34 ms2,4272371,08 GB54 MiB
10 ms8728002,88 GB144 MiB
5 ms161361 6005,44 GB272 MiB
1 ms806488 00025,9 GB1,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:

$$ \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ónBeneficio 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 cloudMáximo -- aumento de throughput hasta 13x, ~20x ahorro de memoria
Microservicio / API GatewayMá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


Referencias y literatura