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 (aWaker-en keresztül), amikor újra érdemes lehívni apoll-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.
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).
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"] }
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:
- A
#[tokio::main]makró generál egymainfüggvényt, ami elindítja a Tokio runtime-ot, és arra ütemezi a te asyncmainfüggvényedet. fetch_adat(1)ésfetch_adat(2)meghívása nem indítja el asleep-et — csak Future-öket hoz létre.- 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. - Amint mindkét időzítő letelik, a Waker jelez, a runtime újra pollozza a taskokat, azok
Ready-t adnak, és ajoin!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.
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
.awaitpontok 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/Synckorlá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.
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.