A Tokio az egyik legelterjedtebb async runtime a Rust ökoszisztémában, de a legtöbben csak a felszínt látják belőle: #[tokio::main], tokio::spawn, és kész is. Pedig a motorháztető alatt egy komoly, work-stealing alapú ütemező dolgozik, ami rengeteg okos trükköt vet be, hogy a taskjaid a lehető leghatékonyabban fussanak. Nézzük meg részletesen, hogyan működik ez a gépezet.
Single-threaded vs multi-threaded runtime
A Tokio kétféle runtime-ot kínál, és ez a választás alapvetően meghatározza, hogyan viselkedik az alkalmazásod.
A current-thread runtime egyetlen szálon fut. Ez azt jelenti, hogy minden task ugyanazon a szálon van ütemezve, nincs szükség szinkronizációra a taskok között, és az overhead minimális. Ez ideális kis alkalmazásokhoz, tesztekhez, vagy olyan esetekhez, ahol tudod, hogy nem lesz sok párhuzamos munka.
#[tokio::main(flavor = "current_thread")]
async fn main() {
println!("Egyetlen szálon futok!");
}
A multi-threaded runtime ezzel szemben egy szálpoolt hoz létre — alapértelmezetten annyi szálat, ahány logikai CPU magod van. Itt már megjelenik a work-stealing scheduler, ami a taskokat elosztja a szálak között.
#[tokio::main]
async fn main() {
// ez alapból multi_thread flavor-t használ
println!("Több szálon futok!");
}
A #[tokio::main] makró alapból a multi-threaded runtime-ot indítja el. Ha explicit módon szeretnéd megadni, írhatod úgy is, hogy #[tokio::main(flavor = "multi_thread", worker_threads = 4)].
A kérdés, hogy melyiket válaszd, attól függ, mit csinál az alkalmazásod. Ha rengeteg párhuzamos I/O-t végzel — sok TCP kapcsolat, sok fájlművelet —, a multi-threaded runtime jobban kihasználja a hardvert. Ha viszont egy egyszerű CLI eszközt írsz, ami néhány async hívást tesz meg, a current-thread variáns kevesebb overhead-del jár.
Work-stealing scheduler egyszerűen elmagyarázva
A multi-threaded runtime szíve a work-stealing scheduler. Az alapötlet egyszerű: minden worker szálnak van egy saját lokális taskqueue-ja, plusz létezik egy globális queue is.
Amikor egy worker szál végez a saját feladataival, és üres a lokális sora, nem áll le tétlenül. Ehelyett "lop" egy taskot valamelyik másik worker szál sorából — innen a work-stealing elnevezés. Ez a mechanizmus biztosítja, hogy a terhelés kiegyensúlyozott maradjon anélkül, hogy egy központi ütemezőnek folyamatosan figyelnie kéne minden szálat.
A folyamat nagyjából így néz ki:
- Egy worker szál a saját lokális queue-jából próbál taskot venni.
- Ha ez üres, megnézi a globális queue-t.
- Ha az is üres, körbenéz a többi worker szál lokális queue-jánál, és lop belőlük egy taskot (vagy néhányat egyszerre, batch-ben).
- Ha sehol sincs munka, a szál rövid ideig "parkol", hogy ne pörgesse feleslegesen a CPU-t.
A work-stealing algoritmus nem véletlenszerűen választ áldozatot: a Tokio randomizált sorrendben próbálja meg a többi szálat megnézni, hogy elkerülje a rendszeres versenyhelyzeteket (contention) ugyanazon a queue-n.
Ez a megközelítés kulcsfontosságú, mert az async taskok futásideje rendkívül változó lehet — egy task lehet, hogy mikroszekundumok alatt lefut, egy másik pedig percekig várakozik I/O-ra. A work-stealing dinamikusan kiegyenlíti ezt a terhelést anélkül, hogy statikus particionálásra lenne szükség.
Mi történik egy tokio::spawn hívás mögött
Amikor meghívod a tokio::spawn-t, a Tokio nem indít új operációs rendszerbeli szálat — ez egy gyakori tévhit. Ehelyett egy zöld szálnak (green thread) is nevezhető, könnyűsúlyú task jön létre, amit a runtime scheduler kezel.
use tokio::time::{sleep, Duration};
#[tokio::main]
async fn main() {
let handle = tokio::spawn(async {
sleep(Duration::from_millis(100)).await;
println!("Kész a task!");
42
});
let result = handle.await.unwrap();
println!("Eredmény: {}", result);
}
A tokio::spawn valójában a következőket teszi:
- Becsomagolja az async blokkot egy
Future-be, majd egyTaskstruktúrába, ami tartalmazza a futásállapotot, a waker mechanizmust és a heap-en allokált adatot. - Ezt a taskot beteszi a hívó szál lokális queue-jába (ha egy worker szálról hívtad), vagy a globális queue-ba (ha runtime-on kívülről, pl. egy külső szálról).
- A scheduler előbb-utóbb felveszi a taskot, és elkezdi pollozni — azaz meghívja rajta a
pollmetódust, ami vagyPoll::Ready-t ad vissza (kész), vagyPoll::Pending-et (még várakozik valamire, pl. I/O-ra). - Ha
Pending, a task regisztrál egy wakert, ami majd értesíti a runtime-ot, amikor újra pollozható.
Ez a poll-alapú modell az, ami lehetővé teszi, hogy egyetlen szálon több ezer taskot is kezelhess egyidejűleg, hiszen a taskok nem "foglalnak" szálat, amíg várakoznak — csak akkor futnak, amikor tényleg van dolguk.
A spawn-nal létrehozott task 'static élettartamú kell legyen, mert a runtime nem tudja garantálni, hogy a hívó függvény stack frame-je még létezik, amikor a task fut. Emiatt gyakran kell Arc-ba csomagolni a megosztott adatokat.
Blokkoló kód hatása a runtime-ra és a spawn_blocking szerepe
Ez talán a legfontosabb rész, amit minden Tokio-t használó fejlesztőnek meg kell értenie: soha ne blokkolj egy async függvényen belül.
Ha egy worker szálon futó async kódba beleírsz egy std::thread::sleep-et, egy szinkron fájlműveletet, vagy egy nehéz CPU-igényes számítást, azzal blokkolod az egész worker szálat. Mivel a work-stealing scheduler ugyanazon a szálon próbálja pollozni a többi taskot is, a blokkolt szál nem tud továbblépni — minden rajta lévő task megreked, amíg a blokkoló művelet be nem fejeződik.
#[tokio::main]
async fn main() {
tokio::spawn(async {
// TILOS! Ez blokkolja a worker szálat
std::thread::sleep(std::time::Duration::from_secs(5));
});
// Ez a task lehet, hogy nem fut le időben,
// ha a fenti ugyanarra a szálra kerül
tokio::spawn(async {
println!("Szia!");
});
}
Erre a problémára ad megoldást a spawn_blocking, ami egy külön, erre a célra fenntartott szálpoolra teszi át a blokkoló kódot, így a worker szálak szabadon maradnak az async taskok pollozására.
use tokio::task;
#[tokio::main]
async fn main() {
let result = task::spawn_blocking(|| {
// ide kerülhet fájl I/O, nehéz számítás, stb.
std::thread::sleep(std::time::Duration::from_secs(2));
"kész a blokkoló munka"
})
.await
.unwrap();
println!("{}", result);
}
A spawn_blocking mögött egy dinamikusan növekvő szálpool áll, amit a Tokio szükség szerint bővít (alapértelmezetten akár 512 szálig). Ezek a szálak nem részei a work-stealing schedulernek — kifejezetten arra vannak fenntartva, hogy blokkoló műveleteket futtassanak, anélkül hogy az async ökoszisztémát megzavarnák.
A CPU-igényes, de nem I/O jellegű munkára (pl. nagy adathalmaz feldolgozása, kriptográfiai számítás) szintén érdemes spawn_blocking-ot használni, mert ezek is "blokkolják" a poll ciklust ugyanúgy, mint egy szinkron I/O hívás.
Gyakorlati példa a runtime konfigurálására
Ha nem a #[tokio::main] makróra hagyatkozol, hanem manuálisan szeretnéd felépíteni a runtime-ot — például mert finomhangolni akarod a worker szálak számát vagy a blocking pool méretét —, a tokio::runtime::Builder API-t használhatod.
use tokio::runtime::Builder;
fn main() {
let runtime = Builder::new_multi_thread()
.worker_threads(4)
.max_blocking_threads(16)
.thread_name("sajat-worker")
.enable_all()
.build()
.expect("nem sikerült létrehozni a runtime-ot");
runtime.block_on(async {
let handle = tokio::spawn(async {
println!("Task fut a kézzel épített runtime-on!");
});
handle.await.unwrap();
});
}
A worker_threads beállítja a work-stealing pool méretét, a max_blocking_threads pedig felülírja a blocking pool alapértelmezett maximumát. Az enable_all() bekapcsolja az I/O és időzítő driver-eket, amik nélkül nem működnének a hálózati vagy sleep-hez hasonló funkciók.
Ha csak egy funkciócsoportra van szükséged (pl. csak I/O-ra, timer nélkül), használhatod az enable_io() vagy enable_time() metódusokat külön-külön, hogy csökkentsd a runtime memória- és inicializálási overhead-jét.
Ez a manuális megközelítés különösen hasznos könyvtárfejlesztőknél, ahol nem akarod rákényszeríteni a felhasználóra, hogy a #[tokio::main] makrót használja, vagy amikor több, elkülönített runtime-ot szeretnél futtatni ugyanabban a folyamatban.
Összefoglalás
A Tokio runtime egy jól átgondolt, work-stealing alapú ütemezőre épül, ami lehetővé teszi, hogy több ezer könnyűsúlyú taskot hatékonyan kezeljünk kevés operációs rendszerbeli szálon. A tokio::spawn mögött egy poll-alapú Future-kezelés áll, ahol a taskok csak akkor "aktívak", amikor tényleg van dolguk. A legfontosabb szabály, amit érdemes megjegyezni: a blokkoló kód az async világ ellensége — mindig a spawn_blocking-ot használd, ha elkerülhetetlen egy szinkron, hosszan futó művelet. Ha ezeket az alapelveket megérted, sokkal tudatosabban tudod megtervezni és hibakeresni az async Rust alkalmazásaidat.