Ha async Rustot írsz, egy ponton biztosan szembejön a kérdés: hogyan üzenjenek egymásnak a task-ok anélkül, hogy Mutex-eket dobálnál mindenhova? A válasz szinte mindig valamilyen csatorna. A tokio::sync modul három fő típust ad a kezünkbe: mpsc, oneshot és broadcast. Ezek mindegyike más problémára optimális, és pontosan ez a cikk célja: megmutatni, melyiket mikor húzd elő a szerszámosládából.
A cikk a tokio async runtime-ra épül. Ha még nem használtad, a Cargo.toml-ban elég egy tokio = { version = \"1\", features = [\"full\"] } sor, és már mehet is a #[tokio::main].
Mikor melyik csatornát válasszuk?
Mielőtt belevágnánk a kódba, tisztázzuk a döntési fát:
mpsc(multi-producer, single-consumer): sok task küld üzenetet, egy task fogadja. Ez a leggyakoribb minta – gondolj egy worker pool-ra, ahol a munkát sorba állítod.oneshot: egyetlen üzenet, egyetlen küldő, egyetlen fogadó. Tipikusan akkor kell, amikor egy task elindít egy másikat, és vár egy "eredményre" – ez a request-response minta async világban.broadcast: egy küldő, sok fogadó, és mindenki megkapja az összes üzenetet. Ilyenkor gondolj egy event bus-ra, ahol több komponens is figyeli ugyanazt az eseményfolyamot.
Fontos: ezek nem versenyeznek egymással, hanem kiegészítik egymást. Egy komolyabb async rendszerben simán előfordul, hogy mindhármat használod egyszerre.
mpsc: a klasszikus producer-consumer
Az mpsc::channel a leggyakrabban használt csatorna típus. A channel(buffer_size) függvény egy (Sender<T>, Receiver<T>) páros ad vissza. A Sender klónozható, tehát több task is küldhet rajta, de csak egy Receiver létezhet.
use tokio::sync::mpsc;
#[derive(Debug)]
struct Job {
id: u32,
payload: String,
}
async fn worker(mut rx: mpsc::Receiver<Job>) {
while let Some(job) = rx.recv().await {
println!("Feldolgozás: {} -> {}", job.id, job.payload);
}
println!("Nincs több küldő, a worker leáll.");
}
#[tokio::main]
async fn main() {
let (tx, rx) = mpsc::channel::<Job>(32);
let handle = tokio::spawn(worker(rx));
for i in 0..5 {
let tx = tx.clone();
tokio::spawn(async move {
let job = Job { id: i, payload: format!("munka-{i}") };
if tx.send(job).await.is_err() {
eprintln!("A receiver már bezárt");
}
});
}
// Fontos: a fő tx-et is drop-oljuk, különben a receiver sosem áll le
drop(tx);
handle.await.unwrap();
}
Ami itt kulcsfontosságú: a Receiver::recv() None-t ad vissza, amikor minden Sender klón elszáll (drop-olódik). Ez az a pont, ahol a legtöbb kezdő elakad – ha az eredeti tx-et nem drop-olod, a worker sosem lát None-t, és a program örökre a recv().await-en fog várni.
Ha tx.clone()-t adsz át egy spawnolt task-nak, az a klón a task végén automatikusan drop-olódik. De az eredeti tx változót, amit main-ben tartasz, neked kell explicit drop-olni, vagy egyszerűen ki kell futnia a scope-jából.
oneshot: egyszeri válaszok
A oneshot::channel pontosan egy értéket enged átküldeni, aztán a csatorna "elhasználódik". Ez tökéletes arra, amikor egy task elindít egy műveletet egy másik task-on, és megvárja az eredményt – klasszikus request-response minta, csak async formában.
use tokio::sync::oneshot;
async fn compute_answer(tx: oneshot::Sender<i32>) {
let result = 21 * 2;
// A send() nem async, azonnal visszatér
let _ = tx.send(result);
}
#[tokio::main]
async fn main() {
let (tx, rx) = oneshot::channel::<i32>();
tokio::spawn(compute_answer(tx));
match rx.await {
Ok(value) => println!("Megkaptam: {value}"),
Err(_) => println!("A küldő elszállt válasz nélkül"),
}
}
Ez a minta rendkívül hasznos, ha van egy szervíz task-od (pl. egy adatbázis-kapcsolatot kezelő worker), amely mpsc-n kap parancsokat, és minden parancshoz egy oneshot::Sender-t csatolsz, hogy a válasz pontosan visszajusson a hívóhoz.
use tokio::sync::{mpsc, oneshot};
enum Command {
Get { key: String, resp: oneshot::Sender<Option<String>> },
}
async fn store_actor(mut rx: mpsc::Receiver<Command>) {
let mut data = std::collections::HashMap::new();
data.insert("foo".to_string(), "bar".to_string());
while let Some(cmd) = rx.recv().await {
match cmd {
Command::Get { key, resp } => {
let value = data.get(&key).cloned();
let _ = resp.send(value);
}
}
}
}
Ez a "actor" minta – ahol egyetlen task birtokolja az állapotot, és mpsc + oneshot kombóval kommunikálsz vele – sokkal jobban skálázódik, mint egy közös Mutex<HashMap<...>>, mert nincs lock contention, csak üzenetküldés.
broadcast: több feliratkozóval
Ha több task-nak is szüksége van ugyanarra az információra – például egy konfigurációváltozás eseményére, vagy egy "shutdown" jelzésre –, a broadcast::channel a barátod. Minden Receiver, amit a subscribe() hívással kapsz, megkapja az összes üzenetet a saját feliratkozása pillanatától kezdve.
use tokio::sync::broadcast;
#[tokio::main]
async fn main() {
let (tx, _rx) = broadcast::channel::<String>(16);
for id in 0..3 {
let mut rx = tx.subscribe();
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(msg) => println!("[feliratkozó {id}] üzenet: {msg}"),
Err(broadcast::error::RecvError::Lagged(n)) => {
eprintln!("[feliratkozó {id}] {n} üzenetet elveszítettünk!");
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
});
}
tx.send("szerver újraindul".to_string()).unwrap();
tx.send("konfiguráció frissült".to_string()).unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
Érdemes megjegyezni, hogy a broadcast csatornának is van buffer mérete, de ez másképp viselkedik, mint az mpsc-nél: ha egy feliratkozó lassabb, mint az üzenetek érkezési sebessége, és a buffer megtelik, a legrégebbi üzeneteket a csatorna eldobja az adott feliratkozó számára, és a RecvError::Lagged(n) hibát kapod meg n elveszett üzenettel. Ez nem crash, csak figyelmeztetés – neked kell eldönteni, mit teszel vele.
Backpressure és buffer méret kezelése
A buffer méret megválasztása nem csak technikai apróság, hanem architekturális döntés. Nézzük a lehetőségeket:
- Kis buffer (pl. 1-8): a
send().awaitgyakran blokkol, amíg a fogadó fel nem szabadít helyet. Ez erős backpressure-t jelent – a küldő task lelassul, ha a fogadó nem tud lépést tartani. Ez sokszor kívánatos, mert megakadályozza, hogy a memória felduzzadjon. - Nagy buffer: rugalmasabb, kevesebb blokkolás, de ha a fogadó tartósan lemarad, a memóriahasználat elszabadulhat, és az egész rendszer instabillá válhat.
Az mpsc::channel-nél a send().await blokkol (async módon, tehát nem foglalja a szálat, csak a task-ot suspendálja), ha a buffer tele van. Ha ehelyett nem-blokkoló küldést akarsz, van try_send(), amely azonnal hibát ad vissza, ha nincs hely.
Egy gyakori hiba, hogy valaki mpsc::channel(10000)-et választ "biztonság kedvéért", majd csodálkozik, amikor a program megeszik fél gigabájt RAM-ot terhelés alatt. A helyes megközelítés: mérd meg, mekkora a tipikus terhelés, és válassz egy olyan buffer méretet, amely még kezelhető torlódást enged, de nem hagyja elszabadulni a memóriát.
use tokio::sync::mpsc;
#[tokio::main]
async fn main() {
let (tx, mut rx) = mpsc::channel::<u64>(4);
let producer = tokio::spawn(async move {
for i in 0..10 {
match tx.try_send(i) {
Ok(_) => println!("elküldve: {i}"),
Err(mpsc::error::TrySendError::Full(val)) => {
println!("telítve, kihagyva: {val}");
}
Err(mpsc::error::TrySendError::Closed(_)) => break,
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
});
while let Some(val) = rx.recv().await {
println!("fogadva: {val}");
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
producer.await.unwrap();
}
Ebben az esetben a producer gyorsabb, mint a consumer, és a try_send segítségével explicit módon dönt arról, mi történjen, ha nincs hely – eldobja az üzenetet, helyette akár logolhat, metrikát növelhet, vagy másik stratégiát is választhatna (pl. várakozás helyett egy másik csatornára irányítás).
Összefoglalás
Ha összefoglalnám egy mondatban: mpsc a munkaelosztásra, oneshot a válaszokra, broadcast az értesítésekre. A három csatorna típus kombinálásával rendkívül tiszta, actor-szerű architektúrákat tudsz felépíteni Rust async kódban, Mutex és megosztott állapot nélkül. A buffer méret és a backpressure kezelése pedig nem mellékes részlet – ez dönti el, hogy a rendszered kontrolláltan lassul le terhelés alatt, vagy egyszerűen felfalja a memóriát. Következő lépésként érdemes megnézni a tokio::sync::watch csatornát is, amely egy speciális eset: csak a legutolsó értéket tartja meg, ami konfigurációk vagy állapot-jelzők terjesztésére ideális – de ez már egy másik cikk témája.