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 fnmaga is egyFuture, 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 aJoinHandle-ö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.
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.
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>.
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(())
}
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(())
}
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áljDrop-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 csaklet _ = 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!vsselect!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.