Ha már írtál async Rust kódot tokio-val, biztosan találkoztál azzal az érzéssel, hogy "minden szépen fut, amíg egyszer nem". Egy lassú DNS feloldás, egy leszakadt TCP kapcsolat vagy egy soha nem válaszoló külső API mind olyan helyzet, amit timeout és megszakítás nélkül simán túlélhetetlenné tehet egy szervert. Ma megnézzük, hogyan kezeld ezt profin.

Miért kritikus a timeout kezelés hálózati alkalmazásokban

Aszinkron Rust-ban egy Future alapból türelmes: addig vár, amíg valaki lehívja (poll-ozza), és amíg az alatta lévő I/O nem jelez készenlétet. Ez remek, amikor minden jól működik, de katasztrofális, ha egy remote szerver egyszerűen nem válaszol. Ilyenkor a taskod örökre a .await ponton ragad, a hozzá tartozó erőforrások (socket, buffer, memória) pedig sosem szabadulnak fel.

Gondolj bele: ha egy webszervered minden bejövő kérésre elindít egy backend hívást, és ezek közül csak néhány akad el örökre, néhány óra alatt felemészthetik a rendelkezésre álló file descriptorokat vagy memóriát. Ez a klasszikus "slow drain" probléma, ami production incidensek egyik leggyakoribb oka.

Jó tudni

A timeout nem luxus, hanem alapvető védelmi mechanizmus. Minden külső hívás körül legyen egy explicit felső korlát — még akkor is, ha az adott API dokumentációja szerint "gyors".

A tokio::time::timeout lépésről lépésre

A tokio::time::timeout a legegyszerűbb eszköz erre a problémára. Egy Future-t és egy Duration-t vár, és ha a megadott idő alatt nem fejeződik be a future, egy Elapsed hibával tér vissza — a belső future pedig automatikusan drop-olódik, ami megszakítja a futását.

use std::time::Duration;
use tokio::time::timeout;

async fn fetch_data() -> Result<String, std::io::Error> {
    // Szimulált lassú hívás
    tokio::time::sleep(Duration::from_secs(5)).await;
    Ok("kész adat".to_string())
}

#[tokio::main]
async fn main() {
    let result = timeout(Duration::from_secs(2), fetch_data()).await;

    match result {
        Ok(Ok(data)) => println!("Siker: {data}"),
        Ok(Err(e)) => println!("Belső hiba: {e}"),
        Err(_) => println!("Timeout: a hívás túl sokáig tartott"),
    }
}

Fontos megérteni, hogy a timeout maga is egy Future, ami a belső future-t poll-ozza, és ha a lejárati idő eltelik, egyszerűen nem poll-ozza tovább, hanem drop-olja. Ez azért működik, mert a Rust future-ök cooperatívak: a drop hatására minden hozzájuk kötött erőforrás (pl. egy TcpStream) is felszabadul, feltéve hogy az implementáció helyesen kezeli a Drop trait-et.

Tipp

A timeout visszatérési típusa Result<T, Elapsed>, ahol T a belső future eredménye. Ez dupla Result-ot ad, ha a belső future is Result-tal tér vissza — ezt a mintát a fenti kódban is látod.

Feladatok megszakítása CancellationToken-nel

A timeout remek egyszerű esetekre, de mi van, ha egy hosszabb életű task-ot (pl. egy háttérben futó worker loopot) szeretnél külső eseményre megszakítani? Erre a tokio-util crate CancellationToken típusa a bevált eszköz.

A CancellationToken egy klónozható handle, amit átadhatsz több task-nak is. Amikor bárki meghívja rajta a .cancel()-t, minden klón értesül róla, és a .cancelled() future felébred.

use tokio_util::sync::CancellationToken;
use std::time::Duration;

async fn worker(token: CancellationToken) {
    loop {
        tokio::select! {
            _ = token.cancelled() => {
                println!("Worker: megszakítást kaptam, leállok");
                break;
            }
            _ = tokio::time::sleep(Duration::from_secs(1)) => {
                println!("Worker: dolgozom...");
            }
        }
    }
}

#[tokio::main]
async fn main() {
    let token = CancellationToken::new();
    let worker_token = token.clone();

    let handle = tokio::spawn(worker(worker_token));

    tokio::time::sleep(Duration::from_secs(3)).await;
    token.cancel();

    handle.await.unwrap();
}

A lényeg, hogy a CancellationToken egy kooperatív mechanizmus: a worker feladatnak magának kell figyelnie a .cancelled() jelzést és értelmesen reagálnia rá. Ez sokkal biztonságosabb, mint egy taskot erővel .abort()-tal megszakítani, mert így a worker maga dönthet arról, hogy éppen milyen cleanup lépéseket kell elvégeznie leállás előtt.

Megjegyzés

A CancellationToken nem a tokio core része, hanem a tokio-util crate-ben található (tokio_util::sync::CancellationToken). Ne keverd össze a tokio::task::JoinHandle::abort()-tal, ami egy sokkal drasztikusabb, nem kooperatív megszakítás.

select! kombinálása timeout logikával

A tokio::select! makró és a timeout kombinálása különösen hasznos, amikor egyszerre több eseményre kell figyelned: érkezhet adat, érkezhet cancellation jelzés, vagy egyszerűen lejárhat egy határidő. A select! az első bekövetkező eseményt választja ki, a többi branch future-jét pedig eldobja.

use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;
use std::time::Duration;

async fn process_requests(
    mut rx: mpsc::Receiver<String>,
    token: CancellationToken,
) {
    loop {
        tokio::select! {
            _ = token.cancelled() => {
                println!("Leállítási kérés érkezett, feldolgozás vége");
                return;
            }
            maybe_msg = rx.recv() => {
                let Some(msg) = maybe_msg else {
                    println!("Csatorna lezárva");
                    return;
                };
                println!("Feldolgozva: {msg}");
            }
            _ = tokio::time::sleep(Duration::from_secs(10)) => {
                println!("10 másodpercig nem jött üzenet, kilépek");
                return;
            }
        }
    }
}

Ebben a példában a let ... else szintaxist is használtam a csatorna lezárásának kezelésére — ez a Rust 1.65 óta stabil feature nagyon jól illik az early-return mintákhoz, és jóval olvashatóbb, mint egy beágyazott match.

Tipp

Ha egy select! branch-ben timeout-ot használsz, minden ciklusban új sleep-et kell létrehoznod, különben az első lejárat után folyamatosan azonnal lejár majd. Érdemes inkább tokio::time::interval vagy Instant alapú számítást használni, ha ismétlődő timeoutra van szükséged.

Gyakori hibák: elfelejtett cancellation és erőforrás-szivárgás

A timeout és cancellation kezelés legtöbb hibája nem a szintaxisból, hanem a gondolkodásból fakad. Nézzünk néhány tipikus csapdát.

1. A cancellation token elfelejtett klónozása. Ha egy CancellationToken-t átadsz egy taskba, de elfelejtesz klónozni belőle egy másikat a fő szálon, akkor nem lesz módod később megszakítani a taskot — a token move-olódik, és elveszíted a kontrollt fölötte.

2. Drop nem elég cleanup-hoz. A timeout és a select! mindkettő drop-olja a nem-választott future-öket, de a drop csak azt garantálja, hogy a Rust szintű erőforrások (memória, handle-ök) felszabadulnak — nem garantálja, hogy egy külső rendszer (pl. egy adatbázis szerver) is tudja, hogy a kapcsolat megszakadt. Ha egy tranzakciót indítottál, és a timeout közben szakítja meg, érdemes explicit rollback logikát írni, ne bízz a drop-ban.

3. Erőforrás-szivárgás spawn-olt taskoknál. Ha egy tokio::spawn-nal indított taskot timeout véd, de a task belsejében maga is spawn-ol további taskokat, a timeout csak a legfelső future-t szakítja meg — a beágyazott spawn-olt taskok tovább futhatnak! Erre pontosan a CancellationToken propagálása a megoldás: add át a tokent minden gyerek tasknak is.

use tokio_util::sync::CancellationToken;

async fn parent_task(token: CancellationToken) {
    let child_token = token.child_token();
    let child = tokio::spawn(async move {
        tokio::select! {
            _ = child_token.cancelled() => {
                println!("Gyerek task megszakítva");
            }
            _ = tokio::time::sleep(std::time::Duration::from_secs(30)) => {
                println!("Gyerek task befejeződött");
            }
        }
    });

    tokio::select! {
        _ = token.cancelled() => {
            println!("Szülő task megszakítva, propagálom a gyereknek");
        }
    }

    let _ = child.await;
}

A child_token() metódus egy hierarchikus cancellation struktúrát tesz lehetővé: ha a szülő token cancel-elődik, minden belőle származó gyerek token is automatikusan cancel-elődik. Ez production kódban rendkívül hasznos, amikor egy komplex workflow-t kell egyszerre leállítani.

Figyelem

A JoinHandle::abort() látszólag egyszerűbb megoldás megszakításra, de figyelmen kívül hagy minden cleanup logikát a task belsejében — a task egyszerűen megszűnik a soron következő .await pontnál, futás közben. Production kódban ezt csak végső eszközként használd, kooperatív cancellation helyett.

Összefoglalás

A timeout és cancellation kezelés nem opcionális extra egy async Rust alkalmazásban, hanem alapvető architektúrai döntés. A tokio::time::timeout egyszerű, egyszeri hívásokra ideális, míg a tokio_util::sync::CancellationToken komplex, több taskot érintő megszakítási logikához nyújt kooperatív, biztonságos megoldást. A select! makróval ezeket rugalmasan kombinálhatod, de mindig figyelj arra, hogy a nem-választott branch-ek erőforrásai valóban felszabaduljanak-e, és hogy a cancellation valóban végigpropagálódjon minden gyerek taskra. Ha ezeket a mintákat beépíted a mindennapi kódodba, sokkal ellenállóbb és production-ready alkalmazásokat fogsz írni.