Lesson 72 of 94
Async Streams
An Iterator produces values one by one, and each next() returns immediately. A Stream is the async version: each next() is a future, because the next value may not exist yet. A page of results from an API, a message from a socket, a tick from a timer: all naturally streams.
| Concept | Rust |
|---|---|
| synchronous sequence | Iterator<Item = T> |
| asynchronous sequence | Stream<Item = T> |
| map, filter, take | stream.map(...), filter, take (same names as Iterator) |
| concurrent mapping | stream.map(f).buffer_unordered(n) |
| sequential async mapping | stream.then(f) |
| batching | stream.chunks(n) |
| merging | futures::stream::select(a, b) |
| backpressure | built in: a stream only produces when polled |
Stream isn't in the standard library yet. The ecosystem uses the Stream trait from the futures crate, with extension methods from futures::StreamExt (and a few extras in tokio_stream::StreamExt). All examples use futures, tokio, and tokio-stream.
1. Consuming a stream
stream::iter turns any iterator into a stream. next().await returns Some(item) or None when the stream ends, so the basic loop is while let Some(x) = stream.next().await.
use futures::StreamExt;
use futures::stream;
#[tokio::main]
async fn main() {
let mut orders = stream::iter(vec!["order-1", "order-2", "order-3"]);
while let Some(order) = orders.next().await {
println!("processing {order}");
}
}processing order-1
processing order-2
processing order-3There's no for loop over streams yet (an async for syntax is being designed), so while let is the idiom.
2. Adapters look just like Iterator
map, filter, take, skip, enumerate, and collect behave like their iterator namesakes. Note that filter's predicate returns a future (it's allowed to await), so a sync condition is wrapped in async { ... } or std::future::ready(...).
use futures::StreamExt;
use futures::stream;
use std::future::ready;
#[tokio::main]
async fn main() {
let prices: Vec<String> = stream::iter(1..=10)
.filter(|n| ready(n % 2 == 0))
.map(|n| n * 150)
.take(3)
.map(|cents| format!("${}.{:02}", cents / 100, cents % 100))
.collect()
.await;
println!("{prices:?}");
}["$3.00", "$6.00", "$9.00"]3. then: an async map
map takes a normal function. When each item needs async work (a lookup, a network call), use then, whose closure returns a future. Items are processed one at a time, in order.
use futures::StreamExt;
use futures::stream;
use std::time::Duration;
async fn lookup_name(user_id: u32) -> String {
tokio::time::sleep(Duration::from_millis(10)).await;
format!("user-{user_id}")
}
#[tokio::main]
async fn main() {
let names: Vec<String> = stream::iter([3, 1, 2]).then(lookup_name).collect().await;
println!("{names:?}");
}["user-3", "user-1", "user-2"]4. filter_map with async work
filter_map's closure returns a future of Option: Some(x) keeps (a transformed) item, None drops it. It's filtering and mapping in one step, with async allowed.
use futures::StreamExt;
use futures::stream;
async fn fetch_email(user_id: u32) -> Option<String> {
tokio::task::yield_now().await; // database lookup
match user_id {
1 => Some(String::from("ana@example.com")),
3 => Some(String::from("cy@example.com")),
_ => None, // no email on file
}
}
#[tokio::main]
async fn main() {
let emails: Vec<String> = stream::iter(1..=4).filter_map(fetch_email).collect().await;
println!("{emails:?}");
}["ana@example.com", "cy@example.com"]5. fold and for_each
fold reduces a stream to one value with an async step; for_each runs an async action for every item. Both return a future that completes when the stream ends.
use futures::StreamExt;
use futures::stream;
#[derive(Debug, Default)]
struct Totals {
count: u32,
cents: u64,
}
#[tokio::main]
async fn main() {
let payments = stream::iter([("ana", 2500u64), ("ben", 1200), ("ana", 800)]);
let totals = payments
.clone()
.fold(Totals::default(), |mut acc, (_, cents)| async move {
acc.count += 1;
acc.cents += cents;
acc
})
.await;
println!("{totals:?}");
payments
.for_each(|(who, cents)| async move {
println!("receipt sent to {who} for {cents}");
})
.await;
}Totals { count: 3, cents: 4500 }
receipt sent to ana for 2500
receipt sent to ben for 1200
receipt sent to ana for 8006. Streams are lazy and pull-based
Like iterators, streams do nothing until something polls them, and a stream produces its next item only when the consumer asks for it. That's backpressure for free: a slow consumer automatically slows the producer. stream::unfold builds a stream from a state and an async step function, and the log shows the interleaving.
use futures::StreamExt;
use futures::stream;
#[tokio::main]
async fn main() {
let numbers = stream::unfold(1, |n| async move {
if n > 3 {
return None; // end of stream
}
println!(" producing {n}");
Some((n, n + 1)) // (item to yield, next state)
});
println!("stream created, nothing produced yet");
let mut numbers = Box::pin(numbers);
while let Some(n) = numbers.next().await {
println!("consumed {n}");
}
}stream created, nothing produced yet
producing 1
consumed 1
producing 2
consumed 2
producing 3
consumed 3(The Box::pin is explained in example 22.)
7. unfold for pagination
A paginated API is the classic stream: fetch a page, yield its items, remember the next cursor, stop when there's no next page. unfold holds the cursor as its state, and flat_map flattens the pages into individual items.
use futures::StreamExt;
use futures::stream;
struct Page {
items: Vec<String>,
next: Option<u32>,
}
async fn fetch_page(cursor: u32) -> Page {
tokio::task::yield_now().await; // HTTP GET /users?cursor=...
let items = (cursor..cursor + 3).map(|i| format!("user{i}")).collect();
Page { items, next: if cursor < 6 { Some(cursor + 3) } else { None } }
}
fn all_users() -> impl futures::Stream<Item = String> {
stream::unfold(Some(0), |cursor| async move {
let page = fetch_page(cursor?).await; // `?` on None ends the stream
Some((page.items, page.next))
})
.flat_map(stream::iter)
}
#[tokio::main]
async fn main() {
let users: Vec<String> = all_users().collect().await;
println!("{} users: {users:?}", users.len());
}9 users: ["user0", "user1", "user2", "user3", "user4", "user5", "user6", "user7", "user8"]The caller sees a flat stream of users and never deals with cursors.
8. Stopping early saves work
Because streams are lazy, take(n) or take_while stops pulling as soon as it has enough, and later pages are never fetched.
use futures::StreamExt;
use futures::stream;
use std::sync::atomic::{AtomicU32, Ordering};
static PAGES_FETCHED: AtomicU32 = AtomicU32::new(0);
async fn fetch_page(n: u32) -> Vec<u32> {
PAGES_FETCHED.fetch_add(1, Ordering::Relaxed);
tokio::task::yield_now().await;
(n * 10..n * 10 + 10).collect()
}
#[tokio::main]
async fn main() {
let first_12: Vec<u32> = stream::iter(0..1000) // up to 1000 pages available
.then(fetch_page)
.flat_map(stream::iter)
.take(12)
.collect()
.await;
println!("{first_12:?}");
println!("pages fetched: {}", PAGES_FETCHED.load(Ordering::Relaxed));
}[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
pages fetched: 29. Other ways to create streams
futures::stream has constructors for the common cases: once (one item from a future), repeat_with (endless, from a closure), empty, and iter. chain concatenates streams.
use futures::StreamExt;
use futures::stream;
#[tokio::main]
async fn main() {
let header = stream::once(async { String::from("id,name") });
let mut next_id = 0;
let rows = stream::repeat_with(move || {
next_id += 1;
format!("{next_id},user{next_id}")
})
.take(3);
let footer = stream::empty::<String>();
let csv: Vec<String> = header.chain(rows).chain(footer).collect().await;
println!("{}", csv.join("\n"));
}id,name
1,user1
2,user2
3,user310. A channel as a stream
tokio_stream::wrappers::ReceiverStream turns an mpsc::Receiver into a stream, so everything produced by other tasks can flow through stream adapters.
use futures::StreamExt;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
#[tokio::main]
async fn main() {
let (tx, rx) = mpsc::channel(8);
tokio::spawn(async move {
for temp in [21.5, 22.0, 35.2, 21.8, 40.1] {
tx.send(temp).await.unwrap();
}
});
let alerts: Vec<String> = ReceiverStream::new(rx)
.enumerate()
.filter(|(_, t)| std::future::ready(*t > 30.0))
.map(|(i, t)| format!("reading #{i}: {t} C is too hot"))
.collect()
.await;
println!("{alerts:#?}");
}[
"reading #2: 35.2 C is too hot",
"reading #4: 40.1 C is too hot",
]11. A timer as a stream
IntervalStream wraps tokio::time::interval. Combined with take and zip, periodic work becomes a stream pipeline.
use futures::StreamExt;
use futures::stream;
use std::time::Duration;
use tokio_stream::wrappers::IntervalStream;
#[tokio::main]
async fn main() {
let ticks = IntervalStream::new(tokio::time::interval(Duration::from_millis(20)));
let jobs = stream::iter(["backup", "vacuum", "reindex"]);
// One job per tick: zip pairs them up and ends with the shorter stream.
let log: Vec<String> = jobs.zip(ticks).enumerate().map(|(i, (job, _tick))| format!("tick {i}: run {job}")).collect().await;
println!("{log:#?}");
}[
"tick 0: run backup",
"tick 1: run vacuum",
"tick 2: run reindex",
]12. buffered: concurrent, in order
then processes one item at a time. To have several futures in flight, map each item to a future and apply buffered(n): up to n run concurrently, and results come out in the original order.
use futures::StreamExt;
use futures::stream;
use std::time::{Duration, Instant};
async fn fetch(id: u32) -> String {
tokio::time::sleep(Duration::from_millis(100 - id as u64 * 20)).await; // later ids are faster
format!("item-{id}")
}
#[tokio::main]
async fn main() {
let start = Instant::now();
let items: Vec<String> = stream::iter(0..4).map(fetch).buffered(4).collect().await;
println!("{items:?}");
println!("concurrent (under 200 ms): {}", start.elapsed() < Duration::from_millis(200));
}["item-0", "item-1", "item-2", "item-3"]
concurrent (under 200 ms): true13. buffer_unordered: concurrent, as they finish
buffer_unordered(n) also runs up to n futures at once, but yields each result as soon as it's ready. It's the most common way to limit concurrency over a list of work.
use futures::StreamExt;
use futures::stream;
use std::time::Duration;
async fn fetch(id: u32) -> String {
tokio::time::sleep(Duration::from_millis(100 - id as u64 * 30)).await; // later ids are faster
format!("item-{id}")
}
#[tokio::main]
async fn main() {
let items: Vec<String> = stream::iter(0..4).map(fetch).buffer_unordered(4).collect().await;
println!("{items:?}");
}["item-3", "item-2", "item-1", "item-0"]14. Limiting concurrency with buffer_unordered
With a limit smaller than the number of items, at most n futures run at the same time. This example tracks the peak to show it.
use futures::StreamExt;
use futures::stream;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
static IN_FLIGHT: AtomicUsize = AtomicUsize::new(0);
static PEAK: AtomicUsize = AtomicUsize::new(0);
async fn call_api(id: u32) -> u32 {
let now = IN_FLIGHT.fetch_add(1, Ordering::SeqCst) + 1;
PEAK.fetch_max(now, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(10)).await;
IN_FLIGHT.fetch_sub(1, Ordering::SeqCst);
id * 2
}
#[tokio::main]
async fn main() {
let results: Vec<u32> = stream::iter(1..=20).map(call_api).buffer_unordered(4).collect().await;
println!("{} results, sum {}", results.len(), results.iter().sum::<u32>());
println!("peak concurrency: {}", PEAK.load(Ordering::SeqCst));
}20 results, sum 420
peak concurrency: 4This is usually simpler than a Semaphore when the work is a list of items.
15. for_each_concurrent
When you don't need results back, for_each_concurrent(limit, f) runs f for every item with at most limit in flight.
use futures::StreamExt;
use futures::stream;
use std::sync::Mutex;
use std::time::Duration;
#[tokio::main]
async fn main() {
let sent = Mutex::new(Vec::new());
stream::iter(["ana", "ben", "cy", "dee", "eve"])
.for_each_concurrent(2, |user| {
let sent = &sent;
async move {
tokio::time::sleep(Duration::from_millis(10)).await; // send a newsletter
sent.lock().unwrap().push(user);
}
})
.await;
let mut sent = sent.into_inner().unwrap();
sent.sort();
println!("newsletter sent to {sent:?}");
}newsletter sent to ["ana", "ben", "cy", "dee", "eve"]16. Batching with chunks
chunks(n) groups items into Vecs of up to n (the last one may be shorter). It's how you turn a stream of rows into batch inserts.
use futures::StreamExt;
use futures::stream;
async fn insert_batch(rows: Vec<u32>) -> usize {
tokio::task::yield_now().await; // INSERT ... VALUES (...), (...), ...
println!("inserted batch {rows:?}");
rows.len()
}
#[tokio::main]
async fn main() {
let inserted: usize = stream::iter(1..=8).chunks(3).then(insert_batch).fold(0, |a, n| async move { a + n }).await;
println!("total rows: {inserted}");
}inserted batch [1, 2, 3]
inserted batch [4, 5, 6]
inserted batch [7, 8]
total rows: 817. chunks_timeout: batch by size or time
For live streams, waiting to fill a batch could take forever. tokio_stream::StreamExt::chunks_timeout(n, duration) emits a batch when it's full or when the time runs out, whichever comes first.
use std::time::Duration;
use tokio::sync::mpsc;
use tokio_stream::StreamExt;
use tokio_stream::wrappers::ReceiverStream;
#[tokio::main]
async fn main() {
let (tx, rx) = mpsc::channel(16);
tokio::spawn(async move {
for i in 1..=4 {
tx.send(i).await.unwrap(); // a burst of 4...
}
tokio::time::sleep(Duration::from_millis(150)).await; // ...then a pause...
tx.send(5).await.unwrap(); // ...then one more
});
let batches = ReceiverStream::new(rx).chunks_timeout(3, Duration::from_millis(50));
tokio::pin!(batches);
while let Some(batch) = batches.next().await {
println!("flush {batch:?}");
}
}flush [1, 2, 3]
flush [4]
flush [5]futures::StreamExt and tokio_stream::StreamExt both define next, map, and friends, so import only the one you need in a given module to avoid ambiguity.
18. Merging streams
futures::stream::select(a, b) merges two streams, yielding items from whichever has one ready. select_all does the same for a list of streams.
use futures::StreamExt;
use futures::stream::{self, select, select_all};
use std::time::Duration;
fn sensor(name: &'static str, every_ms: u64, count: usize) -> impl futures::Stream<Item = String> {
stream::iter(0..count).then(move |i| async move {
tokio::time::sleep(Duration::from_millis(every_ms)).await;
format!("{name}:{i}")
})
}
#[tokio::main]
async fn main() {
let mut merged: Vec<String> = select(Box::pin(sensor("fast", 10, 3)), Box::pin(sensor("slow", 25, 2))).collect().await;
merged.sort();
println!("{merged:?}");
let all = select_all(["a", "b", "c"].map(|n| Box::pin(sensor(n, 5, 2))));
println!("{} readings from 3 sensors", all.count().await);
}["fast:0", "fast:1", "fast:2", "slow:0", "slow:1"]
6 readings from 3 sensors19. take_while, skip_while, and take_until
take_while ends the stream at the first item that fails a condition; skip_while drops items until the condition fails. take_until(future) ends the stream when some other future completes, like a shutdown signal.
use futures::StreamExt;
use futures::stream;
use std::future::ready;
use std::time::Duration;
#[tokio::main]
async fn main() {
let log = ["DEBUG boot", "DEBUG config", "INFO started", "INFO request", "ERROR crash", "INFO restart"];
let after_boot: Vec<&str> = stream::iter(log)
.skip_while(|l| ready(l.starts_with("DEBUG")))
.take_while(|l| ready(!l.starts_with("ERROR")))
.collect()
.await;
println!("{after_boot:?}");
// An endless ticker, stopped after ~55 ms by a timer.
let ticks = stream::unfold(0u32, |n| async move {
tokio::time::sleep(Duration::from_millis(20)).await;
Some((n, n + 1))
});
let seen: Vec<u32> = ticks.take_until(tokio::time::sleep(Duration::from_millis(55))).collect().await;
println!("ticks before shutdown: {seen:?}");
}["INFO started", "INFO request"]
ticks before shutdown: [0, 1]20. Timeouts and throttling
tokio_stream::StreamExt adds time-based adapters: timeout(d) yields Err(Elapsed) for an item that took too long to arrive (and keeps going), and throttle(d) spaces items at least d apart.
use std::time::{Duration, Instant};
use tokio_stream::{self as stream, StreamExt};
#[tokio::main]
async fn main() {
let slow_source = stream::iter([10u64, 80, 10]).then(|ms| async move {
tokio::time::sleep(Duration::from_millis(ms)).await;
ms
});
let results: Vec<_> = slow_source.timeout(Duration::from_millis(50)).map(|r| r.map_err(|_| "timeout")).collect().await;
println!("{results:?}");
let start = Instant::now();
let throttled: Vec<u32> = stream::iter(1..=4).throttle(Duration::from_millis(30)).collect().await;
println!("{throttled:?} took at least 90 ms: {}", start.elapsed() >= Duration::from_millis(90));
}[Ok(10), Err("timeout"), Ok(80), Ok(10)]
[1, 2, 3, 4] took at least 90 ms: trueThe timed-out item isn't lost: timeout reports that the gap was too long, then the stream continues and the late item arrives next.
21. Fallible streams: TryStreamExt
A stream of Results is a TryStream. TryStreamExt adds methods that work on the Ok values and stop at the first Err: map_ok, try_filter, and_then, and try_collect, which gives Result<Vec<T>, E>.
use futures::TryStreamExt;
use futures::stream;
async fn parse_line(line: &str) -> Result<(String, u32), String> {
let (name, qty) = line.split_once(',').ok_or(format!("no comma in {line:?}"))?;
let qty = qty.trim().parse().map_err(|_| format!("bad quantity in {line:?}"))?;
Ok((name.to_string(), qty))
}
async fn load(lines: Vec<&str>) -> Result<Vec<String>, String> {
stream::iter(lines.into_iter().map(Ok::<&str, String>)) // a stream of Result
.and_then(parse_line)
.try_filter(|(_, qty)| std::future::ready(*qty > 0))
.map_ok(|(name, qty)| format!("{qty} x {name}"))
.try_collect()
.await
}
#[tokio::main]
async fn main() {
println!("{:?}", load(vec!["pen, 3", "ink, 0", "paper, 10"]).await);
println!("{:?}", load(vec!["pen, 3", "ink; 2", "paper, 10"]).await);
}Ok(["3 x pen", "10 x paper"])
Err("no comma in \"ink; 2\"")try_collect stops at the first Err: in the second call, paper is never parsed.
22. Pinning: when next() needs pin!
next() requires the stream to be Unpin (safe to move while being polled). Streams built from async blocks, like unfold or then, usually aren't, and calling next() on them directly fails:
use futures::StreamExt;
use futures::stream;
#[tokio::main]
async fn main() {
let mut countdown = stream::unfold(3, |n| async move { if n == 0 { None } else { Some((n, n - 1)) } });
while let Some(n) = countdown.next().await {
println!("{n}");
}
}The compiler's message is error[E0277]: `{async block}` cannot be unpinned, followed by a long chain of notes ending in "consider using the pin! macro".
Pin it first: std::pin::pin! (or tokio::pin!) pins it on the stack, Box::pin on the heap. Consuming adapters like collect, fold, and for_each take the stream by value and don't need this.
use futures::StreamExt;
use futures::stream;
use std::pin::pin;
#[tokio::main]
async fn main() {
let mut countdown = pin!(stream::unfold(3, |n| async move { if n == 0 { None } else { Some((n, n - 1)) } }));
while let Some(n) = countdown.next().await {
println!("{n}...");
}
println!("liftoff");
}3...
2...
1...
liftoff23. Returning streams from functions
Return impl Stream<Item = T> for a single concrete stream type. When different branches build different streams, box them into one type: BoxStream<'a, T> from futures (with the .boxed() method).
use futures::stream::{self, BoxStream, StreamExt};
fn numbers(kind: &str) -> BoxStream<'static, u32> {
match kind {
"evens" => stream::iter((0..10).filter(|n| n % 2 == 0)).boxed(),
"squares" => stream::iter(1..=4).map(|n| n * n).boxed(),
_ => stream::empty().boxed(),
}
}
#[tokio::main]
async fn main() {
for kind in ["evens", "squares", "other"] {
let v: Vec<u32> = numbers(kind).collect().await;
println!("{kind}: {v:?}");
}
}evens: [0, 2, 4, 6, 8]
squares: [1, 4, 9, 16]
other: []A BoxStream is Unpin, so next() works on it directly.
24. Implementing Stream by hand
Like a hand-written Future, a hand-written Stream implements one method, poll_next, which returns Poll::Ready(Some(item)), Poll::Ready(None) at the end, or Poll::Pending. This one yields every line of a text, a little like BufReader::lines.
use futures::{Stream, StreamExt};
use std::pin::Pin;
use std::task::{Context, Poll};
struct Lines {
text: String,
pos: usize,
}
impl Stream for Lines {
type Item = String;
fn poll_next(mut self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Option<String>> {
if self.pos >= self.text.len() {
return Poll::Ready(None);
}
let rest = &self.text[self.pos..];
let end = rest.find('\n').map_or(rest.len(), |i| i);
let line = rest[..end].to_string();
self.pos += end + 1;
Poll::Ready(Some(line)) // always ready: the data is in memory
}
}
#[tokio::main]
async fn main() {
let lines = Lines { text: String::from("GET /\nPOST /login\nGET /cart"), pos: 0 };
let posts: Vec<String> = lines.filter(|l| std::future::ready(l.starts_with("POST"))).collect().await;
println!("{posts:?}");
}["POST /login"]In practice you rarely write poll_next yourself: unfold, channels with ReceiverStream, and adapters cover most needs.
25. Realistic: a paginated search with early exit
A search API returns results page by page. The caller wants the first 5 results with a score above a threshold. Thanks to laziness, the pipeline stops fetching as soon as it has 5.
use futures::StreamExt;
use futures::stream;
use std::future::ready;
use std::sync::atomic::{AtomicU32, Ordering};
static REQUESTS: AtomicU32 = AtomicU32::new(0);
#[derive(Debug)]
struct Hit {
doc: String,
score: u32,
}
async fn search_page(query: &str, page: u32) -> Option<Vec<Hit>> {
REQUESTS.fetch_add(1, Ordering::Relaxed);
tokio::task::yield_now().await;
if page >= 10 {
return None; // no more pages
}
Some((0..4).map(|i| Hit { doc: format!("{query}-p{page}-{i}"), score: (page * 7 + i * 13) % 100 }).collect())
}
#[tokio::main]
async fn main() {
let hits = stream::unfold(0, |page| async move { search_page("rust", page).await.map(|hits| (hits, page + 1)) })
.flat_map(stream::iter)
.filter(|h| ready(h.score >= 50))
.take(5);
let hits: Vec<Hit> = hits.collect().await;
for h in &hits {
println!("{:<12} score {}", h.doc, h.score);
}
println!("requests made: {} of 10 pages", REQUESTS.load(Ordering::Relaxed));
}rust-p2-3 score 53
rust-p3-3 score 60
rust-p4-2 score 54
rust-p4-3 score 67
rust-p5-2 score 61
requests made: 6 of 10 pages26. Realistic: downloading with bounded concurrency and error reporting
Download a list of URLs, at most 3 at a time, collecting successes and failures separately. buffer_unordered handles the concurrency; errors are just values in the stream.
use futures::StreamExt;
use futures::stream;
use std::time::Duration;
async fn download(url: &'static str) -> Result<(String, usize), String> {
tokio::time::sleep(Duration::from_millis(url.len() as u64 * 3)).await;
if url.contains("broken") {
Err(format!("{url}: 404"))
} else {
Ok((url.to_string(), url.len() * 1024))
}
}
#[tokio::main]
async fn main() {
let urls = ["/a.png", "/broken.css", "/app.js", "/logo.svg", "/broken-link", "/index.html"];
let results: Vec<Result<(String, usize), String>> = stream::iter(urls).map(download).buffer_unordered(3).collect().await;
let (ok, failed): (Vec<_>, Vec<_>) = results.into_iter().partition(Result::is_ok);
let mut ok: Vec<(String, usize)> = ok.into_iter().map(Result::unwrap).collect();
ok.sort();
let mut failed: Vec<String> = failed.into_iter().map(Result::unwrap_err).collect();
failed.sort();
let total: usize = ok.iter().map(|(_, bytes)| bytes).sum();
println!("downloaded {} files, {} KiB", ok.len(), total / 1024);
println!("failed: {failed:?}");
}downloaded 4 files, 33 KiB
failed: ["/broken-link: 404", "/broken.css: 404"]27. Realistic: a sensor pipeline
Two sensors feed readings through channels. The pipeline merges them, drops invalid readings, groups them into windows of 4, and computes an average per window.
use futures::StreamExt;
use futures::stream::select;
use std::future::ready;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
fn spawn_sensor(name: &'static str, readings: Vec<f64>) -> ReceiverStream<(&'static str, f64)> {
let (tx, rx) = mpsc::channel(8);
tokio::spawn(async move {
for r in readings {
tx.send((name, r)).await.unwrap();
tokio::task::yield_now().await;
}
});
ReceiverStream::new(rx)
}
#[tokio::main]
async fn main() {
let a = spawn_sensor("north", vec![20.0, 21.0, -999.0, 22.0, 23.0]);
let b = spawn_sensor("south", vec![19.0, 18.0, 17.0, -999.0]);
let windows: Vec<(usize, f64)> = select(a, b)
.filter(|(_, v)| ready(*v > -100.0)) // -999 means "sensor error"
.map(|(_, v)| v)
.chunks(4)
.map(|w| (w.len(), w.iter().sum::<f64>() / w.len() as f64))
.collect()
.await;
let readings: usize = windows.iter().map(|(n, _)| n).sum();
let overall = windows.iter().map(|(n, avg)| *n as f64 * avg).sum::<f64>() / readings as f64;
println!("{} windows, {readings} valid readings", windows.len());
println!("overall average: {overall:.2}");
}2 windows, 7 valid readings
overall average: 20.00Individual window averages depend on how the two sensors' readings interleave, so this prints only order-independent results.
28. Iterators, streams, and futures side by side
| one value | many values | |
|---|---|---|
| sync | T |
Iterator<Item = T> |
| async | Future<Output = T> |
Stream<Item = T> |
| Task | Iterator | Stream |
|---|---|---|
| get next | it.next() |
s.next().await |
| transform | map |
map (sync) / then (async) |
| filter | `filter( | x |
| reduce | fold |
fold with an async closure |
| collect | collect() |
collect().await |
| run many at once | rayon's par_iter |
buffered(n) / buffer_unordered(n) |
Summary
- A
Streamis an asyncIterator:next().awaityieldsSome(item)orNone. Usefutures::StreamExt(plustokio_stream::StreamExtfor time-based adapters). - Create streams with
stream::iter,unfold(stateful producers like pagination),once,repeat_with,ReceiverStream, andIntervalStream. - Adapters mirror
Iterator:map,filter,take,fold,collect. Async steps usethen,filter_map,for_each. - Streams are lazy and pull-based, so
takestops upstream work and slow consumers get backpressure automatically. buffered(n)(ordered) andbuffer_unordered(n)(as-completed) run futures concurrently with a limit;for_each_concurrentwhen you need no results.chunks,chunks_timeout,select,zip,take_until,timeout, andthrottlehandle batching, merging, and time.TryStreamExtworks on streams ofResult. Pin streams withpin!before callingnext()when they aren'tUnpin; returnimpl StreamorBoxStream.
Next: How Async Works: Futures, Pin & Wakers, what happens under async and .await.
