Principes voor snelle Tokio-applicaties

Er zijn weinig harde regels voor het schrijven van code die goed presteert op Tokio-runtimes; het antwoord op veel vragen is "het hangt ervan af". De prestaties van een workload hangen af van wat er op dat moment nog meer op de runtime draait. Dit is waarom zoveel problemen pas in productie aan het licht komen! Het schrijven van async-applicaties die goed presteren is een balans tussen eerlijkheid (fairness) en batching, congestie (contention) en isolatie.

Dit artikel zet enkele algemene principes uiteen en behandelt waar mogelijk de uitzonderingen. Het gaat uit van een basiskennis van Tokio's work-stealing runtime; een beknopte samenvatting hiervan is opgenomen in de bijlage.

Algemene principes

Stel eerst vast of er daadwerkelijk een probleem is

Als je begint te zoeken naar alarmsignalen in een Tokio-applicatie, dan zul je ze vinden. Bijna elke echte applicatie die ik heb gezien, heeft polls (de tijd tussen .await-punten wanneer de code terugkeert naar de runtime) die veel langer zijn dan de 10-100 microseconden die Alice Ryhl aanbeveelt in haar uitstekende artikel What is Blocking?. Deze problemen kunnen de applicatiemetrics of het gedrag waar je daadwerkelijk om geeft wel of niet beïnvloeden (zie: lange polls kunnen soms prima zijn). Het is belangrijk om terug te redeneren vanuit een echte metric die je probeert te verbeteren. Bijvoorbeeld: een applicatie kan lange polls hebben die volledig onschadelijk zijn; het "oplossen" ervan zal geen meetbare impact hebben op de metrics voor de eindgebruiker.

In de overweldigende meerderheid van de problemen die ik ben tegengekomen, zat het probleem in de applicatiecode zelf, vaak in de interactie tussen meerdere componenten van een gedistribueerd systeem (en dus niet daadwerkelijk in Tokio). dial9 heeft veel inzicht gegeven in Tokio; minstens zo vaak als het een Tokio-probleem vindt, demonstreert het juist duidelijk dat er geen probleem is (wat mensen het vertrouwen geeft om elders te zoeken). Natuurlijk is het soms wel een Tokio-probleem.

Wat betreft Tokio-metrics is de onlangs toegevoegde schedule latency histogram de meest nuttige. Scheduling-latentie is de tijd tussen het moment dat je taak klaar is om uitgevoerd te worden (bijv. omdat het socket data bevat) en het moment dat Tokio de future daadwerkelijk pollt. Hoewel dit je niet vertelt wat de oorzaak is, is scheduling-latentie het meest voorkomende symptoom van slechte interacties tussen Tokio en je code.

Splits voor latentie, batch voor doorvoersnelheid

Geef vaker 'yield' om latentie te optimaliseren

Lage latentie over veel verzoeken vereist eerlijkheid (fairness) tussen verbindingen.

Neem Redis (of elke applicatie die request pipelining ondersteunt). Een naïve implementatie leest data direct van de verbinding zolang er meer data beschikbaar is. Wanneer verzoeken gepipelined zijn, komt het volledige gepipelinede verzoek (of het grootste deel ervan) in een geheugenbuffer terecht. Wanneer je frames hiervan leest, zal elke leesactie Poll::Ready zijn (zonder terug te gaan naar het netwerk). Dit veroorzaakt zowel lange polls als oneerlijkheid tussen cliënten.

De impact op de doorvoersnelheid (throughput) is meestal kleiner: hetzelfde aantal verzoeken wordt verwerkt. De latentie verandert echter drastisch, omdat een volledige pipeline kan moeten wachten achter een andere. Het expliciet yielden na elk verzoek kan de latentie in dit voorbeeld met ongeveer 10× verminderen. Je kunt nog betere resultaten behalen door alleen te yielden na meerdere opeenvolgende, direct beschikbare leesacties.

async fn handle_conn(&mut self) -> crate::Result<()> {
    while !self.shutdown.is_shutdown() {
        // Als de verbinding gebufferde data heeft, kan dit herhaaldelijk
        // Poll::Ready retourneren zonder terug te keren naar de runtime.
        let frame = tokio::select! {
            res = self.connection.read_frame() => res?,
            _ = self.shutdown.recv() => {
                return Ok(());
            }
        };
        execute_command(&self.db, &mut self.connection, frame).await?;

        // Om de eerlijkheid (fairness) te verbeteren:
        // tokio::task::yield_now().await;
    }
}

Yielden na vier opeenvolgende, direct beschikbare leesacties maakt gepipelinede verzoeken veel eerlijker zonder batching volledig op te geven.

Hoe weet ik of ik dit probleem heb?

  • P99 is veel groter dan P50.
  • Polls duren langer dan het werk binnen die polls zou vereisen.
  • Veel spans vallen binnen een enkele poll.

Batch werk om overhead te spreiden

Eerlijkheid is niet gratis. Hoe meer nuttig werk je kunt doen per runtime-event — het wisselen van taken, pollen, het verplaatsen tussen workers of het wisselen van threads — hoe efficiënter je applicatie kan zijn.

Misschien wel het beste voorbeeld is tokio::fs. Ik ga soms zo ver om te zeggen dat "tokio::fs als schadelijk wordt beschouwd." Zonder iouring voert Tokio elke bestandssysteemoperatie uit op de blocking pool. Elke aanroep naar spawnblocking heeft een kostprijs, en elke runtime heeft een gedeelde blocking pool.

Als je weet dat je een reeks bestandssysteemoperaties — of ander blokkerend werk — gaat uitvoeren, batch deze dan in het grootste zinvolle blokkerende segment. In sommige gevallen is een toegewezen OS-thread een betere oplossing.

Dit principe is van toepassing op elke interactie met Tokio. Als je weet dat je werk naar de globale wachtrij gaat sturen, kan batching ook die coördinatie optimaliseren.

Zelfs zaken die zo snel zijn als het spawnen van een taak zijn niet gratis! Het spawnen van een taak is goedkoop, maar als je honderden of duizenden taken spawnt, representeert elke taak werk waar de runtime afzonderlijk mee moet omgaan. Elke taak vergroot de kans op scheduling-vertraging, zorgt voor meer individuele polls die de runtime moet afhandelen en veroorzaakt over het algemeen meer overhead. Wanneer je een taak spawnt, overweeg dan hoeveel werk je daadwerkelijk plant: het spawnen van een eenheid van werk van 10 microseconden in een eigen taak is waarschijnlijk contraproductief. Tools zoals dial9 of tokio-metrics kunnen helpen bij het volgen van de levenscyclus van taken.

Hoe weet ik of ik dit probleem heb?

  • Tokio-API's zoals spawn_blocking nemen een merkbare hoeveelheid tijd in beslag in flamegraphs.
  • Een strakke loop voert veel individuele, kleine bestandssysteem- of blokkerende operaties uit.
  • De doorvoersnelheid verbetert wanneer hetzelfde werk wordt gegroepeerd in grotere eenheden.

Pas op met globale resources

De Tokio-runtime plant werk in op workers: dedicated threads die klaarstaande taken pollen. Workers schalen over cores, maar sommige runtime-resources vereisen nog steeds gedeelde coördinatie.

De blocking pool is momenteel een globale resource. Bij voldoende hoge frequenties wordt het pushen van werk naar de blocking-wachtrij een bottleneck en kan spawnblocking zichtbaar worden in flamegraphs. Ik heb negatieve prestatie-effecten gezien bij ongeveer 50.000 blokkerende taken per seconde op een 32-core host; dit verschilt per situatie. spawnblocking is geen magische oplossing voor elk stuk blokkerende of CPU-zware code. Voor kort, begrensd werk kan het sneller zijn om Tokio's workers en work-stealing het af te handelen, maar, zoals altijd: "het hangt ervan af."

Tokio heeft ook een globale taakwachtrij. Taken komen daar terecht wanneer lokale worker-wachtrijen overlopen (wat meestal zeldzaam is), of wanneer werk wordt gepland vanuit buiten een runtime-worker (wat in sommige applicaties gebruikelijk kan zijn). Een voorbeeld is een channel waarvan de sender op een non-Tokio thread draait.

Hoe weet ik of ik dit probleem heb?

  • Runtime-brede operaties zoals spawn_blocking zijn prominent aanwezig in flamegraphs.
  • De globale wachtrij is consistent diep. In een gezonde applicatie zou deze over het algemeen bijna leeg moeten blijven; in een verzadigde applicatie kan het lang duren voordat deze is geleegd.

Wees uiterst voorzichtig met mutexes

Een van de makkelijkste manieren om een hele runtime stil te leggen, is door een worker te blokkeren op een mutex waar veel concurrentie op is (contended mutex).

Zaken zoals een metrics-register dat achter een mutex of read-write lock is opgeslagen, zijn hier bijzonder gevoelig voor. Als een flush het lock vasthoudt terwijl er duur werk wordt gedaan, kan elke Tokio-worker uiteindelijk een taak plannen die een metric probeert te registreren en blokkeert op hetzelfde lock. Work-stealing wordt onmogelijk omdat elke worker vastzit!

Houd kritieke secties in async-applicaties extreem kort (bijv. een enkele update van een hashmap). RWLocks zijn bijna nooit de juiste primitieve om te gebruiken, omdat ze nog steeds concurrentie veroorzaken op atomics, zelfs voor het leespad. Houd het lock niet vast tijdens het flushen, het uitvoeren van I/O of het awaiten van een andere future.

tokio::sync::Mutex ruilt het ene probleem in voor het andere: Tokio-mutexes zijn veel duurder om te locken, zijn gevoelig voor subtiele problemen zoals FutureLock, en zijn eigenlijk alleen geschikt als de kritieke sectie meerdere milliseconden duurt.

Hoe weet ik of ik dit probleem heb?

  • P99 piekt op voorspelbare intervallen, zoals elke minuut wanneer een achtergrondtaak draait.
  • In dial9 worden veel taken plotseling geblokkeerd en zijn ze voor een aanzienlijke duur "off-CPU".
  • Een contended blocking mutex zet alle vier de runtime-workers tegelijkertijd stil.

Beperk parallellisme — meestal

Tokio kan met gemak veel meer taken spawnen dan de rest van je systeem aankan. Het per ongeluk openen van 3.000 gelijktijdige verbindingen met S3 omdat een workload een onbegrensd aantal taken heeft uitgewaaierd, komt zeer vaak voor.

Het antwoord is simpel: beperk de gelijktijdigheid (concurrency). Geavanceerde adaptieve algoritmen zijn soms gepast, maar een Semaphore is vaak voldoende.

Isoleer Tokio-workers van andere threads

Het ontwerp van Tokio vertrouwt erop dat workers snel wakker worden. Echter, als het besturingssysteem zwaar belast is, kan het 10–20 ms (of meer) duren voordat de kernel een worker plant nadat Tokio probeert deze te wekken. Als je P99-latentie meet in enkele milliseconden, is dit rampzalig. Ik heb dit waargenomen tijdens incrementele migraties van Java naar Rust bij Amazon, waarbij beide processen op dezelfde host draaiden en het Rust-proces geleidelijk meer van het werk overnam.

Hoe minder werk het Java-proces deed, hoe sneller het Rust-proces werd, zelfs terwijl het meer werk afhandelde. Dit effect is nog sterker wanneer de andere applicaties een groot aantal threads gebruiken.

De meest basale oplossing is om cgroups of gerelateerde API's te gebruiken om de Tokio-workers en andere code op aparte CPU-cores te pinnen.

Hetzelfde probleem kan ontstaan door andere Rust-threads. Achtergrondthreads, zoals die worden gebruikt door tracing_appender, kunnen soms meer dan 100 ms aan werk doen zonder de CPU af te staan. Als Tokio op dat moment probeert een worker te wekken, kan die worker worden vertraagd totdat de kernel de andere thread preempts.

Als je ziet dat dit gebeurt, is de oplossing hetzelfde: pin niet-kritiek achtergrondwerk op een eigen core en verplaats Tokio-workers naar andere cores. Je hebt zelden elke core nodig voor Tokio, en het reserveren van cores voor ander werk heeft de neiging de latentie te verbeteren.

Hoe weet ik of ik dit probleem heb?

  • dial9 toont een kernel-scheduling vertraging tussen een worker-unpark event en het moment dat de worker daadwerkelijk draait.

Geavanceerde technieken

De patronen in deze sectie zijn over het algemeen niet de juiste manier van werken, maar soms zijn ze precies wat een specifieke workload nodig heeft.

Het blokkeren van de executor kan soms acceptabel zijn

In een geïdealiseerde async-applicatie zou al het werk gebeuren in kleine bursts met frequente yields terug naar Tokio. De echte wereld werkt niet altijd zo, en kleine bursts zijn niet noodzakelijkerwijs de snelste manier om software te draaien. Het batchen van werk kan efficiënter zijn.

In de praktijk zijn lange polls niet altijd een probleem. Bij een lichte belasting kan de work-stealing van Tokio compenseren wanneer één worker langer dan gebruikelijk bezet is. Dat begint te falen onder twee condities:

  1. De Tokio-runtime is zwaar belast en er is geen reserve-capaciteit van workers beschikbaar.
  2. Het besturingssysteem is zwaar belast, waardoor het wekken van workers vaak vertraagd wordt.

In beide gevallen duurt het stelen van werk langer. Als werk niet snel genoeg wordt gestolen, gebeurt essentieel runtime-onderhoud — zoals het aansturen van I/O — mogelijk niet vaak genoeg om een lage latentie te behouden.

Belangrijke opmerking! Dit advies is niet van toepassing als je gebruikmaakt van zaken als tokio::join! en tokio::select! die in-task concurrency gebruiken. Binnen een enkele taak is er geen work-stealing; als je de executor blokkeert, kan niets anders dat op die taak draait vorderingen maken. Dit manifesteert zich soms als onverwachte timeouts en over het algemeen slechte latentie.

Gebruik meerdere runtimes om workloads te isoleren op basis van prioriteit

De sterkste isolatie komt voort uit het toewijzen van werk aan aparte runtimes en het pinnen van die runtimes aan dedicated cores. Veel netwerkdiensten hebben zowel latentiegevoelig werk als achtergrondwerk met een lagere prioriteit. Door deze op aparte runtimes te plaatsen, creëer je een scheduling-boundary tussen de twee.

Je kunt ook OS-level niceness instellen wanneer de runtime-threads starten. Zie het voorbeeld met meerdere runtimes van dial9 en de onthreadstart hook van Tokio.

Tijdens TokioConf was de algemene indruk uit de meeste presentaties dat mensen uiteindelijk overstapten op een oplossing met minstens twee runtimes.

Spinning om de controle te behouden

Dit is een zeer geavanceerde tactiek voor het najagen van latentie die in microseconden wordt gemeten. Ik raad niet aan om hier direct naar te grijpen, maar het kan zeker werken.

Elke keer dat je terugkeert naar de Tokio-scheduler — of Tokio een worker-thread parkeert en deze teruggeeft aan het besturingssysteem — creëer je een kans dat dit werk wordt vertraagd wanneer het opnieuw wakker wordt.

Voor extreem latentiegevoelig werk is een optie om bewust voor een korte, vooraf ingestelde periode te "spinnen" (bijv. 50 microseconden), in plaats van te yielden terwijl er wordt gewacht op het volgende stuk nuttig werk. Dit verbruikt een core en kan naburige workloads schaden, dus is het waarschijnlijk onjuist voor de meeste applicaties. Onder zorgvuldig gecontroleerde omstandigheden kan het echter de juiste afweging zijn.

Bijlage: Een mentaal model voor Tokio in vier punten

  • Rust futures maken incrementele voortgang tussen .await-punten. Deze actieve secties worden polls genoemd, naar de Future::poll methode.
  • Wanneer futures niet worden gepolld, zijn ze inactief en wachten ze tot een executor ze opnieuw uitvoert. Een goede executor pollt een future alleen wanneer deze werk heeft om te doen.
  • Tokio draait $N$ workers, meestal één per beschikbare core. Elke worker heeft een lokale wachtrij. Wanneer een wachtrij overlopt of werk niet aan een lokale wachtrij kan worden toegevoegd, gaat de taak naar de globale wachtrij.
  • Wanneer de wachtrij van één worker volloopt, kan een andere worker werk van die worker "stelen" (work-stealing) — mits de runtime de onbalans detecteert en de andere worker capaciteit heeft.

***

^1 Tokio 1.52.0 leverde kortstondig een sharded blocking queue, maar 1.52.1 draaide dit terug na een regressie die spawn_blocking kon laten hangen. Tokio PR #8337 heeft de sharded queue later opnieuw geïntroduceerd als een onstabiele feature die standaard is uitgeschakeld.