Az async programozás Rustban nem varázslat, csak egy nagyon jól megtervezett absztrakció a nem blokkoló I/O fölé. Ebben a cikkben végigmegyünk azon, hogy miért van erre szükség, hogyan működik a gépezet a motorháztető alatt, és megírjuk az első valódi async feladatunkat Tokio-val. A dátum 2023 augusztusa, a legfrissebb stabil Rust az 1.71, tehát mindenre, amit itt látsz, simán fordul a mai toolchain-nel.

Miért kell egyáltalán async modell?

Hagyományos, szálakon alapuló megközelítésnél minden egyidejű feladathoz (pl. egy hálózati kapcsolat kezeléséhez) egy operációs rendszeri szálat indítunk. Ez működik, de van egy komoly ára: egy OS szál stack mérete tipikusan több száz kilobyte, és a kontextusváltás sem ingyen van. Ha tízezer egyidejű kapcsolatot akarsz kezelni, tízezer szál már komoly memória- és CPU-terhelést jelent.

Az async modell ezzel szemben kooperatív ütemezést használ: egyetlen (vagy néhány) OS szálon rengeteg apró "feladatot" (task) futtatunk, amik önként adják át a vezérlést, amikor épp várniuk kell valamire – például egy socket olvasására. Így egy async task memóriaigénye akár néhány száz bájt is lehet, és tízezer vagy százezer egyidejű kapcsolat is simán kezelhető egyetlen gépen.

ℹ
Megjegyzés

Az async nem "gyorsabbá" teszi a CPU-intenzív kódot – arra továbbra is szálak vagy rayon a megfelelő eszköz. Az async ott nyer, ahol sok az I/O-várakozás.

Az async fn és a .await kulcsszavak működése

Egy async fn deklaráció valójában nem azonnal futtatja a kódot, amikor meghívod. Helyette egy értéket ad vissza, amely a Future traitet implementálja. Ez az érték csak egy "receptet" tartalmaz arra, hogyan kell kiszámolni az eredményt – de amíg nem kérdezik meg tőle (nem "pollozzák"), semmi sem történik.

async fn koszontes() -> String {
    println!("Elkezdtem futni!");
    "Szia, Rust!".to_string()
}

fn main() {
    let fut = koszontes(); // itt még SEMMI nem íródik ki a konzolra!
    println!("A future létrejött, de még nem futott.");
    // fut-ot valakinek le kell pollozni, hogy tényleg végrehajtódjon
    drop(fut);
}

Ha lefuttatod ezt a main-t, azt fogod látni, hogy a "Elkezdtem futni!" sor soha nem jelenik meg – mert a future-t senki nem pollozta. Ez a lusta kiértékelés kulcsa: az async fn teste csak akkor kezd futni, amikor egy executor (pl. a Tokio runtime) tényleg elkezdi pollozni.

A .await kulcsszó pedig azt jelenti: "várd meg ennek a future-nek az eredményét, és amíg vársz, add át a vezérlést az executornak, hogy közben mást is csinálhasson". Fontos: .await-et csak async kontextusban (tehát async fn vagy async blokk belsejében) használhatsz.

A Future trait és a lusta kiértékelés

A Future trait a standard könyvtárban (std::future::Future) roppant egyszerű felépítésű:

pub trait Future {
    type Output;
    fn poll(self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll<Self::Output>;
}

A poll metódus vagy Poll::Ready(érték)-et ad vissza, ha a munka elkészült, vagy Poll::Pending-et, ha még várni kell valamire. Amikor Pending jön vissza, a future felelőssége, hogy regisztráljon egy "ébresztőt" (waker) a Context-en keresztül, amivel majd később értesíti az executort: "most már érdemes újra pollozni engem".

Ezt a bonyolult gépezetet a legtöbb esetben soha nem kell kézzel megírnod – az async fn fordítási időben egy állapotgépet generál neked, amely automatikusan implementálja a Future traitet. A Pin és a Context típusokkal a napi async munkádban ritkán találkozol közvetlenül, de jó tudni, hogy ott vannak a színfalak mögött.

✦
Tipp

Ha kíváncsi vagy, mi történik a motorháztető alatt, próbáld ki a cargo expand crate-et – megmutatja, milyen állapotgépet generál a fordító egy async fn-ből.

Első lépések Tokio-val: lépésről lépésre

Maga a Future trait csak a receptet adja, de valakinek le is kell pollozni – ez az executor, más néven runtime feladata. A legelterjedtebb választás erre a Tokio. Nézzük meg egy minimális projektet.

Először a Cargo.toml-ba kell egy függőség:

[dependencies]
tokio = { version = "1.0", features = ["full"] }

Most írjunk egy programot, amely két "feladatot" indít párhuzamosan, amik mindketten alszanak egy kicsit, majd kiírnak valamit:

use std::time::Duration;
use tokio::time::sleep;

async fn feladat(nev: &str, ido_ms: u64) {
    sleep(Duration::from_millis(ido_ms)).await;
    println!("{} befejeződött ({} ms után)", nev, ido_ms);
}

#[tokio::main]
async fn main() {
    println!("Indulás...");

    let elso = feladat("Első", 200);
    let masodik = feladat("Második", 100);

    // A join! makró egyidejűleg futtatja a két future-t,
    // és megvárja, míg mindkettő befejeződik.
    tokio::join!(elso, masodik);

    println!("Minden feladat kész.");
}

Ha lefuttatod, a kimenet így néz ki:

Indulás...
Második befejeződött (100 ms után)
Első befejeződött (200 ms után)

Figyeld meg: a "Második" hamarabb kiíródik, hiszen rövidebb ideig alszik, és a join! makró egyidejűleg futtatja a két future-t egyetlen szálon (vagy Tokio esetén akár több worker-szálon, ha a multi_thread runtime-ot használod, ami a #[tokio::main] alapértelmezett viselkedése).

Ha önálló task-ként, egymástól függetlenül szeretnéd futtatni őket, és nem akarod megvárni, hogy egyszerre induljanak el, használhatod a tokio::spawn-t is:

use std::time::Duration;
use tokio::time::sleep;

#[tokio::main]
async fn main() {
    let handle = tokio::spawn(async {
        sleep(Duration::from_millis(150)).await;
        println!("Háttértask lezajlott");
        42
    });

    println!("A főprogram közben csinál mást...");

    let eredmeny = handle.await.expect("a task elszállt");
    println!("Az eredmény: {}", eredmeny);
}

A tokio::spawn egy önálló task-ot ad az executornak, amely a .await-elt JoinHandle-lel kérhető vissza. Ez a mintázat nagyon gyakori, ha sok kis, egymástól független munkát akarsz futtatni – például egy webszerverben minden bejövő kapcsolatot külön task-ban kezelnek.

Gyakori kezdő hibák és buktatók

Az async Rust tanulása közben néhány buktatóba szinte mindenki belefut. Íme a leggyakoribbak.

1. Elfelejtett .await. Ha egy async fn hívása után nem írod ki a .await-et, a kód lefordul (mert a Future egy érvényes érték), de a benne lévő kód sosem fut le. A fordító adott esetben figyelmeztet erre (unused must_use warning), de kezdőként ez könnyen átcsúszik.

async fn dolgozik() {
    println!("Dolgozom!");
}

async fn hibas_hivas() {
    dolgozik(); // FIGYELEM: nincs .await, ez soha nem fut le!
}
⚠
Figyelem

A fordító #[must_use] figyelmeztetést ad az ilyen esetekre, de sosem hiba – csak warning. Mindig érdemes cargo build közben átfutni a figyelmeztetéseket!

2. Blokkoló hívás async kontextusban. Ha egy async fn-en belül std::thread::sleep-et vagy szinkron fájlműveletet hívsz, az az egész worker-szálat blokkolja, ami alatt a Tokio scheduler semmilyen más task-ot nem tud futtatni azon a szálon. Ehelyett mindig a Tokio saját aszinkron megfelelőit használd (tokio::time::sleep, tokio::fs), vagy ha elkerülhetetlen a blokkoló hívás, csomagold tokio::task::spawn_blocking-ba.

3. .await egy Mutex lock-on belül tartva. A std::sync::Mutex-ot nem arra tervezték, hogy .await pontokon átnyúlva tartsd a lock-ot – ha megteszed, deadlockot kockáztatsz, mert a task felfüggesztése közben más task-ok sosem jutnak hozzá a mutexhez. Async kontextusban erre a tokio::sync::Mutex a megfelelő eszköz.

4. A runtime elfelejtése. Egy async fn main-nek szüksége van egy executorra, amely elindítja. A #[tokio::main] makró ezt automatikusan megcsinálja neked, de ha library kódot írsz, és teszteled, ne feledd a #[tokio::test] attribútumot a teszt függvényeken.

#[tokio::test]
async fn teszt_aszinkron_fuggveny() {
    let eredmeny = 2 + 2;
    assert_eq!(eredmeny, 4);
}
✓
Jó tudni

Sose keverd össze a std::sync::Mutex-ot a tokio::sync::Mutex-szal! Az előbbi szinkron, blokkoló primitíva, az utóbbi kifejezetten async kódhoz készült.

Összefoglalás

Az async Rust első ránézésre ijesztő lehet a Future trait, a Pin és a lusta kiértékelés miatt, de a napi munkában a legtöbbször csak annyit kell tudnod: az async fn egy futtatható receptet ad, a .await elindítja és megvárja, és egy executor – jellemzően Tokio – felelős azért, hogy mindezt valóban le is pollozza. Kezdd kis lépésekkel: írj egy #[tokio::main] programot, próbálkozz a sleep, join! és spawn hármassal, és figyelj a fentebb felsorolt buktatókra. Ha ezt megérted, már készen állsz arra, hogy komolyabb async projektekbe – akár egy Axum-alapú webszerverbe – vágj bele.