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.

Megjegyzés

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.

Jó tudni

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ásMiért nem elég a `Future` trait egyedül ahhoz, hogy egy async program lefusson?
MegoldásMert 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ásMi 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ásA 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ásMié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ásA `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 Future trait egy "majd elkészülő" értéket modellez: van egy Output típusa és egy poll metódusa, amely Poll::Ready(érték)-et vagy Poll::Pending-et adhat vissza.
  • A poll hívásához a Future-t Pin<&mut Self>-be kell csomagolni (Pin::new), mert a bonyolultabb Future-ök önmagukra hivatkozó belső szerkezetet tartalmazhatnak.
  • A Waker az 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 Future trait 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/Waker bonyodalmat az async/await szintaxis mögé – ugyanez a modell, csak sokkal olvashatóbb formában.

További olvasmányok: