Mi is az a runtime valójában?

Amikor leírod egy async Rust programban a .await-et, valójában semmi nem történik automatikusan a háttérben — a Rust nyelv maga nem tartalmaz beépített runtime-ot, csak a Future trait-et és a nyelvi szintaxist az async/.await köré. A tényleges munkát (ütemezés, I/O figyelés, szálkezelés) egy külső crate végzi, esetünkben a Tokio.

A Tokio runtime három fő komponensből áll össze:

  • Executor: futtatja a task-okat, azaz meghívja rajtuk a poll()-t, amikor esély van a haladásra.
  • Reactor: figyeli az operációs rendszer I/O eseményeit (epoll Linuxon, kqueue macOS-en, IOCP Windowson), és értesíti a megfelelő task-ot, ha egy socket olvashatóvá vagy írhatóvá válik.
  • Scheduler: eldönti, melyik task-ot melyik szálon és mikor futtassa — ez az a réteg, ami a work-stealing logikát implementálja multi-thread módban.
Megjegyzés

A Future trait maga csak egy poll(&mut self, cx: &mut Context) -> Poll<Self::Output> metódust ír elő. Minden más — hogy mikor és melyik szálon hívjuk meg ezt a poll-t — a runtime feladata.

Executor, reactor és scheduler szerepe

Gondolj az executorra úgy, mint egy állandóan pörgő ciklusra, amely a futásra kész task-okat egy sorból veszi ki és futtatja őket egy darabig (amíg Poll::Pending-et nem adnak vissza, vagy be nem fejeződnek). A reactor ezzel párhuzamosan az OS-től érkező I/O eseményeket figyeli, és amikor egy file descriptor készen áll, a hozzá tartozó task-ot visszateszi a futásra kész sorba.

A scheduler a kettő közötti hidat képezi: ő dönt arról, hogy melyik szálon fusson egy adott task, és hogyan egyenlítse ki a terhelést a szálak között. Multi-thread runtime esetén ez egy nem triviális feladat, mert a cél az, hogy minél kevesebb szinkronizációs overhead mellett minél jobban kihasználjuk a rendelkezésre álló CPU magokat.

#[tokio::main]
async fn main() {
    // Ez a makró alatt valójában egy Runtime::new().unwrap()
    // majd egy block_on(...) hívás történik.
    let handle = tokio::spawn(async {
        println!("Task fut az executor egyik szálán");
    });

    handle.await.unwrap();
}

Work-stealing ütemezés több szálon

A Tokio multi-thread schedulere work-stealing algoritmust használ. Ez azt jelenti, hogy minden worker szálnak van egy saját, lokális task-sorja (jellemzően egy lock-mentes, fix méretű ring buffer), és ha ez kiürül, a szál „ellop” task-okat egy másik szál sorából.

Ez a megközelítés azért hatékony, mert:

  1. A leggyakoribb eset — egy szál a saját sorából dolgozik — nem igényel zárolást vagy atomikus műveletet a globális állapoton.
  2. A lopás csak akkor történik, amikor tényleg szükséges, tehát nem generál felesleges szinkronizációs overheadet kiegyensúlyozott terhelés esetén.
  3. Emellett van egy globális sor is, amelybe azok a task-ok kerülnek, amelyeket runtime-on kívülről (pl. egy másik szálról) spawn-olnak.
Tipp

A work-stealing scheduler pontosan azért skálázódik jól, mert a leggyakoribb művelet (helyi sorból dolgozni) olcsó, a ritka művelet (lopás) pedig csak akkor fut, ha valamelyik szál éhezik.

Fontos megérteni, hogy a work-stealing nem jelenti azt, hogy egy adott task menet közben szálat vált. Egy task egyszerre csak egy szálon fut, de két egymást követő poll() hívás között akár másik szálra is kerülhet, ha az előző futás után visszakerült a futásra kész sorba, és egy másik szál vette fel onnan.

A task spawn folyamata és a Waker mechanizmus

Amikor meghívod a tokio::spawn(...)-t, a runtime egy JoinHandle-t ad vissza, és a future-ből egy belső Task struktúrát csomagol, amely tartalmazza a future állapotát, egy referenciaszámlálót, és egy Waker-t képes generálni saját magához.

A Waker a Rust async ökoszisztéma egyik legfontosabb, de leginkább rejtett darabja. Amikor egy future Poll::Pending-et ad vissza (mert például egy socketre vár), a futtatott kódnak el kell mentenie a Context-ből kapott Waker-t, és amikor a várt esemény bekövetkezik (pl. a reactor jelez, hogy az adat megérkezett), meg kell hívni a waker.wake()-et. Ez jelzi az executornak, hogy a task ismét futásra kész, és visszakerül a scheduler sorába.

use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};

struct MyFuture {
    ready: bool,
}

impl Future for MyFuture {
    type Output = &'static str;

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        if self.ready {
            Poll::Ready("kész vagyok")
        } else {
            // Itt normál esetben elmentenénk a wakert egy
            // háttérszálnak vagy I/O eseménynek, hogy később
            // visszahívja. Demonstráció céljából most rögtön
            // felébresztjük magunkat.
            self.ready = true;
            cx.waker().wake_by_ref();
            Poll::Pending
        }
    }
}
Figyelem

Ha egy future elfelejti elmenteni és később meghívni a Waker-t, a task örökre "beragadhat" Pending állapotban, hiszen az executor soha nem fogja tudni, hogy ismét ütemezhető. Ez az egyik leggyakoribb hibaforrás, ha saját Future implementációt írsz.

A spawn hívás után a task egy Arc-ba csomagolva él a heap-en, és a referenciaszámláló gondoskodik arról, hogy addig ne szabaduljon fel a memória, amíg a JoinHandle, a scheduler sora és a Waker-ek közül legalább egy hivatkozik rá.

Multi-thread vs current-thread runtime

A Tokio két fő runtime-flavort ajánl:

  • Multi-thread runtime: ez az alapértelmezett #[tokio::main] mögött, amely egy szálpool-t indít (alapesetben a CPU magok számával egyezően), és work-stealing schedulert használ.
  • Current-thread runtime: minden task egyetlen szálon fut, nincs work-stealing, nincs szálak közti szinkronizációs overhead.
fn main() {
    let rt = tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()
        .unwrap();

    rt.block_on(async {
        println!("Egyetlen szálon futunk, nincs work-stealing overhead");
    });
}

A current-thread runtime kiváló választás, ha:

  • Egyszerű, CPU-könnyű, I/O-vezérelt programot írsz (pl. egyszerű CLI eszköz), ahol a párhuzamosítás overheadje nem térülne meg.
  • Determinisztikus tesztelést szeretnél, ahol a végrehajtási sorrend kevésbé kaotikus.
  • Beágyazott vagy erőforrás-korlátozott környezetben dolgozol, ahol nem akarsz sok szálat indítani.

A multi-thread runtime viszont akkor éri meg, ha valóban több magot szeretnél kihasználni párhuzamos munkaterheléshez — például sok egyidejű kapcsolatot kezelő szerver esetén.

Jó tudni

A current-thread runtime nem jelenti azt, hogy blokkoló kódot futtathatsz benne büntetés nélkül. Egy blokkoló hívás (pl. egy szinkron fájlolvasás) ugyanúgy megállítja az egyetlen szálat, és minden más task-ot is blokkol, mert nincs másik szál, ami átvenné a stafétát.

Teljesítmény-hangolási tippek

Ha komolyan gondolod a runtime finomhangolását, a tokio::runtime::Builder számos opciót kínál:

fn main() -> std::io::Result<()> {
    let rt = tokio::runtime::Builder::new_multi_thread()
        .worker_threads(4)
        .thread_name("my-worker")
        .thread_stack_size(3 * 1024 * 1024)
        .enable_all()
        .build()?;

    rt.block_on(async {
        // itt fut az async logikád
    });

    Ok(())
}

Néhány gyakorlati szempont:

  • worker_threads: alapértelmezetten a CPU magok száma. Ha a programod más folyamatokkal osztja meg a gépet, érdemes lehet ezt alacsonyabbra állítani, hogy ne versenyezzen feleslegesen erőforrásért.
  • Blokkoló munka kiszervezése: CPU-intenzív vagy blokkoló (fájl I/O, szinkron könyvtárak) kódot mindig a tokio::task::spawn_blocking segítségével küldj egy külön blocking thread pool-ra, különben az egész worker szálat elfoglalja, és minden más task éhezni fog rajta.
  • spawn granularitása: ne bontsd túl apró task-okra a munkát, mert a task létrehozásnak és ütemezésnek van overheadje. Fordítva, ha egyetlen task túl sokáig fut .await nélkül, blokkolja a saját szálát.
async fn cpu_intenziv_feladat() -> u64 {
    tokio::task::spawn_blocking(|| {
        // Ez egy dedikált blocking thread pool-on fut,
        // nem a work-stealing workereken.
        (0..10_000_000u64).sum()
    })
    .await
    .unwrap()
}
Tipp

Ha bizonytalan vagy abban, hogy egy adott hívás blokkolhat-e (pl. egy régi, szinkron C library FFI hívása), inkább tedd be spawn_blocking-ba. A felesleges kontextusváltás ára jóval kisebb, mint egy beragadt worker szál.

Összefoglalás

A Tokio runtime egy jól megtervezett, réteges rendszer: az executor futtatja a task-okat, a reactor figyeli az I/O eseményeket, a scheduler pedig work-stealing algoritmussal osztja el a munkát a szálak között. A Waker mechanizmus köti össze mindezt azzal, hogy lehetővé teszi a task-ok „alvását” és „felébredését” anélkül, hogy az executornak folyton pollolnia kellene mindent.

Ha érted ezt a belső működést, sokkal könnyebben tudod eldönteni, mikor éri meg a current-thread runtime, mikor van szükség spawn_blocking-ra, és hogyan hangold a worker szálak számát a saját use case-edhez. A motorháztető alatt nézve az async Rust már sokkal kevésbé rejtélyes — és ez az alaptudás minden komolyabb Tokio-alapú rendszer hibakeresésénél és optimalizálásánál kifizetődik.