¿Cómo se dimensiona correctamente un pool de hilos, y qué sale mal si te equivocas?
Dimensionar un pool de hilos parece un simple parámetro de configuración, pero el número equivocado convierte silenciosamente un servicio sano en una cola que nunca se vacía.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo se dimensiona correctamente un pool de hilos, y qué sale mal si te equivocas?
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.#Por qué se pregunta esto en entrevistas
Casi todo desarrollador backend ha escrito alguna vez Executors.newFixedThreadPool(10) sin pensar demasiado en ese 10. Esta pregunta separa a quienes copiaron esa línea de quienes entienden realmente qué trade-offs implica un pool de hilos. Es una pregunta "básica" en el sentido de que la idea de fondo es simple, pero los modos de falla son lo bastante sutiles como para aparecer en incidentes de producción en cualquier empresa que corra una JVM, un clúster de Node o un servidor Go bajo carga real.
Un pool de hilos existe para acotar la concurrencia: en vez de crear un hilo del sistema operativo por cada solicitud (costoso, y eventualmente fatal bajo carga), reutilizas un conjunto fijo de hilos trabajadores y encolas el trabajo frente a ellos. El tamaño de ese pool determina dos cosas a la vez: cuánto trabajo paralelo puedes hacer, y cuánta memoria y overhead de cambio de contexto pagas por ese privilegio.
#Trabajo ligado a CPU vs. ligado a I/O
El tamaño correcto depende por completo de qué hacen realmente las tareas mientras se ejecutan.
- Trabajo ligado a CPU (hashing, redimensionado de imágenes, serialización JSON a gran escala, compresión) mantiene un núcleo ocupado todo el tiempo que corre. Agregar más hilos que núcleos no crea más cómputo: solo obliga al planificador del sistema operativo a repartir el tiempo entre ellos, lo que suma overhead de cambio de contexto y contaminación de caché sin aumentar el throughput. La regla general aquí ronda el
número de núcleos(a veces núcleos + 1, para que un hilo mantenga la CPU ocupada mientras otro espera por un fallo de página o su turno de planificación). - Trabajo ligado a I/O (llamadas a bases de datos, llamadas HTTP a otros servicios, lecturas de disco) pasa la mayor parte de su tiempo sin hacer nada a nivel de cómputo: está estacionado esperando un socket o un controlador de disco. Aquí, tener muchísimos más hilos que núcleos no solo está bien, es necesario, porque la mayoría de esos hilos están bloqueados, no compitiendo por CPU.
Una fórmula habitual (derivada de teoría de colas, adaptada de Java Concurrency in Practice) es:
hilos = núcleos * utilización_objetivo * (1 + tiempo_de_espera / tiempo_de_cómputo)
Si tienes 8 núcleos, quieres 90% de utilización, y cada tarea pasa 5 unidades esperando I/O por cada 1 unidad de trabajo de CPU real, eso da 8 * 0.9 * (1 + 5) = 43.2, es decir, unos 43-45 hilos. Nota cuán lejos está eso de "uso 8 hilos porque tengo 8 núcleos": ese instinto es correcto para trabajo ligado a CPU y está muy equivocado para trabajo ligado a I/O.
#La cola hace más de lo que parece
Aquí está la parte que confunde tanto en entrevistas como en producción: en el ThreadPoolExecutor de Java, el parámetro maximumPoolSize es, con frecuencia, configuración muerta. El executor solo crea un hilo más allá de corePoolSize cuando una tarea enviada es rechazada por la cola (es decir, queue.offer() devuelve false porque la cola está llena y es acotada). Si le entregas una cola no acotada — que es justo lo que usan internamente Executors.newFixedThreadPool y Executors.newCachedThreadPool en algunas configuraciones — la cola siempre tiene espacio, así que el pool nunca crece más allá de corePoolSize, y maximumPoolSize nunca hace nada. Peor aún: las tareas simplemente se apilan en esa cola no acotada durante un pico de carga, consumiendo memoria en silencio hasta que el proceso se queda sin ella. No hay backpressure, ni advertencia visible: solo una cola que crece lentamente y luego un OutOfMemoryError que parece no tener relación con su causa real.
int cores = Runtime.getRuntime().availableProcessors();double targetUtilization = 0.9;double waitToComputeRatio = 5.0; // p. ej. 5ms esperando I/O por cada 1ms de CPUint poolSize = (int) Math.ceil(cores * targetUtilization * (1 + waitToComputeRatio));// Cola acotada: al llenarse, los nuevos envíos activan el rejection handler// en vez de hacer crecer la memoria sin límite.BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(500);ThreadPoolExecutor executor = new ThreadPoolExecutor(poolSize, // corePoolSizepoolSize, // maximumPoolSize - igual a propósito aquí60L, TimeUnit.SECONDS, // keepAliveTime para hilos ociosos sobre el coreworkQueue,new ThreadPoolExecutor.CallerRunsPolicy() // backpressure real, no silencio);
CallerRunsPolicy hace un trabajo real en ese fragmento: cuando la cola está llena y el pool está al máximo, ejecuta la tarea rechazada en el hilo que la envió en vez de en el pool. Eso frena a quien está enviando trabajo, que es exactamente lo que quieres, porque regula al productor en vez de esconder la sobrecarga detrás de una cola que crece sin parar.
Ese diagrama también responde a "cuándo crece realmente el pool": solo por el camino D -- No --> F -- Sí, al que una cola no acotada nunca puede llegar.
#Qué sale mal en cada extremo
| Síntoma | Muy pocos hilos | Demasiados hilos |
|---|---|---|
| Latencia | Las solicitudes se acumulan detrás de hilos ocupados; la latencia p99 sube aunque la CPU se vea ociosa (los hilos están bloqueados en I/O, no en la CPU) | Los hilos compiten por núcleos y locks compartidos; más cambios de contexto, no más throughput |
| Memoria | La cola crece sin límite si las solicitudes llegan más rápido de lo que se drenan | Cada hilo reserva una pila (típicamente 512KB-1MB por defecto en la JVM); miles de hilos pueden agotar la memoria por sí solos |
| Modo de falla | Las solicitudes hacen timeout del lado del cliente mientras la CPU del servidor apenas se usa | El servidor "parece" ocupado (CPU alta, load average alto) mientras el throughput de trabajo útil cae |
| Una trampa sutil | Un handler envía una subtarea al mismo pool y espera bloqueado su resultado - si el pool está saturado, esa subtarea nunca corre y el hilo del handler espera para siempre (deadlock por agotamiento del pool) | — |
#Ideas clave
- Clasifica primero la carga de trabajo: lo ligado a CPU quiere hilos cercanos al número de núcleos; lo ligado a I/O quiere muchos más, dimensionados según cuánto tiempo pasan esperando.
- Una cola de trabajo no acotada anula
maximumPoolSizey esconde la sobrecarga como crecimiento silencioso de memoria en vez de backpressure visible. - Elige una política de rechazo a propósito -
CallerRunsPolicypara backpressure,AbortPolicysi quieres que las fallas sean ruidosas - pero nunca dejes que el valor por defecto te sorprenda. - Vigila la profundidad de la cola y el número de hilos activos en producción; ese es el verdadero bucle de retroalimentación, la fórmula es solo el punto de partida.
🧠 Check your understanding
0/2 · 0/2 answered1. En el ThreadPoolExecutor de Java, ¿cuándo crea realmente el pool un hilo más allá de corePoolSize?
2. Un servicio pasa la mayor parte de su tiempo esperando una llamada HTTP downstream en vez de hacer trabajo de CPU. ¿Qué implica esto para el dimensionamiento del pool de hilos?