Zum Hauptinhalt springen

Die Worker-Pool-Obergrenze: Wenn ein Node bei 25 % CPU ein Plateau erreicht

· 8 Minuten Lesezeit
Clustron Team
Engineering für verteilte Systeme

Ein Node bei nahezu 25 % CPU mit flachem Durchsatz, weil der Dispatch-Worker-Pool die Obergrenze war

Hier ist ein Benchmark-Ergebnis, das wie ein Widerspruch aussieht. Ein Zaris-Node, der eine leselastige Workload bedient, erreicht seine Obergrenze bei knapp unter einer halben Million Ops/s und weigert sich zu steigen — fügen Sie mehr Client-Verbindungen und mehr Lastgeneratoren hinzu, die Zahl bewegt sich nicht. Die naheliegende Diagnose lautet „er ist gesättigt." Nur sagt das CPU-Diagramm etwas anderes: Der Node steht bei etwa einem Viertel seiner verfügbaren CPU. Drei von vier Kernen sind untätig, und der Durchsatz ist flach.

Das ist kein CPU-limitierter Node und — wenn Sie das sorgfältig ausgeschlossen haben — auch kein Client-limitierter Benchmark. Es ist eine dritte Sache, und zwar ein Klassiker: Der Worker-Pool für das Request-Dispatching war schmaler, als die Arbeit es wollte, sodass Requests in der Warteschlange standen, während Kerne Däumchen drehten.

Zuerst die üblichen Verdächtigen ausschließen​

Zwei Erklärungen müssen ausscheiden, bevor diese überhaupt auf den Tisch kommt, denn sie sind weit häufiger.

CPU-limitiert. Steht der Node bei oder nahe 100 % CPU und der Durchsatz ist flach, ist Ihnen tatsächlich die CPU ausgegangen — die Antwort sind mehr Kerne oder mehr Nodes, und Zaris skaliert Lesezugriffe durch zusätzliche Kerne. Das ist hier nicht der Fall: Die CPU ist untätig.

Client-limitiert. Ein einzelner Lastgenerator sättigt sich lange vor dem Server, und dann messen Sie Ihren Client, nicht Zaris. Ist der Durchsatz flach, während Sie Server-Kerne hinzufügen, verdächtigen Sie zuerst Ihren Client — fügen Sie Generatoren und Verbindungen hinzu, bis die Zahl auf der Serverseite tatsächlich reagiert. Wir haben diese Falle im Beitrag zum Lesen behandelt, und sie ist die, die Sie ausschließen müssen, bevor Sie weitermachen.

Was nach beiden übrig bleibt, ist der interessante Fall: genug angebotene Last, um den Node zu beschäftigen, reichlich CPU-Spielraum und trotzdem flacher Durchsatz. Wenn der Engpass im Server liegt, aber nicht die CPU ist, ist das, was man sich ansehen muss, die Nebenläufigkeit — wie viele Requests der Node gleichzeitig bearbeitet.

Der Dispatch-Pool ist das Nebenläufigkeitslimit​

Jeder Request, der an einem Zaris-Node ankommt, wird an einen Worker-Pool für das Request-Dispatching übergeben. Ein Worker nimmt den Request auf, routet ihn zur Partition, die den Key besitzt, führt die Operation gegen den In-Process-Store dieser Partition aus und schreibt die Antwort zurück. Bei einem Lesezugriff ist die Operation selbst ein Lookup im Speicher im Mikrosekundenbereich — wirklich winzig.

Die Breite dieses Pools — wie viele Worker gleichzeitig mitten in einem Request sein können — setzt eine harte Obergrenze für die Requests in Bearbeitung, und das ist eine andere Zahl als „wie schnell ist eine einzelne Operation". Der ursprüngliche Default leitete die Breite von einem kleinen Vielfachen von Environment.ProcessorCount ab. Auf einem Node mit 4 vCPUs sind zwei Mal die Kernzahl nur eine Handvoll Worker.

Hier ist die Feinheit, die das CPU-Diagramm falsch aussehen lässt. Würde jeder Worker seinen gesamten Slot damit verbringen, CPU für den Lookup zu verbrennen, wäre ein an die Kerne angepasster Pool genau richtig, und der Node würde 100 % CPU erreichen. Aber Worker verbringen nicht ihren ganzen Slot auf der CPU. Ein Worker parkt gelegentlich — eine await-Fortsetzung, ein Socket-Schreibvorgang, der nicht synchron abschließt, ein kurzer Lock auf eine gemeinsame Struktur. Ein geparkter Worker verbrennt keine CPU, aber er hält weiterhin seinen Pool-Slot. Bei nur einer Handvoll Slots genügen einige geparkte Worker, um den ganzen Node zum Stillstand zu bringen: Neue Requests stauen sich hinter ihnen, und weil die beschäftigten Worker meist warten statt zu rechnen, leuchten die Kerne nie auf. Flacher Durchsatz, untätige CPU. Die Pool-Breite war die Obergrenze, nicht das Silizium.

Das Erkennungsmerkmal, das dies von einem Client-limitierten Benchmark unterscheidet: Requests stauen sich am Server (die Latenz steigt, der Durchsatz ist flach), während die Server-CPU niedrig bleibt. Client-limitiert sieht anders aus — der Server ist untätig, weil nichts schnell genug ankommt. Hier kommt Arbeit an; der Node nimmt nur nicht genug davon gleichzeitig an.

Die Lösung: den Pool für die Arbeit dimensionieren, nicht für die Kernzahl​

Die Reparatur ist nicht exotisch. Lesezugriffe sind Lookups im Speicher im Mikrosekundenbereich, also sollte der Pool standardmäßig breit eingestellt sein — breit genug, dass eine Handvoll geparkter Worker den Node nicht aushungern kann — und er sollte abstimmbar sein, weil die richtige Breite vom Node und von der Workload abhängt, nicht von einem festen Vielfachen der Kernzahl.

Die Standardbreite des Pools beträgt jetzt CPU-Anzahl × 16, begrenzt auf ein sinnvolles Band von 64–1024, mit einer Untergrenze von max(4, CPU-Anzahl × 4). Zwei Umgebungsvariablen überschreiben sie:

VariableDefaultWas sie bewirkt
ZARIS_SERVER_MAX_WORKERSCPU-Anzahl × 16 (begrenzt auf 64–1024)Harte Überschreibung (1–4096) der Breite des Dispatch-Pools. Erhöhen Sie sie, wenn ein Node freie CPU hat und Requests sich stauen.
ZARIS_SERVER_MIN_WORKERSmax(4, CPU-Anzahl × 4)Untergrenze (1–4096) der Pool-Breite.
# 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

Eine Eigenschaft ist hervorzuheben: Dies ist eine Änderung ausschließlich auf der Serverseite. Das Wire-Protokoll ändert sich nicht, das Client-SDK ändert sich nicht, und es gibt kein NuGet-Update, dem Sie nachlaufen müssten — Sie stimmen einen Node ab, indem Sie ihn mit einer anderen Umgebung neu starten, und jeder bestehende Client spricht unverändert weiter mit ihm.

Ehrlich abstimmen — Verbreitern ist nicht kostenlos​

Der Reflex nach der Lektüre ist, ZARIS_SERVER_MAX_WORKERS auf jedem Node bis zur Obergrenze hochzudrehen. Tun Sie das nicht. Ein breiterer Pool hilft in genau einer Situation und bewirkt in den anderen nichts — oder schadet leicht.

  • Er hilft, wenn Requests sich stauen, während die CPU untätig ist. Das ist der Fall in diesem ganzen Beitrag: Die Nebenläufigkeit der Requests in Bearbeitung ist das Limit, also füllt deren stärkere Zulassung die Kerne. Hier sehen Sie den Durchsatz springen.
  • Er bewirkt nichts, wenn Sie bereits CPU-limitiert sind. Steht der Node bei 100 % CPU, zaubern mehr Worker keinen Durchsatz aus dem Nichts — sie fügen einer Maschine ohne freie Zyklen nur Overhead durch Kontextwechsel hinzu. Mehr Worker sind nicht mehr Rechenleistung.
  • Er kann schaden, wenn man ihn auf eine absurde Breite treibt. Tausende Worker, die einen Node mit 2 vCPUs belasten, verbringen echte Zeit mit Kontextwechseln statt mit dem Bedienen. Deshalb gibt es eine Begrenzung, und deshalb endet der Regler bei 4096 statt bei unendlich.

Die ehrliche Abstimmungsschleife lautet: Achten Sie auf die Signatur — steigende Latenz und flacher Durchsatz, während die CPU deutlich unter der Sättigung liegt —, erhöhen Sie MAX_WORKERS und messen Sie. Steigt die CPU und folgt der Durchsatz, war der Pool Ihre Obergrenze. Bewegt sich nichts, war er es nicht, und Sie sollten den Regler zurückstellen und anderswo suchen (den Netzwerkpfad, den Client oder den tatsächlichen Bedarf an mehr Nodes).

CPU dort messen, wo sie wirklich stimmt​

Diese ganze Untersuchung hängt davon ab, der CPU-Zahl zu vertrauen, und diese Zahl ist leicht falsch zu bekommen.

  • Lesen Sie die CPU aus der cgroup, nicht aus einer Schätzung des Cluster-Metrics-Servers. Besonders unter Kubernetes hinkt und glättet der Wert des Metrics-Servers — er sagt Ihnen, ein Node stehe bei 40 %, obwohl die cgroup-Bilanzierung sagt, er sei am Anschlag, oder umgekehrt. Diagnostizieren Sie einen Fall „flacher Durchsatz, untätige CPU" aus der cgroup, sonst jagen Sie einer Zahl nach, die nicht real ist.
  • Lokale Benchmarks sind Host-limitiert und verbergen die Obergrenze. Auf einem Laptop kämpfen Lastgenerator und Server um dieselben Kerne, sodass Sie dem Server nie saubere, ausreichende Last präsentieren — das Plateau, das Sie messen, ist Ihr Testaufbau, nicht Zaris. Die echte Worker-Pool-Obergrenze zeigte sich im großen Maßstab auf einem verwalteten Cluster mit Last von getrennten Maschinen, nicht auf einer Entwicklerkiste. Validieren Sie den Durchsatz dort, wo der Client nicht der Engpass sein kann.

Das Fazit​

Eine flache Durchsatzkurve hat drei sehr verschiedene Ursachen, und das CPU-Diagramm sagt Ihnen, welche Sie vor sich haben. Bei 100 % CPU sind Sie CPU-limitiert — fügen Sie Nodes hinzu. Ist der Server untätig, weil nichts schnell genug ankommt, sind Sie Client-limitiert — reparieren Sie den Test. Aber ein Node, der bei einem Viertel seiner CPU flach ist und bei dem sich Requests sichtbar stauen, ist keines von beidem: Es ist ein Nebenläufigkeitslimit, und bei Zaris ist das der Dispatch-Worker-Pool. Der Default ist jetzt breit genug, dass die meisten Workloads nie darauf stoßen, und ZARIS_SERVER_MAX_WORKERS gibt es für die, die es tun — um bewusst erhöht zu werden, wenn das CPU-Diagramm es sich verdient hat.

Die vollständige Oberfläche der Umgebungsvariablen und die genauen Defaults finden Sie in der Konfigurationsreferenz. Wenn Sie einen Cluster für Durchsatz dimensionieren, führt der Benchmarking-Leitfaden durch die Einstellungen, die die Zahlen tatsächlich bewegen, und Lesezugriffe, die mit Ihren Kernen skalieren behandelt die andere Hälfte der Geschichte — warum, sobald der Pool aus dem Weg ist, zusätzliche Kerne Lesekapazität hinzufügen, statt an eine Mauer mit einer einzigen Event-Loop zu stoßen.