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.
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.
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!
}
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);
}
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.