Miért nehezebb a hibakezelés async kontextusban

Szinkron Rust-ban a hibakezelés viszonylag egyszerű: egy függvény visszaad egy Result<T, E>-t, a hívó pedig a ? operátorral tovább dobja, vagy lekezeli. Async kontextusban ugyanez a minta alapvetően működik, de van néhány extra réteg, ami megnehezíti a dolgunkat:

  • Egy async fn maga is egy Future, aminek a kiértékelése elhalasztott. A hiba csak akkor "keletkezik", amikor a future-t valaki .await-eli.
  • Ha taskokat tokio::spawn-nal indítasz, azok saját, független futásúak lesznek — egy panic bennük nem omlasztja össze a fő szálat, hanem a JoinHandle-ön keresztül kell lekérdezni.
  • Párhuzamos várakozásnál (join!, select!) a hibák több forrásból is jöhetnek egyszerre, és el kell dönteni, hogy melyiket részesítjük előnyben, illetve mi történjen a többivel.
Megjegyzés

Az async/await maga nem vezet be új hibatípusokat a nyelvbe — a Result és a ? operátor pontosan ugyanúgy működik async fn-ekben, mint szinkron függvényekben. A nehézség inkább az architektúrában rejlik: honnan, mikor és hova kell eljuttatni a hibát.

A ? operátor használata async fn-ekben

Jó hír: a ? operátor teljesen természetesen viselkedik async fn-ekben. Ha a függvényed Result-ot ad vissza, a ?-tal ugyanúgy korai visszatérést tudsz csinálni, mint bárhol máshol.

use std::fmt;

#[derive(Debug)]
struct FetchError(String);

impl fmt::Display for FetchError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "fetch hiba: {}", self.0)
    }
}

impl std::error::Error for FetchError {}

async fn fetch_data(url: &str) -> Result<String, FetchError> {
    if url.is_empty() {
        return Err(FetchError("üres URL".into()));
    }
    // Képzeletbeli hálózati hívás
    Ok(format!("adat innen: {url}"))
}

async fn process() -> Result<(), FetchError> {
    let data = fetch_data("https://example.com").await?;
    println!("Feldolgozva: {data}");
    Ok(())
}

A lényeg, hogy a ? az .await UTÁN kerül alkalmazásra — a future-t előbb ki kell értékelni, csak utána tudjuk kicsomagolni a Result-ot. Ez a sorrend (fetch_data(url).await?) az, amit meg kell szoknod: sok kezdő próbálja fordítva írni, és értetlenül áll a fordítási hiba előtt.

Tipp

Ha egy async fn main-ből hívsz ?-t, magának a main-nek is Result-ot kell visszaadnia, és #[tokio::main] attribútummal kell ellátni:

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    Ok(())
}

Hibatípusok egységesítése: anyhow és thiserror

A valós projektekben ritkán van egyetlen hibatípusod — hálózati hiba, parse hiba, IO hiba mind más-más Error implementáció. Két crate vált gyakorlatilag sztenderddé ennek kezelésére:

  • thiserror: könyvtárakhoz, ahol pontosan definiált, típusos hibákat akarsz exponálni a hívóknak.
  • anyhow: alkalmazásokhoz, ahol nem érdekel a pontos hibatípus, csak az, hogy legyen jó hibaüzenet és lehessen kontextust fűzni hozzá.
[dependencies]
thiserror = "1"
anyhow = "1"
tokio = { version = "1.0", features = ["full"] }

Egy tipikus thiserror-os hibatípus:

use thiserror::Error;

#[derive(Error, Debug)]
enum AppError {
    #[error("hálózati hiba: {0}")]
    Network(String),

    #[error("parse hiba a(z) '{0}' mezőben")]
    Parse(String),

    #[error("időtúllépés")]
    Timeout,
}

async fn download() -> Result<String, AppError> {
    Err(AppError::Timeout)
}

Amikor viszont egy alkalmazás main függvényében sok különböző forrásból jövő hibát kell egységesen kezelni, az anyhow::Result és az anyhow::Context trait életet menthet:

use anyhow::{Context, Result};

async fn read_config(path: &str) -> Result<String> {
    tokio::fs::read_to_string(path)
        .await
        .with_context(|| format!("nem sikerült beolvasni: {path}"))
}

#[tokio::main]
async fn main() -> Result<()> {
    let config = read_config("config.toml").await?;
    println!("{config}");
    Ok(())
}

Az anyhow::Error automatikusan tud konvertálni bármilyen std::error::Error-t implementáló típusból, tehát a ? operátor a thiserror-os egyedi hibáidat is simán átalakítja anyhow hibává, ha a függvény visszatérési típusa anyhow::Result<T>.

Jó tudni

A .context() és .with_context() metódusok kulcsfontosságúak: ezek segítségével emberi olvasásra alkalmas, réteges hibaüzeneteket tudsz építeni, ahelyett hogy csak egy száraz "IO error"-t kapnál a logban.

join!/select! makrók és a hibák terjedése párhuzamos taskoknál

Amikor több async műveletet futtatsz párhuzamosan, a tokio::join! makró minden future-t végigvár, és egy tuple-ben adja vissza az eredményeket — beleértve a Result-okat is, amiket neked kell lekezelned.

use anyhow::Result;

async fn fetch_user(id: u32) -> Result<String> {
    Ok(format!("user-{id}"))
}

async fn fetch_orders(id: u32) -> Result<Vec<String>> {
    Ok(vec![format!("order-{id}-1")])
}

async fn load_profile(id: u32) -> Result<()> {
    let (user, orders) = tokio::join!(fetch_user(id), fetch_orders(id));

    let user = user?;
    let orders = orders?;

    println!("{user}: {:?}", orders);
    Ok(())
}

Fontos, hogy a join! mindkét future-t lefuttatja a végéig, akkor is, ha az egyik hibát ad — nem szakítja meg a másikat. Ez sokszor pont ez a kívánt viselkedés (pl. logolás, cleanup), de ha az egyik hiba esetén azonnal le akarod állítani a másik műveletet is, a tokio::select! a barátod.

A select! az első befejeződő future eredményét adja vissza, a többit pedig eldobja (drop-olja):

use tokio::time::{sleep, Duration};
use anyhow::Result;

async fn slow_operation() -> Result<&'static str> {
    sleep(Duration::from_secs(5)).await;
    Ok("kész")
}

async fn timeout_guard() -> Result<()> {
    tokio::select! {
        result = slow_operation() => {
            match result {
                Ok(val) => println!("eredmény: {val}"),
                Err(e) => println!("hiba: {e}"),
            }
        }
        _ = sleep(Duration::from_secs(1)) => {
            println!("időtúllépés, megszakítjuk");
        }
    }
    Ok(())
}
Figyelem

A select!-ben eldobott future-ök nem kapnak esélyt "tisztán" lezárulni — ha a future-öd valamilyen erőforrást tart (pl. fájl handle-t vagy mutex lock-ot), célszerű Drop implementációval gondoskodni a takarításról, mert a hirtelen megszakítás async kontextusban nem egyenértékű egy szinkron return-nel.

tokio::spawn és a panic-ok kezelése JoinHandle-lel

Ha tokio::spawn-nal indítasz egy taskot, az egy önálló, a fő végrehajtási szálaktól elszigetelt egységgé válik. Ha a taskban panic történik, az NEM omlasztja össze a teljes programot — a panic elkapásra kerül a Tokio runtime által, és a JoinHandle::await egy Err(JoinError)-t ad vissza.

use anyhow::Result;

async fn risky_task() -> u32 {
    panic!("valami nagyon elromlott");
}

#[tokio::main]
async fn main() -> Result<()> {
    let handle = tokio::spawn(risky_task());

    match handle.await {
        Ok(value) => println!("eredmény: {value}"),
        Err(join_err) if join_err.is_panic() => {
            eprintln!("a task panic-olt: {join_err}");
        }
        Err(join_err) => {
            eprintln!("a task megszakadt: {join_err}");
        }
    }

    Ok(())
}

Ha a spawn-olt task maga is Result-t ad vissza, akkor egy dupla csomagolással kell számolnod: a JoinHandle::await eredménye Result<Result<T, E>, JoinError> lesz. Ez elsőre furcsa, de logikus — a külső Result a task futásának sikerességéről (panic-olt-e vagy sem), a belső pedig a te üzleti logikád hibájáról szól.

use anyhow::Result;

async fn business_logic() -> Result<u32> {
    Ok(42)
}

#[tokio::main]
async fn main() -> Result<()> {
    let handle = tokio::spawn(business_logic());

    let value = handle.await??; // első ? a JoinError-ra, második az anyhow::Error-ra
    println!("végeredmény: {value}");

    Ok(())
}
Tipp

A handle.await?? szintaxis működik, mert az anyhow::Error implementálja a szükséges konverziókat a From trait-en keresztül — de ha egyedi thiserror-os hibatípusod van, győződj meg róla, hogy a JoinError-ból is tudsz konvertálni, különben a fordító panaszkodni fog.

Gyakorlati tippek robusztus async hibakezeléshez

  • Használj anyhow-t alkalmazás szinten, thiserror-t library szinten. Ez a kombináció a legtöbb valós projektben jól működik: a könyvtáraid típusos hibákat adnak, az alkalmazásod pedig egységesen kezeli őket.
  • Mindig gondolkodj el a select! eldobott ágain. Ha egy erőforrást tartó future-t dobsz el, implementálj Drop-ot, vagy használj explicit cleanup logikát.
  • Ne nyeld el csendben a JoinError-t. Ha egy spawn-olt task panic-ol, az valamit jelez — legalább logold ki, ne csak let _ = handle.await;-tel intézd el.
  • Fűzz kontextust a hibákhoz .context()-tel. Egy generikus "IO error" helyett sokkal hasznosabb egy "nem sikerült beolvasni a config.toml fájlt" üzenet.
  • join! vs select! tudatos választás. Ha minden művelet eredményére szükséged van, join!-t használj. Ha az első eredmény (vagy hiba) elég, és a többit meg akarod szakítani, select! a helyes eszköz.
// Egyszerű minta: több taskot indítunk, és összegyűjtjük a hibákat
use anyhow::Result;

async fn worker(id: u32) -> Result<()> {
    if id == 2 {
        anyhow::bail!("worker {id} elhasalt");
    }
    Ok(())
}

#[tokio::main]
async fn main() -> Result<()> {
    let mut handles = Vec::new();
    for id in 0..4 {
        handles.push(tokio::spawn(worker(id)));
    }

    let mut errors = Vec::new();
    for handle in handles {
        if let Err(e) = handle.await? {
            errors.push(e);
        }
    }

    if !errors.is_empty() {
        for e in &errors {
            eprintln!("hiba: {e}");
        }
        anyhow::bail!("{} worker elhasalt", errors.len());
    }

    Ok(())
}

Összefoglalás

Az async Rust hibakezelése alapjaiban ugyanazokra az eszközökre épül, mint a szinkron világ — Result, ? operátor, From konverziók —, de a taskok, a join!/select! makrók és a JoinHandle extra rétegeket adnak hozzá, amiket tudatosan kell kezelni. Az anyhow és thiserror páros szinte minden éles projektben megkönnyíti az életed: a könyvtári kód típusosan, az alkalmazás kód pedig kényelmesen kezeli a hibákat. Ha ehhez hozzáveszed a JoinError és a select! eldobott future-jeinek tudatos kezelését, már egy nagyon robusztus async hibakezelési stratégiánál tartasz.