El techo del pool de workers: cuando un nodo se estanca al 25% de CPU
Aquí hay un resultado de benchmark que parece una contradicción. Un nodo de Zaris sirviendo una carga de trabajo intensiva en lecturas tope en poco menos de medio millón de ops/seg y se niega a trepar —añade más conexiones de cliente, más generadores de carga, y el número no se mueve. El diagnóstico obvio es "está saturado". Excepto que el gráfico de CPU dice otra cosa: el nodo está al cuarto de su CPU disponible, aproximadamente. Tres núcleos de cada cuatro están ociosos, y el throughput está plano.
Eso no es un nodo limitado por núcleos, y —si lo has descartado con cuidado— tampoco es un benchmark limitado por el cliente. Es una tercera cosa, y es un clásico: el pool de workers de despacho de solicitudes era más estrecho de lo que el trabajo quería ser, así que las solicitudes se encolaban mientras los núcleos estaban de brazos cruzados.
Primero, descarta a los sospechosos habituales
Dos explicaciones tienen que irse antes de que esta esté siquiera sobre la mesa, porque son mucho más comunes.
Limitado por núcleos. Si el nodo está en o cerca del 100% de CPU y el throughput está plano, genuinamente te has quedado sin CPU —la respuesta es más núcleos o más nodos, y Zaris escala las lecturas añadiendo núcleos. Ese no es este caso: aquí la CPU está ociosa.
Limitado por el cliente. Un solo generador de carga se satura mucho antes que el servidor, y entonces estás midiendo tu cliente, no Zaris. Si el throughput está plano a medida que añades núcleos de servidor, sospecha de tu cliente primero —añade generadores y conexiones hasta que el número del lado del servidor realmente responda. Cubrimos esta trampa en el artículo sobre lecturas, y es la que hay que eliminar antes de seguir adelante.
Lo que queda después de esos dos es el caso interesante: suficiente carga ofrecida para mantener ocupado al nodo, mucho margen de CPU, y el throughput todavía plano. Cuando el cuello de botella está dentro del servidor pero no es la CPU, lo que hay que mirar es la concurrencia —cuántas solicitudes el nodo trabajará a la vez.
El pool de despacho es el límite de concurrencia
Cada solicitud que llega a un nodo de Zaris se entrega a un pool de workers de despacho de solicitudes. Un worker la recoge, la enruta a la partición que posee la clave, ejecuta la operación contra el almacén en proceso de esa partición, y escribe la respuesta de vuelta. Para una lectura, la operación en sí es una búsqueda en memoria a escala de microsegundo —genuinamente diminuta.
El ancho de ese pool —cuántos workers pueden estar a mitad de solicitud simultáneamente— fija un techo duro sobre las solicitudes en vuelo, y ese es un número distinto de "qué tan rápido es una operación". El valor por defecto original derivaba el ancho de un pequeño múltiplo de Environment.ProcessorCount. En un nodo de 4 vCPU, un pool de un par-de-veces-los-núcleos son solo un puñado de workers.
Aquí está la sutileza que hace que el gráfico de CPU parezca equivocado. Si cada worker gastara toda su ranura quemando CPU en la búsqueda, un pool dimensionado a los núcleos sería exactamente correcto y el nodo sí alcanzaría el 100% de CPU. Pero los workers no gastan toda su ranura en CPU. Un worker ocasionalmente se estaciona (parks) —una continuación de await, una escritura de socket que no se completa síncronamente, un breve lock sobre una estructura compartida. Un worker estacionado no quema CPU, pero aún sostiene su ranura del pool. Con solo un puñado de ranuras, unos pocos workers estacionados bastan para estancar todo el nodo: las nuevas solicitudes se encolan detrás de ellos, y como los workers ocupados están mayormente esperando en lugar de computando, los núcleos nunca se encienden. Throughput plano, CPU ociosa. El ancho del pool era el techo, no el silicio.
La señal que distingue esto de un benchmark limitado por el cliente: las solicitudes se encolan en el servidor (la latencia trepa, el throughput está plano) mientras la CPU del servidor se mantiene baja. Limitado por el cliente se ve distinto —el servidor está ocioso porque nada llega lo bastante rápido. Aquí, el trabajo está llegando; el nodo simplemente no acepta suficiente de él a la vez.
El arreglo: dimensiona el pool para el trabajo, no para el conteo de núcleos
La reparación no es exótica. Las lecturas son búsquedas en memoria a escala de microsegundo, así que el pool debería estar fijado ancho por defecto —lo bastante ancho como para que un puñado de workers estacionados no pueda hambrear al nodo— y debería ser ajustable, porque el ancho correcto depende del nodo y la carga de trabajo, no de un múltiplo fijo del conteo de núcleos.
El ancho de pool por defecto ahora es CPU count × 16, acotado a una banda sensata de 64–1024, con un piso de max(4, CPU count × 4). Dos variables de entorno lo anulan:
| Variable | Por defecto | Qué hace |
|---|---|---|
ZARIS_SERVER_MAX_WORKERS | CPU count × 16 (acotado 64–1024) | Anulación dura (1–4096) para el ancho del pool de despacho. Súbelo cuando un nodo tiene CPU de sobra y las solicitudes se encolan. |
ZARIS_SERVER_MIN_WORKERS | max(4, CPU count × 4) | Cota inferior (1–4096) sobre el ancho del pool. |
# A node with spare CPU and requests queuing — widen the pool.
ZARIS_SERVER_MAX_WORKERS=512 ZARIS_SERVER_MIN_WORKERS=64 \
dotnet Clustron.Zaris.Server.dll
Una propiedad que vale la pena señalar: este es un cambio del lado del servidor y nada más. El protocolo de cable no cambia, el SDK del cliente no cambia, y no hay un bump de NuGet que perseguir —afinas un nodo reiniciándolo con un entorno distinto, y cada cliente existente sigue hablando con él sin cambios.
Afínalo con honestidad: ensanchar no es gratis
El reflejo tras leer esto es subir ZARIS_SERVER_MAX_WORKERS al tope en cada nodo. No lo hagas. Un pool más ancho ayuda en exactamente una situación y no hace nada —o perjudica levemente— en las demás.
- Ayuda cuando las solicitudes se encolan mientras la CPU está ociosa. Ese es el caso de todo este artículo: la concurrencia en vuelo es el límite, así que admitir más de ella llena los núcleos. Aquí es donde verás saltar el throughput.
- No hace nada cuando ya estás limitado por CPU. Si el nodo está al 100% de CPU, más workers no conjurarán throughput de la nada —solo añaden sobrecarga de cambio de contexto a una máquina que no tiene ciclos de sobra. Más workers no es más cómputo.
- Puede perjudicar si lo llevas a un ancho absurdo. Miles de workers machacando un nodo de 2 vCPU gastan tiempo real cambiando de contexto en lugar de servir. Por eso hay un acotamiento, y por eso la perilla tope en 4096 en lugar del infinito.
El bucle de afinado honesto es: busca la firma —latencia trepando y throughput plano mientras la CPU está muy por debajo de la saturación— sube MAX_WORKERS, y mide. Si la CPU trepa y el throughput la sigue, el pool era tu techo. Si nada se mueve, no lo era, y deberías devolver la perilla y mirar en otro lugar (la ruta de red, el cliente, o genuinamente necesitar más nodos).
Mide la CPU donde es realmente cierta
Toda esta investigación depende de confiar en el número de CPU, y ese número es fácil de obtener mal.
- Lee la CPU del cgroup, no de una estimación del metrics-server del clúster. En Kubernetes especialmente, la cifra del metrics-server se retrasa y suaviza —te dirá que un nodo está al 40% cuando la contabilidad del cgroup dice que está clavado, o al revés. Diagnostica un caso de "throughput plano, CPU ociosa" desde el cgroup, o perseguirás un número que no es real.
- Los benchmarks locales están limitados por el host y ocultarán el techo. En un portátil, el generador de carga y el servidor pelean por los mismos núcleos, así que nunca presentas al servidor con carga limpia y suficiente —la meseta que mides es tu plataforma de prueba, no Zaris. El techo real del pool de workers apareció a escala en un clúster gestionado con la carga impulsada desde máquinas separadas, no en una caja de desarrollador. Valida el throughput donde el cliente no pueda ser el cuello de botella.
La conclusión
Una curva de throughput plana tiene tres causas muy distintas, y el gráfico de CPU te dice cuál estás mirando. Al 100% de CPU, estás limitado por núcleos —añade nodos. Con el servidor ocioso porque nada llega lo bastante rápido, estás limitado por el cliente —arregla la prueba. Pero un nodo que está plano al cuarto de su CPU, con solicitudes visiblemente encolándose, no es ninguno de los dos: es un límite de concurrencia, y en Zaris ese es el pool de workers de despacho. El valor por defecto ahora es lo bastante ancho como para que la mayoría de las cargas de trabajo nunca lo alcancen, y ZARIS_SERVER_MAX_WORKERS está ahí para las que sí —para subirse deliberadamente, cuando el gráfico de CPU se lo ha ganado.
Para la superficie completa de variables de entorno y los valores por defecto exactos, consulta la referencia de configuración. Si estás dimensionando un clúster para throughput, la guía de benchmarking recorre los ajustes que realmente mueven los números, y Lecturas que escalan con tus núcleos cubre la otra mitad de la historia —por qué, una vez que el pool está fuera del camino, añadir núcleos añade capacidad de lectura en lugar de chocar con un muro de bucle-de-eventos-único.