Amikor valaki átvált szinkron Rustról async kódra, hamar szembesül azzal, hogy a hibakezelés minden korábbi jó szokása továbbra is érvényes — csak épp task-határok, spawn-ok és Future-ök között kell átvezetni. Ez a cikk végigmegy azon, hogyan használd hatékonyan a ? operátort async fn-ekben, hogyan egységesítsd a hibatípusokat az anyhow crate-tel, és hogyan kezeld a panic-ot a hibáktól elkülönítve.
A ? operátor működése async fn-ekben
Az async fn valójában egy Future-t visszaadó függvény, amit a fordító alakít állapotgéppé. A ? operátor pontosan ugyanúgy működik benne, mint egy szinkron fn-ben: ha a kifejezés Err-t ad vissza, azonnal visszatér a hívó felé a hibával, korai return-nel. A lényeg, hogy ez a mechanizmus teljesen független attól, hogy .await-elünk-e valamit előtte vagy sem.
use std::fmt;
#[derive(Debug)]
enum FetchError {
Network(String),
Parse(String),
}
impl fmt::Display for FetchError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
FetchError::Network(e) => write!(f, "hálózati hiba: {e}"),
FetchError::Parse(e) => write!(f, "parse hiba: {e}"),
}
}
}
impl std::error::Error for FetchError {}
async fn fetch_and_parse(url: &str) -> Result<u32, FetchError> {
let body = fake_network_call(url)
.await
.map_err(|e| FetchError::Network(e.to_string()))?;
let value: u32 = body
.trim()
.parse()
.map_err(|_| FetchError::Parse(body.clone()))?;
Ok(value)
}
async fn fake_network_call(_url: &str) -> Result<String, std::io::Error> {
Ok("42".to_string())
}
Figyeld meg, hogy a ? az .await után szerepel — ez a szokásos minta. Az .await? kombináció annyit jelent: várd meg a Future-t, majd ha Err, propagáld azonnal.
Ha egy async blokkban (async move { ... }) használsz ?-t, a blokk visszatérési típusának is Result-nak kell lennie, egyébként a fordító típushibát dob. Ez gyakori kezdő buktató tokio::spawn belsejében.
Hibatípusok egységesítése az anyhow crate segítségével
A fenti FetchError teljesen rendben van egy kis, önálló modulban, de amint több modulból hívogatod egymást — network, DB, fájlrendszer, szerializáció —, a hibatípusok kombinálása gyorsan fájdalmassá válik. Itt jön képbe az anyhow.
Az anyhow::Error egy típustörlő (type-erased) hibakonténer, ami bármilyen std::error::Error + Send + Sync + 'static implementáló típust be tud csomagolni. Ez ideális application-szintű kódhoz (nem library kódhoz — ott inkább a thiserror-ral definiált saját enumok a jó választás, mert azok megőrzik a típusinformációt a hívó számára).
[dependencies]
tokio = { version = "1", features = ["full"] }
anyhow = "1.0"
use anyhow::{Context, Result};
async fn read_config(path: &str) -> Result<String> {
let content = tokio::fs::read_to_string(path)
.await
.with_context(|| format!("nem sikerült beolvasni a konfigot: {path}"))?;
Ok(content)
}
async fn parse_port(config: &str) -> Result<u16> {
let port: u16 = config
.trim()
.parse()
.context("a port érték nem szám")?;
Ok(port)
}
#[tokio::main]
async fn main() -> Result<()> {
let config = read_config("port.txt").await?;
let port = parse_port(&config).await?;
println!("A szerver a {port} porton indul.");
Ok(())
}
Az anyhow::Result<T> valójában Result<T, anyhow::Error> alias, így minden ?-operátoros láncolás automatikusan konvertál bármilyen konkrét hibatípust anyhow::Error-ré, amíg az implementálja az Error traitet. A .context() és .with_context() metódusok pedig egy emberi olvasható üzenetet fűznek a hibalánchoz, ami hibakereséskor aranyat ér.
A main függvény visszatérési típusaként simán használhatsz anyhow::Result<()>-t — ha a program hibával áll le, az Error: ... formátumban íródik ki, a teljes context-lánccal együtt.
Hibaterjedés több async task között
Amikor tokio::spawn-nal indítasz egy taskot, az visszaad egy JoinHandle<T>-t. Ez a T a task belső Result-ja lehet, de van egy második hibaréteg is: maga a JoinHandle::await is Result-t ad vissza, ami akkor Err, ha a task panickolt vagy megszakadt (cancelled).
Ez azt jelenti, hogy egy taskból induló hibát kétszer kell kezelni: egyszer a task belső logikájában, egyszer pedig a join szinten.
use anyhow::{anyhow, Result};
async fn worker(id: u32) -> Result<u32> {
if id == 3 {
return Err(anyhow!("a 3-as worker sosem szeret dolgozni"));
}
Ok(id * 10)
}
#[tokio::main]
async fn main() -> Result<()> {
let mut handles = Vec::new();
for id in 0..5 {
handles.push(tokio::spawn(worker(id)));
}
for handle in handles {
match handle.await {
Ok(Ok(value)) => println!("worker eredmény: {value}"),
Ok(Err(e)) => eprintln!("worker hiba: {e:#}"),
Err(join_err) => eprintln!("a task megszakadt vagy panickolt: {join_err}"),
}
}
Ok(())
}
A handle.await típusa itt Result<Result<u32, anyhow::Error>, JoinError> — innen a duplán egymásba ágyazott match. Az {e:#} formázás az anyhow::Error esetén a teljes context-láncot kiírja, nem csak a legfelső üzenetet.
Ha egy tokio::spawn-nal indított taskban panic történik, az alapértelmezett runtime beállítás mellett nem áll le az egész program — a panic elszigetelődik a taskra, és a JoinHandle::await Err(JoinError)-t ad vissza. Ez jó hír robusztusság szempontjából, de könnyű elfelejteni a hívó oldali kezelést.
Ha csatorna (tokio::sync::mpsc) segítségével kommunikálsz taskok között, érdemes a hibát is végigküldeni az üzenettípusban, például egy Result<Msg, anyhow::Error> csatornán, hogy a fogadó oldal döntsön a kezelésről.
Panic és hiba közötti különbség async kontextusban
Fontos élesen elválasztani két fogalmat:
- Hiba (
Result::Err): várható, kezelhető állapot — hálózati timeout, hibás input, fájl nem található. Ezt propagáljuk?-vel, és a hívó eldönti, mit kezd vele. - Panic: programozói hiba vagy helyrehozhatatlan invariáns-sérülés — index out of bounds,
unwrap()egyNone-on, integer overflow debug módban. Ez nemResult, hanem a végrehajtás megszakítása.
Async kontextusban a panic különösen alattomos tud lenni, mert egy tokio::spawn-nal indított task panicja nem dobja fel magát a hívó .await pontján kivételként, hanem a JoinError-on keresztül jelenik meg, ahogy fentebb láttuk. Ha valaki elfelejti kezelni a JoinHandle eredményét (let _ = tokio::spawn(...)), a panic csendben eltűnik.
#[tokio::main]
async fn main() {
let handle = tokio::spawn(async {
let v: Vec<i32> = vec![];
v[0] // ez panickolni fog
});
if let Err(e) = handle.await {
eprintln!("a task panickolt: {e}");
}
}
Soha ne használj unwrap()-et vagy expect()-et olyan async kódban, ahol az input külső forrásból (hálózat, felhasználó, fájl) érkezik. Ezek helyén mindig ? és megfelelő hibatípus legyen — a panic legyen fenntartva valóban helyrehozhatatlan, programozói hibás állapotokra.
Best practice-ek production kódhoz
Összefoglalva néhány gyakorlati szabály, amit érdemes követni:
- Library kódban használj
thiserror-ral definiált konkrét enumokat, hogy a hívó tudjon típus szerintmatch-elni a hibákra. - Application kódban (binary crate,
main.rs) használjanyhow::Result-ot mindenhol, és fűzz.context()-et minden?elé, ahol az segít a hibakeresésben. tokio::spawneredményét mindig kezeld le — vagy.await-elj rá és dolgozd fel aResult<Result<T, E>, JoinError>-t, vagy explicit módon jelezd, ha szándékosan eldobod (let _handle = ...legalább kommenttel).- Loggolj hibát a határon, ne minden szinten — ha egy hiba végigfut több híváson
?-vel, elég egyetlen helyen (pl. a HTTP handler tetején) logolni a teljes context-láncot{:#}-vel. - Ne keverd a panicot és a hibát — ha egy függvény visszatérési típusa
Result, soseunwrap()-elj benne olyan értéket, ami külső inputból származik. - Cancel-safe kód írása — async függvényekben legyél tudatában annak, hogy egy
.awaitpont megszakadhat (pl.tokio::select!miatt), ezért kerüld a félbehagyott állapotmódosítást a hiba-útvonalakon.
use anyhow::{Context, Result};
async fn handle_request(id: u32) -> Result<String> {
let data = fetch_from_db(id)
.await
.with_context(|| format!("DB lekérdezés sikertelen id={id}"))?;
Ok(format!("Feldolgozva: {data}"))
}
async fn fetch_from_db(_id: u32) -> Result<String, std::io::Error> {
Ok("minta adat".to_string())
}
Összefoglalás
Az async Rust hibakezelése nem bonyolultabb a szinkronnál, csak több réteget kell egyszerre fejben tartani: a ? operátor logikája változatlan, de a tokio::spawn és a JoinHandle egy extra hibaszintet vezet be. Az anyhow crate remekül old meg minden application-szintű hibatípus-egyesítést, míg a panic és a hiba közti határvonalat tudatosan kell meghúznod — a Result a várt, kezelhető állapotokra való, a panic pedig a valóban helyrehozhatatlan hibákra. Ha ezeket a mintákat követed, a production async Rust kódod sokkal átláthatóbb és karbantarthatóbb lesz.