Ha eddig más nyelvből (mondjuk JavaScriptből vagy C#-ból) érkeztél az async/await világába, valószínűleg meglepődtél, amikor egy Rust async fn-t meghívtál, és... semmi nem történt. Nem hiba, nem panic, csak néma csend. Ez nem véletlen — ez a Rust async modell egyik legfontosabb, és leggyakrabban félreértett tulajdonsága.
Mi az a Future, és miért lusta?
Amikor leírsz egy async fn-t, a fordító a háttérben egy state machine-t generál belőle, ami implementálja a Future traitet. Ez a trait roppant egyszerű:
pub trait Future {
type Output;
fn poll(self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll<Self::Output>;
}
A lényeg a poll metódus. Ez vagy visszaad egy Poll::Ready(érték)-et, ha a munka elkészült, vagy egy Poll::Pending-et, ha még várni kell valamire (pl. egy socketre, egy timerre). A Future tehát önmagában semmit sem csinál — csak leírja, hogyan lehet előrébb vinni a munkát, ha valaki (egy executor) meghívja rajta a poll-t.
Ez a lusta kiértékelés (lazy evaluation) a kulcs. Egy async fn hívása nem indít el semmilyen munkát, csak létrehoz egy értéket, ami leírja a jövőbeli munkát. Amíg valaki nem kezdi el pollozni (közvetve vagy közvetlenül egy executoron keresztül), addig a belsejében lévő kód egyetlen sora sem fut le.
A Future-ök "push" helyett "pull" modellt követnek: az executor kérdezi meg időnként a Future-t, hogy "kész vagy már?", nem pedig a Future szól be magától, amikor végzett. (A Waker mechanizmus gondoskodik róla, hogy ne kelljen feleslegesen pollozgatni, de ez már egy másik cikk témája.)
async fn vagy async block?
Két fő módja van annak, hogy async kódot írj: az async fn és az async blokk. Első ránézésre hasonlóak, de van néhány fontos különbség.
Az async fn egy függvénydefiníció, aminek a visszatérési típusa automatikusan egy anonim, Future-t implementáló típus lesz:
async fn lekerdez_adatot(id: u32) -> String {
// képzeletbeli hálózati hívás
format!("adat-{id}")
}
Ez gyakorlatilag ekvivalens azzal, mintha ezt írnád (leegyszerűsítve):
fn lekerdez_adatot(id: u32) -> impl std::future::Future<Output = String> {
async move {
format!("adat-{id}")
}
}
Az async blokk viszont egy kifejezés, ami helyben hoz létre egy Future-t, anélkül hogy külön függvényt kellene definiálnod hozzá. Ez remekül jön, ha egy meglévő függvényen belül szeretnél egy kis async logikát becsomagolni, vagy ha closure-szerűen akarsz Future-t gyártani:
fn keszits_feladatot(id: u32) -> impl std::future::Future<Output = String> {
async move {
format!("feladat-{id}")
}
}
A gyakorlatban a legtöbb esetben az async fn-t fogod használni, mert olvashatóbb és a szignatúrája önmagáért beszél. Az async blokkokra tipikusan akkor van szükséged, amikor egy meglévő szinkron függvényen belül kell egy Future-t összerakni (pl. tokio::spawn hívásba), vagy amikor több async ág közül kell választanod feltételesen.
Az async move blokk (és az async move fn, bár ez utóbbi ritkán kell explicit módon) átveszi a hivatkozott változók tulajdonjogát. Ez különösen fontos, ha a Future-t egy másik taskra akarod átadni tokio::spawn-nal, mert az átadott Future-nek 'static élettartamúnak kell lennie — vagyis nem tartalmazhat kölcsönzött referenciákat a jelenlegi stack frame-ből.
Az .await operátor lépésről lépésre
Az .await az a pont, ahol a varázslat történik — de fontos tudni, hogy ez nem egy blokkoló hívás a hagyományos értelemben. Nézzük meg, mi történik pontosan, amikor leírsz egy .await-et egy async fn testében:
- A fordító az
async fn-ből egy állapotgépet (state machine) generál. Minden.awaitpont egy lehetséges "szüneteltetési pontot" jelent ebben az állapotgépben. - Amikor a végrehajtás elér egy
.await-hez, a Rust meghívja a mögötte lévő Futurepollmetódusát. - Ha a
pollPoll::Ready(érték)-et ad vissza, a végrehajtás egyszerűen folytatódik, mintha egy szinkron függvényhívás tért volna vissza. - Ha a
pollPoll::Pending-et ad vissza, a teljes külső Future (vagyis maga azasync fn, amiben ez a.awaitvan) isPoll::Pending-et ad vissza az őt hívó executornak. Az állapotgép elmenti, hol tartott, és amikor legközelebb pollozzák, onnan folytatja. - A belső Future felelőssége, hogy regisztrálja a jelenlegi taskot (a
Waker-en keresztül) egy értesítési mechanizmusban, hogy amikor készen áll a folytatásra, jelezzen az executornak: "pollozz meg engem újra".
Ez azt jelenti, hogy az .await nem foglal el egy szálat üresjáratban várakozással — átadja az irányítást az executornak, ami közben más taskokat tud futtatni. Ez a lényegi különbség a hagyományos, szálakon alapuló blokkoló I/O-hoz képest.
async fn peldafuggveny() {
println!("1. lépés");
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
println!("2. lépés, 100ms után");
}
Ebben a példában a sleep(...).await sorban a peldafuggveny Future-je Pending-et ad vissza, amíg a timer le nem jár, közben a szál szabadon futtathat más taskokat.
Miért nem fut le semmi executor nélkül?
Ez a pont okozza a legtöbb fejfájást kezdőknek. Ha csak simán meghívod az async fn-edet egy fn main()-ből, semmi nem történik:
async fn koszones() {
println!("Szia, világ!");
}
fn main() {
koszones(); // Ez NEM ír ki semmit!
}
A koszones() hívás csak létrehoz egy Future értéket, de senki sem hívja meg rajta a poll-t. Sőt, a fordító figyelmeztetni is fog erre a #[must_use] attribútum miatt, amivel a standard könyvtár és a legtöbb async ökoszisztéma-crate megjelöli a Future típusokat:
warning: unused implementer of `Future` that must be used
note: futures do nothing unless you `.await` or poll them
Ez a figyelmeztetés (ami az 1.68-as Rustban is aktívan jelen van a #[must_use] annotáción keresztül) pontosan azért létezik, hogy elkapja ezt a kezdő hibát: elfelejtett .await.
Ahhoz, hogy egy Future ténylegesen lefusson, kell egy executor (más néven runtime), ami folyamatosan pollozza a taskokat. A Rust standard könyvtára szándékosan nem tartalmaz executort — ez egy tudatos döntés, hogy a felhasználók megválaszthassák a saját igényeikhez illő implementációt. A legelterjedtebb választás a tokio:
#[tokio::main]
async fn main() {
koszones().await;
}
async fn koszones() {
println!("Szia, világ!");
}
A #[tokio::main] makró a színfalak mögött egy tokio::runtime::Runtime-ot hoz létre, és azon futtatja le a main async blokkját. Enélkül a main sima szinkron függvény maradna, és az async fn main() szintaxis nem is fordulna.
Ha a Cargo.toml-ban elfelejted bekapcsolni a tokio full (vagy legalább rt-multi-thread és macros) feature-jét, a #[tokio::main] makró nem fog fordulni. Egy tipikus beállítás:
[dependencies]
tokio = { version = "1", features = ["full"] }
Gyakori kezdő hibák
Nézzünk pár klasszikus hibát, amibe szinte mindenki belefut az első async projektjében.
1. Elfelejtett .await. Ahogy fentebb láttuk, ez a leggyakoribb. A #[must_use] figyelmeztetés szerencsére szinte mindig felhívja rá a figyelmet, de érdemes odafigyelni a fordítói warningokra, ne kapcsold ki őket vaktában.
2. Blokkoló kód async kontextusban. Ha egy async fn-en belül std::thread::sleep-et vagy más blokkoló I/O-t hívsz meg (pl. szinkron fájlolvasást), az egész executor szálat lefoglalod, ami megbénítja a többi task futását is:
async fn rossz_pelda() {
// Ne csináld ezt! Blokkolja a teljes executor szálat.
std::thread::sleep(std::time::Duration::from_secs(1));
}
async fn jo_pelda() {
// Ez helyesen átadja az irányítást az executornak várakozás közben.
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
}
3. 'static élettartam megsértése tokio::spawn-nál. Mivel a spawnolt task egy külön, akár másik szálon futó taskká válik, minden benne hivatkozott adatnak 'static-nak vagy tulajdonolt (owned) értéknek kell lennie:
async fn spawnolo_pelda() {
let uzenet = String::from("hello");
tokio::spawn(async move {
// az `move` kulcsszó nélkül a fordító hibát dobna
println!("{uzenet}");
});
}
4. Async blokk visszatérési típusának félreértése. Egy async blokk mindig egy Future-t ad vissza, nem a benne lévő kifejezés végeredményét közvetlenül. Ha elfelejted az .await-et a blokk eredményén, könnyen egy Future<Output = T> típusú értékkel próbálsz meg T-ként dolgozni, amit a típusrendszer azonnal jelez fordítási hibaként — ez legalább nem néma hiba, mint az elfelejtett .await a #[must_use] esetében.
5. Executor típusának keverése. Ha egyszerre használsz tokio-t és egy másik async runtime-ot (pl. async-std-et) ugyanabban a projektben, a Future-ök típusai kompatibilisek maradnak (hiszen mindkettő a std Future traitet implementálja), de a runtime-specifikus segédfüggvények (pl. tokio::time::sleep vs. async_std::task::sleep) nem átjárhatók. Válassz egy runtime-ot, és maradj is annál egy projekten belül.
Összefoglalás
A Rust async modellje elsőre szokatlan lehet, mert a nyelv szándékosan nem rejt el semmit: egy Future csak egy adatstruktúra, ami leírja a jövőbeli munkát, és semmi sem történik, amíg egy executor nem kezdi el pollozni. Az .await nem varázslat, hanem egy jól definiált állapotgép-átmenet, ami lehetővé teszi, hogy a szál más munkát végezzen várakozás közben. Ha megérted ezt a mentális modellt — lusta Future, explicit polling, 'static határok a spawnolt taskoknál —, onnantól az async Rust kód sokkal kevésbé lesz misztikus, és a fordító hibaüzenetei is értelmesebbé válnak.