Ez az útvonal az async Rust és a webszerverek világába vezet, és ez itt az első lecke – nincs mire visszautalnunk, viszont van egy szilárd alap: ismered az ownershipet, a trait-eket, a generikákat és az Iterator trait lusta működését. Ez utóbbi különösen fontos, mert a Future trait nagyon hasonló elven működik: egy érték, amely nem azonnal áll elő, hanem lépésről lépésre "húzzuk ki" belőle az eredményt. Mielőtt bármit is írnál async fn-nel (ami a következő leckében jön), érdemes megérteni, mi rejtőzik a motorháztető alatt – mert ez adja meg a kulcsot ahhoz, hogy az async kód ne csak "működjön", hanem értsd is, miért működik.
Miért nem elég a szinkron várakozás?
Ha egy program hálózati kérésre, fájlműveletre vagy más lassú erőforrásra vár, a leggyakoribb hagyományos megoldás az, hogy a program szála (thread) egyszerűen blokkol: megáll, és semmi mást nem csinál, amíg a válasz nem érkezik meg. Egy szerver esetében ez katasztrofális lehet – ha egyszerre több ezer kliens kapcsolódik, nem indíthatunk ezres nagyságrendű szálat mindegyiknek.
A megoldás felé az első lépés az, hogy a "várunk valamire" fogalmát ne blokkolással, hanem ismételt lekérdezéssel (pollozással) modellezzük. Nézzünk egy egyszerű, még Future nélküli példát, ami ezt az alapgondolatot szemlélteti:
struct SlowCounter {
ticks_left: u32,
}
impl SlowCounter {
// Ha még nem kész, None-t ad vissza – nem blokkol, csak jelez.
fn try_finish(&mut self) -> Option<u32> {
if self.ticks_left == 0 {
Some(42)
} else {
self.ticks_left -= 1;
None
}
}
}
fn main() {
let mut counter = SlowCounter { ticks_left: 3 };
// Addig kérdezzük újra, amíg kész nem lesz – ez maga a "pollozás" ötlete.
loop {
match counter.try_finish() {
Some(value) => {
println!("Kész: {value}");
break;
}
None => println!("Még nincs kész, próbáljuk újra..."),
}
}
}
Ez még messze nem async Rust, de pontosan ez a mag: egy érték, amit nem egyszerre kapunk meg, hanem időről időre megkérdezünk, hogy "kész vagy-e már?". A Future trait ezt az ötletet formalizálja egy szabványos interfészben.
A Future trait: egy majd elkészülő érték
A std::future::Future trait (nagyjából) így néz ki – ezt nem kell megírnod, csak érteni, mert a standard könyvtár már definiálja:
// Részlet a std könyvtárból, illusztrációként:
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub enum Poll<T> {
Ready(T),
Pending,
}
A Output asszociált típus (ezt már ismered az Iterator::Item-ből) azt mondja meg, milyen érték áll majd elő, ha a Future elkészül. A poll metódus pedig a lekérdezés maga: minden meghívásakor a Future eldöntheti, hogy készen van-e (Poll::Ready(érték)) vagy még nem (Poll::Pending).
A self: Pin<&mut Self> furcsán néz ki elsőre. A Pin egy speciális "csomagolás" &mut Self körül, amely garantálja, hogy a Future értéke nem mozdul el a memóriában, amíg pollozzák – ez azért fontos, mert bonyolultabb Future-ök (amilyeneket a async fn generál majd) önmagukra hivatkozó belső mutatókat tartalmazhatnak. Most elég annyit tudni, hogy a poll hívásához mindig egy Pin<&mut Self>-re van szükség, ezt a Pin::new(&mut érték) hívással tudjuk előállítani, ha az érték Unpin (ami a saját, egyszerű struct-jainkra alapból igaz).
Írjunk egy saját, kézzel implementált Future-t:
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll, Waker};
/// Egy Future, amely pontosan `remaining` pollozás után lesz kész.
struct Countdown {
remaining: u32,
}
impl Future for Countdown {
type Output = String;
fn poll(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.remaining == 0 {
Poll::Ready(String::from("Kész!"))
} else {
self.remaining -= 1;
println!("Még várunk... ({} van hátra)", self.remaining);
Poll::Pending
}
}
}
fn main() {
let mut countdown = Countdown { remaining: 3 };
// Waker::noop() egy olyan Wakert ad, ami nem csinál semmit –
// csak demonstrációra jó, valódi runtime-ban nem így néz ki.
let waker = Waker::noop();
let mut cx = Context::from_waker(waker);
// Kézzel "futtatjuk" a Future-t – éles kódban ezt egy runtime tenné meg.
loop {
if let Poll::Ready(value) = Pin::new(&mut countdown).poll(&mut cx) {
println!("Eredmény: {value}");
break;
}
}
}
Figyeld meg: a Countdown semmiben sem különbözik a korábbi SlowCounter-től gondolatilag – csak most a szabványos Future interfészt implementáltuk, amit bármilyen async-kompatibilis kód felismer és tud kezelni.
A Waker::noop() egy olyan &'static Waker-t ad, amely semmit sem csinál értesítéskor. Ez most azért elég, mert mi magunk pollozunk egy loop-ban – egy valódi runtime viszont nem pollozna feleslegesen körbe-körbe, hanem a Waker segítségével csak akkor pollozna újra, ha van esély a haladásra. Erről bővebben az következő szakaszban.
Hibás pollozás: mit üzen a fordító?
A Pin<&mut Self> self-típus miatt a poll metódust nem lehet közvetlenül, Pin::new nélkül meghívni egy sima értéken – a metódus-feloldás nem "csomagolja be" automatikusan Pin-be az értékedet. Nézzük meg, mi történik, ha kihagyjuk:
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll, Waker};
struct Countdown {
remaining: u32,
}
impl Future for Countdown {
type Output = String;
fn poll(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.remaining == 0 {
Poll::Ready(String::from("Kész!"))
} else {
self.remaining -= 1;
Poll::Pending
}
}
}
fn main() {
let mut countdown = Countdown { remaining: 3 };
let waker = Waker::noop();
let mut cx = Context::from_waker(waker);
// Hiányzik a Pin::new(&mut countdown) – direktben próbáljuk pollozni.
match countdown.poll(&mut cx) {
Poll::Ready(value) => println!("Eredmény: {value}"),
Poll::Pending => println!("Még nincs kész"),
}
}
error[E0599]: no method named `poll` found for struct `Countdown` in the current scope
--> src/main.rs:20:15
|
20 | match countdown.poll(&mut cx) {
| ^^^^ method not found in `Countdown`
|
= note: `Countdown` implements `Future`, but `Future::poll` expects `self: Pin<&mut Self>`,
not `&Countdown` or `&mut Countdown`
help: consider wrapping the receiver in `Pin::new`
|
20 | match Pin::new(&mut countdown).poll(&mut cx) {
| ++++++++ +
Ez a hibaüzenet pontosan arra figyelmeztet, amit fent kiemeltünk: a poll hívásához explicit Pin-be csomagolás kell.
A Waker és a runtime szükségessége
Az előző példákban egy loop-ban folyamatosan pollozunk – ez működik, de rettenetesen hatékonytalan lenne éles kódban: a processzor feleslegesen pörögne, miközben semmi hasznos nem történik. Itt jön képbe a Waker.
A gondolat az, hogy amikor egy Future Poll::Pending-et ad vissza, nem a mi felelősségünk újra és újra lekérdezni. Ehelyett a Future megkapja a Context-ből a Waker-t, elteszi valahova (pl. egy háttérszálon futó I/O-eseményhez köti), és amikor a várt esemény bekövetkezik (megjött az adat a hálózatról, letelt egy időzítő stb.), a Future meghívja a Waker-en a wake() metódust. Ez jelzi a futtatónak: "most már érdemes újra pollozni ezt a Future-t, van esély a haladásra".
Ez a mechanizmus teszi lehetővé, hogy egyetlen szálon (vagy néhány szálon) több ezer Future várakozhasson egyszerre anélkül, hogy bármelyik szál feleslegesen pörögne vagy blokkolna. A futtató (runtime) feladata pontosan ez: nyilvántartja, mely Future-ök várnak, figyeli a beérkező wake() jelzéseket, és csak azokat a Future-öket pollozza újra, amelyeknél tényleg van esély a haladásra.
Ezt mutatja be – még mindig Waker-ek nélkül, csak a lényeget kiemelve – egy nagyon egyszerű, kézi "executor", amely két Future-t felváltva pollozik:
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll, Waker};
struct Countdown {
remaining: u32,
}
impl Future for Countdown {
type Output = String;
fn poll(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
if self.remaining == 0 {
Poll::Ready(String::from("Kész!"))
} else {
self.remaining -= 1;
Poll::Pending
}
}
}
fn main() {
let mut task_a = Countdown { remaining: 2 };
let mut task_b = Countdown { remaining: 4 };
let waker = Waker::noop();
let mut cx = Context::from_waker(waker);
let mut a_done = false;
let mut b_done = false;
// Ez egy minimális "executor": felváltva pollozza a két Future-t.
while !a_done || !b_done {
if !a_done {
if let Poll::Ready(value) = Pin::new(&mut task_a).poll(&mut cx) {
println!("A kész: {value}");
a_done = true;
}
}
if !b_done {
if let Poll::Ready(value) = Pin::new(&mut task_b).poll(&mut cx) {
println!("B kész: {value}");
b_done = true;
}
}
}
}
Ez elviekben az, amit a Tokio nevű runtime csinál – csak sokkal kifinomultabban: valódi I/O-eseményekre (socket, timer) épít, és nem pörgő ciklussal, hanem a Waker wake() hívásaira reagálva ébred fel. A Future trait maga a standard könyvtárban van, de a futtatáshoz mindig szükség van egy külső runtime-ra – a nyelv önmagában csak a szerződést (interfészt) adja, a végrehajtás motorját nem.
A Future trait önmagában semmit nem futtat! Ha csak létrehozol egy Future-t, de soha nem pollozod (sem kézzel, sem egy runtime-mal), az egyszerűen nem fog történni semmi. Ez az egyik leggyakoribb async kezdő hiba – erről is szó lesz a következő leckében.
Próbáld ki!
Vedd a Countdown struct-ot, és alakítsd át úgy, hogy a poll minden hívásnál kiírja, hányadik pollozásnál járunk (pl. egy polls_so_far: u32 mezővel), majd futtasd le a kézi loop-os pollozó kódot 5-ös kezdőértékkel. Figyeld meg: pontosan hányszor hívódik meg a poll, mire Poll::Ready-t kapunk?
Önellenőrzés
Megoldás
Miért nem elég a `Future` trait egyedül ahhoz, hogy egy async program lefusson?Megoldás
Mert a trait csak a szerződést (a `poll` metódust és a `Poll` enumot) definiálja, magát a végrehajtást nem. Valakinek – egy runtime-nak – meg kell hívnia a `poll`-t, és reagálnia kell a `Poll::Pending` és `Poll::Ready` eredményekre. Nélküle a Future csak egy adatszerkezet marad, ami soha nem "halad előre".Megoldás
Mi a különbség a mi kézi `loop`-os pollozásunk és egy valódi runtime (pl. Tokio) viselkedése között?Megoldás
A mi `loop`-unk feltétlenül, feleslegesen pörög, amíg a Future készen nem áll – ez processzoridőt éget hiába. Egy valódi runtime a Waker `wake()` hívására vár, és addig nem pollozza újra a Future-t, amíg nincs jelzés arra, hogy érdemes. Így akár több ezer várakozó Future is elférhet egyetlen szálon, blokkolás és felesleges pörgés nélkül.Megoldás
Miért kell `Pin<&mut Self>`-et használni a `poll` self-paraméterének, és mi a gyakorlati következménye ennek a mi kódunkban?Megoldás
A `Pin` garantálja, hogy a Future értéke nem mozdul el a memóriában, amíg pollozzák – ez a komplex, önmagukra hivatkozó Future-ök (amilyeneket az `async fn` generál) helyes működéséhez szükséges. A gyakorlatban ez azt jelenti, hogy a `poll`-t nem hívhatjuk meg közvetlenül egy sima `&mut` értéken – előbb `Pin::new(&mut érték)`-kel be kell csomagolnunk, különben fordítási hibát (`E0599`) kapunk.Összefoglalás és mi jön legközelebb
- A
Futuretrait egy "majd elkészülő" értéket modellez: van egyOutputtípusa és egypollmetódusa, amelyPoll::Ready(érték)-et vagyPoll::Pending-et adhat vissza. - A
pollhívásához a Future-tPin<&mut Self>-be kell csomagolni (Pin::new), mert a bonyolultabb Future-ök önmagukra hivatkozó belső szerkezetet tartalmazhatnak. - A
Wakeraz a mechanizmus, amivel egy Future jelezheti a futtatónak, hogy "most már érdemes újra pollozni" – ez teszi lehetővé, hogy a rendszer ne pörögjön feleslegesen, hanem hatékonyan várjon. - Önmagában a
Futuretrait nem futtat semmit: mindig szükség van egy külső runtime-ra (mint a Tokio), amely a pollozást, a Waker-eket és az események kezelését megvalósítja. - A következő leckében megnézzük, hogyan rejti el a Rust az összes itt látott kézi
poll/Pin/Wakerbonyodalmat az async/await szintaxis mögé – ugyanez a modell, csak sokkal olvashatóbb formában.
További olvasmányok: