Az előző leckében megismerted az async fn-eket, a .await-et és az async blokkokat — de egyelőre csak egyetlen Future-t tudtál futtatni egyszerre, egymás után. Ideje ezen továbblépni: ma megtanulod, hogyan futtathatsz több async feladatot egyszerre a Tokio runtime segítségével.

Cél és előfeltételek

Ez a lecke egy önálló, kis Cargo-projektet épít fel lépésről lépésre. A végére képes leszel:

  • elindítani a Tokio async runtime-ot a #[tokio::main] makróval,
  • konkurrens taskokat indítani a tokio::spawn segítségével,
  • egy elindított task eredményét visszavárni egy JoinHandle-lel,
  • több Future-t egyszerre várakoztatni a join! makróval,
  • az elsőként befutó Future-t kiválasztani a select! makróval.

Ehhez szükséged lesz a korábbi leckék anyagára: az async fn/.await szintaxisra, a Future trait fogalmi ismeretére, a Result/? operátorra, valamint az ownership- és borrow-szabályokra (ezek async környezetben is ugyanúgy érvényesek).

Megjegyzés

A Send + 'static kitekintést az előző leckéből már ismered fogalmi szinten: a tokio::spawn-nal indított taskoknak ezeket a feltételeket kell teljesíteniük, mert a runtime bármikor átteheti őket egy másik szálra. Ma ezt a gyakorlatban is látni fogod.

Előkészítés

Hozz létre egy új Cargo-projektet, és add hozzá a Tokio függőséget.

cargo new tokio-spawn-join
cd tokio-spawn-join
[package]
name = "tokio-spawn-join"
version = "0.1.0"
edition = "2024"

[dependencies]
tokio = { version = "1", features = ["full"] }

A "full" feature-készlet most a legegyszerűbb választás: tartalmazza a runtime-ot, a spawn-t, az időzítést (tokio::time) és a makrókat is. Élesben érdemes lesz majd finomabban megválasztani a feature-öket, de gyakorláshoz ez most tökéletes.

Ellenőrizd, hogy fordul-e a projekt:

cargo check

1. lépés — a #[tokio::main] makró

Ez a makró teszi lehetővé, hogy a main függvényed async fn legyen. A háttérben egy teljes Tokio runtime-ot indít el, elindítja a main-t mint egy Future-t, és lefuttatja azt a végéig.

Feladat: írj egy programot, amelyben a main async fn, és belül .await-tel hívsz meg egy másik async fn-t, amely tokio::time::sleep-pel szimulál egy kis várakozást, majd kiír egy üzenetet.

Megoldás
use std::time::Duration;

async fn greet(name: &str) {
    tokio::time::sleep(Duration::from_millis(100)).await;
    println!("Szia, {name}!");
}

#[tokio::main]
async fn main() {
    greet("Rust tanuló").await;
    println!("A program végére értünk.");
}
Tipp

A #[tokio::main] alapértelmezetten egy multi-thread runtime-ot indít, ami több OS-szálon ütemezi a taskokat. Ha csak egy szálat szeretnél (pl. teszteléshez), írhatod #[tokio::main(flavor = "current_thread")] formában is — ezt egyelőre elég csak megjegyezni.

2. lépés — tokio::spawn és JoinHandle

A .await önmagában nem konkurrens: amíg egy Future-re várunk, a kódunk ott áll meg, és csak azután folytatódik. A konkurrenciához a tokio::spawn függvény kell: ez egy Future-t (jellemzően egy async blokk vagy async fn eredményét) elad a runtime-nak, amely azt függetlenül, a háttérben futtatja.

A tokio::spawn visszatérési értéke egy JoinHandle<T>, ahol T a taskod visszatérési típusa. A JoinHandle maga is .await-elhető: amikor megvárod, visszakapod a task eredményét egy Result<T, JoinError> csomagolásban (a JoinError akkor jönne létre, ha a task belül panicolna — ezt egyelőre elég tudni, hogy létezik).

Feladat: indíts el egy taskot tokio::spawn-nal, amely visszaad egy i32 értéket, majd várd vissza a JoinHandle-lel, és írd ki az eredményt.

Megoldás
use std::time::Duration;

async fn compute() -> i32 {
    tokio::time::sleep(Duration::from_millis(50)).await;
    42
}

#[tokio::main]
async fn main() {
    let handle = tokio::spawn(compute());

    // Itt közben a main task is dolgozhatna valamin.
    println!("A task elindult, várunk az eredményre...");

    match handle.await {
        Ok(value) => println!("A task eredménye: {value}"),
        Err(err) => println!("A task hibával állt le: {err}"),
    }
}
Jó tudni

A tokio::spawn-nal indított Future-nek 'static élettartamúnak és Send-nek kell lennie. Ez azt jelenti, hogy nem tartalmazhat rövid életű referenciákat a spawn-t hívó függvény lokális változóira — a taskba mozgatott (move) adatoknak saját tulajdonban kell lenniük. Ha egy változót meg akarsz osztani több task között, a Rust fordító azonnal jelezni fogja, ha ez sérülne.

3. lépés — több task egyszerre

A tokio::spawn igazi ereje akkor látszik, amikor több taskot indítasz egyszerre, és azok párhuzamosan (valójában: konkurrensen, egymással átfedve) futnak.

Feladat: indíts el egy ciklusban öt taskot, mindegyik szimuláljon egy kis munkát (sleep), és adjon vissza egy számot. Gyűjtsd össze a JoinHandle-öket egy Vec-be, majd várd vissza mindegyiket, és összesítsd az eredményeket.

Megoldás
use std::time::Duration;

async fn work(id: u32) -> u32 {
    tokio::time::sleep(Duration::from_millis(50)).await;
    id * 10
}

#[tokio::main]
async fn main() {
    let mut handles = Vec::new();

    for id in 1..=5 {
        // A `move` szükséges, mert az `id`-t a taskba mozgatjuk.
        let handle = tokio::spawn(async move { work(id).await });
        handles.push(handle);
    }

    let mut sum = 0;
    for handle in handles {
        let result = handle.await.expect("a task ne paniceljon");
        println!("Eredmény: {result}");
        sum += result;
    }

    println!("Összesen: {sum}");
}

Figyeld meg: mind az öt task egyszerre elindul, és mivel mindegyik ugyanannyit (50 ms-ot) vár, a teljes program mégis csak nagyjából 50 ms-ot vesz igénybe — nem 250-et. Ez a konkurrencia gyakorlati haszna.

4. lépés — a join! makró

Ha nem szeretnél JoinHandle-öket kézzel gyűjtögetni, hanem csak egyszerűen egyszerre meg akarsz várni néhány Future-t (ugyanabban a taskban, spawn nélkül), erre a tokio::join! makró a legkézenfekvőbb eszköz. A join! a megadott Future-öket egymással átfedve futtatja, és egy tuple-ben adja vissza az összes eredményt, miután mindegyik befutott.

Feladat: hívj meg egyszerre két különböző async fn-t a join! makróval, amelyek eltérő típust adnak vissza (pl. egy szám és egy szöveg), és írd ki mindkét eredményt.

Megoldás
use std::time::Duration;

async fn fetch_number() -> i32 {
    tokio::time::sleep(Duration::from_millis(30)).await;
    7
}

async fn fetch_message() -> String {
    tokio::time::sleep(Duration::from_millis(60)).await;
    String::from("kész vagyok")
}

#[tokio::main]
async fn main() {
    let (number, message) = tokio::join!(fetch_number(), fetch_message());

    println!("Szám: {number}, üzenet: {message}");
}
Megjegyzés

A join! nem indít külön taskot, mint a tokio::spawn — a megadott Future-ök ugyanazon a taskon belül futnak, csak felváltva haladnak, amikor egyikük vár (pl. sleep közben a másik is haladhat). Ha valódi, más szálon futó párhuzamosságot akarsz, a tokio::spawn a megfelelő eszköz.

5. lépés — a select! alapjai

Néha nem az összes Future eredményére van szükséged, csak arra, amelyik elsőként befut — a többit pedig el akarod dobni. Erre a tokio::select! makró szolgál: több Future-t egyszerre figyel, és amint az egyik befejeződik, a hozzá tartozó ág kódját futtatja le, a többi Future pedig megszakad.

Feladat: indíts két sleep-elő Future-t eltérő várakozási idővel, és a select!-tel írd ki, melyik ért véget előbb.

Megoldás
use std::time::Duration;

async fn fast() -> &'static str {
    tokio::time::sleep(Duration::from_millis(20)).await;
    "gyors"
}

async fn slow() -> &'static str {
    tokio::time::sleep(Duration::from_millis(200)).await;
    "lassú"
}

#[tokio::main]
async fn main() {
    tokio::select! {
        result = fast() => {
            println!("Előbb ez futott be: {result}");
        }
        result = slow() => {
            println!("Előbb ez futott be: {result}");
        }
    }

    println!("A select! után folytatódik a program.");
}

A példában a fast() fut be előbb, ezért a slow() Future futása megszakad — soha nem is íródik ki a "lassú" szöveg. Ez különösen hasznos időtúllépések (timeout) kezelésénél: egy select!-ben versenyeztetheted a valódi munkát egy sleep-pel, és ha a sleep fut be előbb, tudod, hogy túllépted az időkorlátot.

Figyelem

A select! ágakban használt Future-öknek Unpin-eknek kell lenniük, vagy Box::pin-nel rögzítve kell átadni őket, ha valamiért nem azok. A leckében szereplő async fn-ek automatikusan megfelelnek ennek, így ezzel most nem kell foglalkoznod — csak jegyezd meg, hogy bonyolultabb esetekben előjöhet.

Teljes referencia-megoldás

Az alábbi program egyben tartalmazza mindegyik lépés lényegét: taskok indítása spawn-nal, eredmények visszavárása JoinHandle-lel, két Future egyidejű várakoztatása join!-lel, és egy verseny lezárása select!-tel.

use std::time::Duration;

async fn work(id: u32) -> u32 {
    tokio::time::sleep(Duration::from_millis(30 * id as u64)).await;
    id * 10
}

async fn fetch_number() -> i32 {
    tokio::time::sleep(Duration::from_millis(30)).await;
    7
}

async fn fetch_message() -> String {
    tokio::time::sleep(Duration::from_millis(60)).await;
    String::from("kész vagyok")
}

async fn fast() -> &'static str {
    tokio::time::sleep(Duration::from_millis(20)).await;
    "gyors"
}

async fn slow() -> &'static str {
    tokio::time::sleep(Duration::from_millis(200)).await;
    "lassú"
}

#[tokio::main]
async fn main() {
    // 1) Több task indítása és összegyűjtése
    let mut handles = Vec::new();
    for id in 1..=3 {
        handles.push(tokio::spawn(async move { work(id).await }));
    }

    let mut sum = 0;
    for handle in handles {
        sum += handle.await.expect("a task ne paniceljon");
    }
    println!("Taskok összesített eredménye: {sum}");

    // 2) Két eltérő típusú Future egyidejű várakoztatása
    let (number, message) = tokio::join!(fetch_number(), fetch_message());
    println!("join! eredménye: {number} / {message}");

    // 3) Az elsőként befutó Future kiválasztása
    tokio::select! {
        result = fast() => println!("select! győztese: {result}"),
        result = slow() => println!("select! győztese: {result}"),
    }
}

Ez a program egyetlen #[tokio::main] alatt bemutatja mind a négy fő eszközt, amelyeket ma megtanultál. Futtasd le, és próbáld megváltoztatni a sleep időtartamokat — figyeld meg, hogyan változik a kimenet sorrendje.

Bónusz-kihívások

  • Módosítsd a taskokat indító ciklust úgy, hogy minden task egy Result<u32, String>-et adjon vissza (pl. az id > 100 esetén hibát), és a fő függvényben számold össze, hány task sikeres és hány hibás.
  • Írj egy select!-es megoldást, amely egy valódi munkát végző Future-t versenyeztet egy tokio::time::sleep-pel mint időtúllépéssel, és ha a sleep győz, írj ki egy "időtúllépés" hibaüzenetet a println!-nel.

Összefoglalás és mi jön legközelebb

Ma megtanultad, hogyan indítja el a #[tokio::main] a Tokio runtime-ot, hogyan indítasz konkurrens taskokat tokio::spawn-nal, hogyan várod vissza az eredményüket JoinHandle-lel, és hogyan várakoztatsz vagy versenyeztetsz több Future-t a join! és select! makrókkal. Ezek az alapkövei minden komolyabb async Rust programnak.

Amit még nem érintettünk: hogyan kommunikálnak egymással a taskok futás közben, ahelyett hogy csak a végén adnának vissza egy értéket. A következő leckében ezt vesszük górcső alá: megismerkedünk a csatornákkal (mpsc és oneshot), amelyekkel a taskok üzeneteket küldhetnek egymásnak, amíg futnak.