A Tokio az egyik legelterjedtebb async runtime a Rust ökoszisztémában, de a legtöbben csak a felszínt látják belőle: #[tokio::main], tokio::spawn, és kész is. Pedig a motorháztető alatt egy komoly, work-stealing alapú ütemező dolgozik, ami rengeteg okos trükköt vet be, hogy a taskjaid a lehető leghatékonyabban fussanak. Nézzük meg részletesen, hogyan működik ez a gépezet.

Single-threaded vs multi-threaded runtime

A Tokio kétféle runtime-ot kínál, és ez a választás alapvetően meghatározza, hogyan viselkedik az alkalmazásod.

A current-thread runtime egyetlen szálon fut. Ez azt jelenti, hogy minden task ugyanazon a szálon van ütemezve, nincs szükség szinkronizációra a taskok között, és az overhead minimális. Ez ideális kis alkalmazásokhoz, tesztekhez, vagy olyan esetekhez, ahol tudod, hogy nem lesz sok párhuzamos munka.

#[tokio::main(flavor = "current_thread")]
async fn main() {
    println!("Egyetlen szálon futok!");
}

A multi-threaded runtime ezzel szemben egy szálpoolt hoz létre — alapértelmezetten annyi szálat, ahány logikai CPU magod van. Itt már megjelenik a work-stealing scheduler, ami a taskokat elosztja a szálak között.

#[tokio::main]
async fn main() {
    // ez alapból multi_thread flavor-t használ
    println!("Több szálon futok!");
}
Megjegyzés

A #[tokio::main] makró alapból a multi-threaded runtime-ot indítja el. Ha explicit módon szeretnéd megadni, írhatod úgy is, hogy #[tokio::main(flavor = "multi_thread", worker_threads = 4)].

A kérdés, hogy melyiket válaszd, attól függ, mit csinál az alkalmazásod. Ha rengeteg párhuzamos I/O-t végzel — sok TCP kapcsolat, sok fájlművelet —, a multi-threaded runtime jobban kihasználja a hardvert. Ha viszont egy egyszerű CLI eszközt írsz, ami néhány async hívást tesz meg, a current-thread variáns kevesebb overhead-del jár.

Work-stealing scheduler egyszerűen elmagyarázva

A multi-threaded runtime szíve a work-stealing scheduler. Az alapötlet egyszerű: minden worker szálnak van egy saját lokális taskqueue-ja, plusz létezik egy globális queue is.

Amikor egy worker szál végez a saját feladataival, és üres a lokális sora, nem áll le tétlenül. Ehelyett "lop" egy taskot valamelyik másik worker szál sorából — innen a work-stealing elnevezés. Ez a mechanizmus biztosítja, hogy a terhelés kiegyensúlyozott maradjon anélkül, hogy egy központi ütemezőnek folyamatosan figyelnie kéne minden szálat.

A folyamat nagyjából így néz ki:

  1. Egy worker szál a saját lokális queue-jából próbál taskot venni.
  2. Ha ez üres, megnézi a globális queue-t.
  3. Ha az is üres, körbenéz a többi worker szál lokális queue-jánál, és lop belőlük egy taskot (vagy néhányat egyszerre, batch-ben).
  4. Ha sehol sincs munka, a szál rövid ideig "parkol", hogy ne pörgesse feleslegesen a CPU-t.
Tipp

A work-stealing algoritmus nem véletlenszerűen választ áldozatot: a Tokio randomizált sorrendben próbálja meg a többi szálat megnézni, hogy elkerülje a rendszeres versenyhelyzeteket (contention) ugyanazon a queue-n.

Ez a megközelítés kulcsfontosságú, mert az async taskok futásideje rendkívül változó lehet — egy task lehet, hogy mikroszekundumok alatt lefut, egy másik pedig percekig várakozik I/O-ra. A work-stealing dinamikusan kiegyenlíti ezt a terhelést anélkül, hogy statikus particionálásra lenne szükség.

Mi történik egy tokio::spawn hívás mögött

Amikor meghívod a tokio::spawn-t, a Tokio nem indít új operációs rendszerbeli szálat — ez egy gyakori tévhit. Ehelyett egy zöld szálnak (green thread) is nevezhető, könnyűsúlyú task jön létre, amit a runtime scheduler kezel.

use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    let handle = tokio::spawn(async {
        sleep(Duration::from_millis(100)).await;
        println!("Kész a task!");
        42
    });

    let result = handle.await.unwrap();
    println!("Eredmény: {}", result);
}

A tokio::spawn valójában a következőket teszi:

  1. Becsomagolja az async blokkot egy Future-be, majd egy Task struktúrába, ami tartalmazza a futásállapotot, a waker mechanizmust és a heap-en allokált adatot.
  2. Ezt a taskot beteszi a hívó szál lokális queue-jába (ha egy worker szálról hívtad), vagy a globális queue-ba (ha runtime-on kívülről, pl. egy külső szálról).
  3. A scheduler előbb-utóbb felveszi a taskot, és elkezdi pollozni — azaz meghívja rajta a poll metódust, ami vagy Poll::Ready-t ad vissza (kész), vagy Poll::Pending-et (még várakozik valamire, pl. I/O-ra).
  4. Ha Pending, a task regisztrál egy wakert, ami majd értesíti a runtime-ot, amikor újra pollozható.

Ez a poll-alapú modell az, ami lehetővé teszi, hogy egyetlen szálon több ezer taskot is kezelhess egyidejűleg, hiszen a taskok nem "foglalnak" szálat, amíg várakoznak — csak akkor futnak, amikor tényleg van dolguk.

Jó tudni

A spawn-nal létrehozott task 'static élettartamú kell legyen, mert a runtime nem tudja garantálni, hogy a hívó függvény stack frame-je még létezik, amikor a task fut. Emiatt gyakran kell Arc-ba csomagolni a megosztott adatokat.

Blokkoló kód hatása a runtime-ra és a spawn_blocking szerepe

Ez talán a legfontosabb rész, amit minden Tokio-t használó fejlesztőnek meg kell értenie: soha ne blokkolj egy async függvényen belül.

Ha egy worker szálon futó async kódba beleírsz egy std::thread::sleep-et, egy szinkron fájlműveletet, vagy egy nehéz CPU-igényes számítást, azzal blokkolod az egész worker szálat. Mivel a work-stealing scheduler ugyanazon a szálon próbálja pollozni a többi taskot is, a blokkolt szál nem tud továbblépni — minden rajta lévő task megreked, amíg a blokkoló művelet be nem fejeződik.

#[tokio::main]
async fn main() {
    tokio::spawn(async {
        // TILOS! Ez blokkolja a worker szálat
        std::thread::sleep(std::time::Duration::from_secs(5));
    });

    // Ez a task lehet, hogy nem fut le időben,
    // ha a fenti ugyanarra a szálra kerül
    tokio::spawn(async {
        println!("Szia!");
    });
}

Erre a problémára ad megoldást a spawn_blocking, ami egy külön, erre a célra fenntartott szálpoolra teszi át a blokkoló kódot, így a worker szálak szabadon maradnak az async taskok pollozására.

use tokio::task;

#[tokio::main]
async fn main() {
    let result = task::spawn_blocking(|| {
        // ide kerülhet fájl I/O, nehéz számítás, stb.
        std::thread::sleep(std::time::Duration::from_secs(2));
        "kész a blokkoló munka"
    })
    .await
    .unwrap();

    println!("{}", result);
}

A spawn_blocking mögött egy dinamikusan növekvő szálpool áll, amit a Tokio szükség szerint bővít (alapértelmezetten akár 512 szálig). Ezek a szálak nem részei a work-stealing schedulernek — kifejezetten arra vannak fenntartva, hogy blokkoló műveleteket futtassanak, anélkül hogy az async ökoszisztémát megzavarnák.

Figyelem

A CPU-igényes, de nem I/O jellegű munkára (pl. nagy adathalmaz feldolgozása, kriptográfiai számítás) szintén érdemes spawn_blocking-ot használni, mert ezek is "blokkolják" a poll ciklust ugyanúgy, mint egy szinkron I/O hívás.

Gyakorlati példa a runtime konfigurálására

Ha nem a #[tokio::main] makróra hagyatkozol, hanem manuálisan szeretnéd felépíteni a runtime-ot — például mert finomhangolni akarod a worker szálak számát vagy a blocking pool méretét —, a tokio::runtime::Builder API-t használhatod.

use tokio::runtime::Builder;

fn main() {
    let runtime = Builder::new_multi_thread()
        .worker_threads(4)
        .max_blocking_threads(16)
        .thread_name("sajat-worker")
        .enable_all()
        .build()
        .expect("nem sikerült létrehozni a runtime-ot");

    runtime.block_on(async {
        let handle = tokio::spawn(async {
            println!("Task fut a kézzel épített runtime-on!");
        });

        handle.await.unwrap();
    });
}

A worker_threads beállítja a work-stealing pool méretét, a max_blocking_threads pedig felülírja a blocking pool alapértelmezett maximumát. Az enable_all() bekapcsolja az I/O és időzítő driver-eket, amik nélkül nem működnének a hálózati vagy sleep-hez hasonló funkciók.

Tipp

Ha csak egy funkciócsoportra van szükséged (pl. csak I/O-ra, timer nélkül), használhatod az enable_io() vagy enable_time() metódusokat külön-külön, hogy csökkentsd a runtime memória- és inicializálási overhead-jét.

Ez a manuális megközelítés különösen hasznos könyvtárfejlesztőknél, ahol nem akarod rákényszeríteni a felhasználóra, hogy a #[tokio::main] makrót használja, vagy amikor több, elkülönített runtime-ot szeretnél futtatni ugyanabban a folyamatban.

Összefoglalás

A Tokio runtime egy jól átgondolt, work-stealing alapú ütemezőre épül, ami lehetővé teszi, hogy több ezer könnyűsúlyú taskot hatékonyan kezeljünk kevés operációs rendszerbeli szálon. A tokio::spawn mögött egy poll-alapú Future-kezelés áll, ahol a taskok csak akkor "aktívak", amikor tényleg van dolguk. A legfontosabb szabály, amit érdemes megjegyezni: a blokkoló kód az async világ ellensége — mindig a spawn_blocking-ot használd, ha elkerülhetetlen egy szinkron, hosszan futó művelet. Ha ezeket az alapelveket megérted, sokkal tudatosabban tudod megtervezni és hibakeresni az async Rust alkalmazásaidat.