Az async Rust köré rengeteg legenda épült: "bonyolult", "mágikus", "nem lehet megérteni". A valóság az, hogy az egész modell néhány egyszerű alapelemre épül, és ha ezeket megérted, onnantól kezdve minden async kód logikusnak fog tűnni. Ebben a cikkben végigmegyünk a Future traiten, az async/await szintaxis kulisszái mögötti működésen, egy valódi Tokio-alapú példán, és a leggyakoribb kezdő hibákon is.

Mi az a Future, és miért "lusta" egy async blokk?

A Rustban az aszinkron munka alapegysége a Future trait. Leegyszerűsítve egy Future egy olyan érték, ami még nem tartalmazza az eredményét, de idővel elő fogja állítani. A trait definíciója (a standard könyvtárban) valahogy így néz ki:

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

pub trait Future {
    type Output;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

A kulcs a poll metódus. Amikor egy executor (pl. a Tokio runtime) meghívja a poll-t, két dolog történhet:

  • Poll::Ready(érték) — a Future végzett, itt az eredmény.
  • Poll::Pending — a Future még nem tud eredményt adni, de regisztrálta magát, hogy majd értesítést kapjon (a Waker-en keresztül), amikor újra érdemes lehívni a poll-t.

Ez a lényeg, ami miatt azt mondjuk, hogy a Future-ök lusták. Ha csak létrehozol egy async blokkot vagy meghívsz egy async fn-t, önmagában semmi nem történik — nincs szál, nincs végrehajtás, csak egy adatstruktúra jön létre, ami leírja, mit kell majd csinálni. Amíg valaki (egy executor) nem hívja meg rajta a poll-t, addig a Future érintetlenül hever a memóriában.

Megjegyzés

Ez alapvető különbség más nyelvek Promise/Task modelljeihez képest, ahol a művelet elindítás pillanatában elkezdődik. Rustban az async blokk indítása == a Future létrehozása, nem a végrehajtása.

Így néz ki egy kézzel írt, primitív Future, ami néhány pollozás után lesz kész (ez csak illusztráció, valós időzítésre a tokio::time::sleep-et használjuk majd):

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

struct Delay {
    remaining: u32,
}

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

    fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        if self.remaining == 0 {
            Poll::Ready("kész vagyok!")
        } else {
            self.remaining -= 1;
            // Kérjük az executort, hogy azonnal pollozzon minket újra.
            cx.waker().wake_by_ref();
            Poll::Pending
        }
    }
}

Ez a Delay egy "busy poll" megoldás — nem hatékony, mert azonnal újra kéri a pollozást ahelyett, hogy egy valódi időzítőre várna. Valós kódban erre soha nem lesz szükséged, de remekül szemlélteti, hogy a Poll::Pending és a Waker együtt hogyan alkotja a mechanizmus vázát.

async fn vs. sima fn: mi történik a kulisszák mögött?

Amikor leírsz egy async fn-t, a fordító a háttérben egy állapotgépet generál belőle, ami implementálja a Future traitet. Vegyük ezt a függvényt:

async fn kiszamol(x: u32) -> u32 {
    x * 2
}

Ez nagyjából ekvivalens azzal, mintha te magad írtál volna egy struktúrát, ami implementálja a Future<Output = u32>-t, és a poll hívásakor azonnal visszaadja az eredményt (mivel nincs benne .await, egyetlen pollozás elég). Minél több .await pontot tartalmaz egy async fn, annál több "állapotot" kell a generált gépnek nyilvántartania — gyakorlatilag minden .await egy lehetséges megszakítási pont, ahonnan a végrehajtás majd folytatódhat.

Fontos különbség a sima fn-hez képest: egy async fn hívása nem futtatja le a törzset. Csak akkor kezdődik el bármi is, ha a visszakapott Future-t valaki pollozza — jellemzően egy .await-tel, vagy azzal, hogy odaadod egy executornak (tokio::spawn, #[tokio::main] stb.).

async fn naplozz() {
    println!("Ez lefut, amikor pollozzák a Future-t");
}

fn main() {
    let _future = naplozz(); // Semmi sem íródik ki még!
    println!("Ez a sor viszont azonnal lefut.");
}

Ha lefuttatnád ezt a példát, a println! a naplozz függvényben soha nem hívódik meg, mert a Future-t senki nem pollozza — a fordító warningot is ad rá (unused implementer of Future that must be used).

Tipp

Ha a fordító azt írja, hogy "future cannot be sent between threads safely" vagy hasonlót, gyakran az az ok, hogy egy nem Send típust (pl. Rc<T>) tartasz élve egy .await ponton át. Az állapotgép ugyanis tartalmazza az összes olyan lokális változót, ami átível az await pontokon.

Await pontok és a végrehajtás megszakítása

Az .await a Rust async modelljének a szíve. Amikor egy .await-hez érsz, a futó Future meghívja a belső Future poll-ját. Ha az Poll::Ready-t ad vissza, a végrehajtás simán folytatódik. Ha Poll::Pending-et ad vissza, az egész külső Future is Pending-et ad vissza az őt pollozó executornak — vagyis a végrehajtás megszakad, és az executor szabadon foglalkozhat más feladatokkal (más taskokkal), amíg a Waker jelzi, hogy érdemes újra próbálkozni.

Ez egy kooperatív ütemezési modell: a Rust async runtime-ja (pl. Tokio) egyetlen (vagy néhány) OS szálon is képes rengeteg taskot kezelni, mert a taskok önként "átadják a szót" minden .await pontnál, amikor épp várnak valamire (hálózati I/O, időzítő, csatorna üzenet stb.).

async fn parse_es_ossz(input: &str) -> Option<u32> {
    let Ok(szam) = input.parse::<u32>() else {
        return None;
    };
    Some(szam * 2)
}

Ez a let-else szintaxis (ami már stabil funkció) remekül illik async fn-ekbe is, mert korán ki tudsz lépni hibás bemenet esetén, anélkül hogy beágyaznád a logikát egy match-be.

Egyszerű Tokio-alapú példa lépésről lépésre

Nézzünk egy valódi, futtatható példát. Adjuk hozzá a Tokio-t a Cargo.toml-hoz:

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

A "full" feature flag mindent bekapcsol (runtime, időzítők, I/O, makrók) — kezdéshez tökéletes, később érdemes finomítani, hogy csak azt fordítsd le, amire ténylegesen szükséged van.

És a kód:

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

async fn fetch_adat(id: u32) -> String {
    // Szimulálunk egy lassú hálózati hívást.
    sleep(Duration::from_millis(200)).await;
    format!("adat-{id} betöltve")
}

#[tokio::main]
async fn main() {
    let elso = fetch_adat(1);
    let masodik = fetch_adat(2);

    // A két Future párhuzamosan (konkurensen) fut a join! makróval.
    let (a, b) = tokio::join!(elso, masodik);

    println!("{a}");
    println!("{b}");
}

Lépésről lépésre mi történik:

  1. A #[tokio::main] makró generál egy main függvényt, ami elindítja a Tokio runtime-ot, és arra ütemezi a te async main függvényedet.
  2. fetch_adat(1) és fetch_adat(2) meghívása nem indítja el a sleep-et — csak Future-öket hoz létre.
  3. A tokio::join! makró egyszerre pollozza mindkét Future-t. Amikor mindkettő Pending-et ad (mert várnak az időzítőre), a runtime más munkára fordítja a szálat.
  4. Amint mindkét időzítő letelik, a Waker jelez, a runtime újra pollozza a taskokat, azok Ready-t adnak, és a join! visszaadja a végeredményt.

Mivel a két sleep párhuzamosan fut, a teljes program kb. 200 ezredmásodperc alatt lefut — nem 400 alatt, mintha egymás után hívtad volna meg .await-tel őket.

Gyakori kezdő hibák: blokkoló kód async kontextusban

Az egyik leggyakrabban elkövetett hiba, hogy valaki egy async fn-en belül szinkron, blokkoló hívást használ — tipikusan std::thread::sleep-et, vagy egy szinkron adatbázis klienst.

use std::time::Duration;

async fn rossz_pelda() {
    // TILOS! Ez blokkolja azt az executor szálat,
    // amin ez a task fut — minden más taskot is megállít rajta!
    std::thread::sleep(Duration::from_secs(1));
}

async fn jo_pelda() {
    tokio::time::sleep(Duration::from_secs(1)).await;
}

A különbség kritikus: a std::thread::sleep az egész OS szálat blokkolja, amíg a tokio::time::sleep(...).await csak a Future-t helyezi Pending állapotba, és a szál szabadon foglalkozhat más taskokkal.

Figyelem

Ha egy multi-thread Tokio runtime-on blokkolsz egy szálat, a többi task ugyan átterelődhet a másik szálakra, de ha minden szál blokkolva van (pl. sok ilyen hívás fut egyszerre), az egész alkalmazás "beragad". Éles kódban ez nagyon nehezen debuggolható jelenség tud lenni.

Ha mindenképp CPU-igényes vagy blokkoló szinkron kódot kell futtatnod (pl. egy fájlrendszeri művelet, ami nem async-kompatibilis, vagy egy nehéz számítás), használd a tokio::task::spawn_blocking-ot, ami egy külön, erre a célra fenntartott szálpoolba teszi át a munkát:

async fn nehez_szamitas() -> u64 {
    tokio::task::spawn_blocking(|| {
        // Ez a blokk egy dedikált blocking szálon fut,
        // nem az async executor szálain.
        (1..=1_000_000u64).sum()
    })
    .await
    .expect("a spawn_blocking task pánikolt")
}

Mikor NE használj async-et?

Az async Rust nagyszerű eszköz, de nem ezüstgolyó. Néhány eset, amikor jobban jársz sima, szinkron kóddal:

  • Tisztán CPU-kötött munka. Ha a programod fő terhelése számítás (pl. képfeldolgozás, tömörítés), az async nem gyorsít semmit — a .await pontok I/O-ra várásra optimalizálnak, nem CPU-terhelés elosztására. Ilyenkor inkább szálakat vagy adat-párhuzamosságot (pl. Rayon) használj.
  • Egyszerű, rövid életű CLI scriptek. Ha egy parancssori eszközöd csak beolvas egy fájlt, feldolgoz, kiír, valószínűleg felesleges terhet ad az async runtime bevezetése és a hozzá tartozó komplexitás.
  • Amikor a csapatod még nem ismeri jól a Rust ownership és lifetime szabályait. Az async kód lifetime-jai és a Send/Sync korlátozások extra kognitív terhet adnak — érdemes előbb magabiztosan tudni szinkron Rustban dolgozni.
  • Ha nincs valódi I/O-kötött konkurrencia igényed. Ha csak egyetlen hálózati hívást csinálsz és megvárod a választ, egy szinkron kliens (pl. reqwest::blocking) sokkal egyszerűbb, és nincs szükséged runtime-ra sem.
Jó tudni

Az async Rust igazi ereje ott mutatkozik meg, amikor sok egyidejű I/O-kötött feladatot kell kezelned kevés szálon — webszerverek, proxyk, sok konkurrens hálózati kapcsolat. Ha ez nem a te use case-ed, a szinkron kód egyszerűbb, könnyebben debuggolható, és gyakran gyorsabb is a fejlesztésben.

Összefoglalás

A Rust async modellje a Future trait körül forog: minden async blokk vagy async fn egy lusta, állapotgép-alapú Future-t hoz létre, amit csak egy executor pollozása indít el ténylegesen. Az .await pontok jelölik ki azokat a helyeket, ahol a végrehajtás megszakadhat és átadhatja a szálat más taskoknak. Ha ezt a mentális modellt magadévá teszed, sokkal könnyebben fogod érteni, miért blokkol be egy program, miért nem Send egy Future, vagy miért nem indul el egy hívás, amit "elfelejtettél" .await-elni. A gyakorlásra pedig nincs jobb, mint egy egyszerű Tokio projekt — próbáld ki a fenti példákat, írj bele saját sleep-eket, join!-okat, és figyeld meg élőben, ahogy a taskok versenyeznek egymással a végrehajtási időért.