Lesson 71 of 94
Async Traits
In Rust, you can write async fn directly in a trait. It works smoothly with generics, and it has two sharp edges that this lesson is mostly about:
Send: a generic caller can't assume the returned future isSend, so spawning it on Tokio may fail to compile. The fix is a one-line change to the trait.dyn Trait: each implementation returns a different future type, so a trait withasync fncan't be used asdyn Traitdirectly. You box the futures, by hand or with theasync-traitcrate.
If you've read the Traits and Trait Objects lessons, everything here builds on them.
| Situation | Rust |
|---|---|
| declaring an async method | trait Repo { async fn find(&self, id: u64) -> User; } |
| calling through a generic | fn f<R: Repo>(r: &R) or &impl Repo: works directly |
| a list of mixed implementations | Vec<Box<dyn Repo>>: needs boxed futures |
| one future type for everything | Pin<Box<dyn Future<Output = T> + Send>> (BoxFuture<T>) |
1. async fn in a trait
Write it like any other trait method. Implementations use async fn too.
use std::time::Duration;
trait WeatherSource {
async fn temperature(&self, city: &str) -> Option<f64>;
}
struct FakeApi;
impl WeatherSource for FakeApi {
async fn temperature(&self, city: &str) -> Option<f64> {
tokio::time::sleep(Duration::from_millis(10)).await; // network call
match city {
"Oslo" => Some(-3.5),
"Lisbon" => Some(18.0),
_ => None,
}
}
}
#[tokio::main]
async fn main() {
let api = FakeApi;
for city in ["Oslo", "Lisbon", "Atlantis"] {
println!("{city}: {:?}", api.temperature(city).await);
}
}Oslo: Some(-3.5)
Lisbon: Some(18.0)
Atlantis: None2. Using the trait through generics
Generic code over the trait works exactly like it does for normal traits: T: WeatherSource or &impl WeatherSource. Each call is statically dispatched, with no allocation.
trait WeatherSource {
async fn temperature(&self, city: &str) -> Option<f64>;
}
struct Fixed(f64);
impl WeatherSource for Fixed {
async fn temperature(&self, _city: &str) -> Option<f64> {
Some(self.0)
}
}
async fn warmest<'a>(source: &impl WeatherSource, cities: &[&'a str]) -> Option<(&'a str, f64)> {
let mut best: Option<(&str, f64)> = None;
for &city in cities {
if let Some(t) = source.temperature(city).await {
if best.is_none_or(|(_, b)| t > b) {
best = Some((city, t));
}
}
}
best
}
#[tokio::main]
async fn main() {
println!("{:?}", warmest(&Fixed(21.0), &["Rome", "Paris"]).await);
}Some(("Rome", 21.0))3. Several implementations: real and fake
The main reason to put async operations behind a trait is to swap implementations: a real HTTP client in production, an in-memory fake in tests.
use std::collections::HashMap;
use std::time::Duration;
trait ExchangeRates {
async fn rate(&self, from: &str, to: &str) -> Result<f64, String>;
}
struct HttpRates; // would call a real API
impl ExchangeRates for HttpRates {
async fn rate(&self, from: &str, to: &str) -> Result<f64, String> {
tokio::time::sleep(Duration::from_millis(20)).await;
if from == "EUR" && to == "USD" { Ok(1.08) } else { Err(format!("no quote for {from}/{to}")) }
}
}
struct FixedRates(HashMap<(&'static str, &'static str), f64>);
impl ExchangeRates for FixedRates {
async fn rate(&self, from: &str, to: &str) -> Result<f64, String> {
self.0.iter().find(|((f, t), _)| *f == from && *t == to).map(|(_, r)| *r).ok_or(String::from("missing"))
}
}
async fn convert(rates: &impl ExchangeRates, cents: u64, from: &str, to: &str) -> Result<u64, String> {
let rate = rates.rate(from, to).await?;
Ok((cents as f64 * rate).round() as u64)
}
#[tokio::main]
async fn main() {
println!("{:?}", convert(&HttpRates, 10_000, "EUR", "USD").await);
println!("{:?}", convert(&HttpRates, 10_000, "EUR", "JPY").await);
let test_rates = FixedRates(HashMap::from([(("EUR", "JPY"), 160.0)]));
println!("{:?}", convert(&test_rates, 10_000, "EUR", "JPY").await);
}Ok(10800)
Err("no quote for EUR/JPY")
Ok(1600000)4. Default async methods
Traits can provide default implementations for async methods, which can call the required ones.
trait KeyValueStore {
async fn get(&self, key: &str) -> Option<String>;
// Default method built on top of `get`.
async fn get_or(&self, key: &str, fallback: &str) -> String {
self.get(key).await.unwrap_or_else(|| fallback.to_string())
}
async fn get_many(&self, keys: &[&str]) -> Vec<Option<String>> {
let mut out = Vec::new();
for k in keys {
out.push(self.get(k).await);
}
out
}
}
struct EnvStore;
impl KeyValueStore for EnvStore {
async fn get(&self, key: &str) -> Option<String> {
match key {
"PORT" => Some(String::from("8080")),
"HOST" => Some(String::from("0.0.0.0")),
_ => None,
}
}
}
#[tokio::main]
async fn main() {
let store = EnvStore;
println!("{}", store.get_or("LOG_LEVEL", "info").await);
println!("{:?}", store.get_many(&["HOST", "PORT", "DEBUG"]).await);
}info
[Some("0.0.0.0"), Some("8080"), None]5. What async fn in a trait means
async fn name(&self) -> T in a trait is shorthand for a method returning impl Future<Output = T>. You can write that form yourself, and implementations may still use async fn.
use std::future::Future;
trait Greeter {
// Same as: async fn greet(&self, name: &str) -> String;
fn greet(&self, name: &str) -> impl Future<Output = String>;
}
struct English;
struct Spanish;
impl Greeter for English {
async fn greet(&self, name: &str) -> String {
format!("Hello, {name}")
}
}
impl Greeter for Spanish {
// Or write the long form in the impl too.
fn greet(&self, name: &str) -> impl Future<Output = String> {
let name = name.to_string();
async move { format!("Hola, {name}") }
}
}
#[tokio::main]
async fn main() {
println!("{}", English.greet("Ana").await);
println!("{}", Spanish.greet("Ana").await);
}Hello, Ana
Hola, AnaEach implementation's future is a different, compiler-generated type. That's what makes calls zero-cost, and it's also the root of both problems below.
6. Generic async methods
An async trait method can have its own type parameters, like any other method. A codec that turns any serializable value into bytes is a typical example of reusable infrastructure code:
use serde::{Deserialize, Serialize, de::DeserializeOwned};
trait Codec {
async fn encode<T: Serialize + Sync>(&self, value: &T) -> Result<Vec<u8>, String>;
async fn decode<T: DeserializeOwned>(&self, bytes: &[u8]) -> Result<T, String>;
}
struct Json;
struct PrettyJson;
impl Codec for Json {
async fn encode<T: Serialize + Sync>(&self, value: &T) -> Result<Vec<u8>, String> {
serde_json::to_vec(value).map_err(|e| e.to_string())
}
async fn decode<T: DeserializeOwned>(&self, bytes: &[u8]) -> Result<T, String> {
serde_json::from_slice(bytes).map_err(|e| e.to_string())
}
}
impl Codec for PrettyJson {
async fn encode<T: Serialize + Sync>(&self, value: &T) -> Result<Vec<u8>, String> {
serde_json::to_vec_pretty(value).map_err(|e| e.to_string())
}
async fn decode<T: DeserializeOwned>(&self, bytes: &[u8]) -> Result<T, String> {
serde_json::from_slice(bytes).map_err(|e| e.to_string())
}
}
#[derive(Debug, Serialize, Deserialize)]
struct Session {
user_id: u64,
roles: Vec<String>,
}
// Works with any codec and any serializable type.
async fn round_trip<C: Codec>(codec: &C, session: &Session) -> Result<Session, String> {
let bytes = codec.encode(session).await?;
println!("{} bytes", bytes.len());
codec.decode(&bytes).await
}
#[tokio::main]
async fn main() {
let session = Session { user_id: 7, roles: vec![String::from("admin")] };
println!("{:?}", round_trip(&Json, &session).await);
println!("{:?}", round_trip(&PrettyJson, &session).await);
println!("{:?}", Json.decode::<Session>(b"{\"user_id\": \"seven\"}").await);
}31 bytes
Ok(Session { user_id: 7, roles: ["admin"] })
48 bytes
Ok(Session { user_id: 7, roles: ["admin"] })
Err("invalid type: string \"seven\", expected u64 at line 1 column 19")Generic methods are resolved at compile time, so a trait with them can't be used as dyn Codec (see Trait Objects). Use it through generics, as round_trip does.
7. The Send problem
A generic function doesn't know which implementation it gets, so it doesn't know whether the future is Send. tokio::spawn needs Send, so this fails:
use std::sync::Arc;
trait Notifier {
async fn notify(&self, msg: String) -> bool;
}
fn notify_in_background<N: Notifier + Send + Sync + 'static>(n: Arc<N>, msg: String) {
tokio::spawn(async move {
n.notify(msg).await;
});
}
fn main() {}error: future cannot be sent between threads safely
--> src/main.rs:8:5
|
8 | / tokio::spawn(async move {
9 | | n.notify(msg).await;
10 | | });
| |______^ future created by async block is not `Send`
|
= help: within `{async block@src/main.rs:8:18: 8:28}`, the trait `Send` is not implemented for `impl Future<Output = bool>`
note: future is not `Send` as it awaits another future which is not `Send`
--> src/main.rs:9:9
|
9 | n.notify(msg).await;
| ^^^^^^^^^^^^^ await occurs here on type `impl Future<Output = bool>`, which is not `Send`
note: required by a bound in `tokio::spawn`
--> ~/.cargo/registry/src/.../tokio-1.53.1/src/task/spawn.rs:176:21
|
174 | pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
| ----- required by a bound in this function
175 | where
176 | F: Future + Send + 'static,
| ^^^^ required by this bound in `spawn`
help: `Send` can be made part of the associated future's guarantees for all implementations of `Notifier::notify`
|
4 - async fn notify(&self, msg: String) -> bool;
4 + fn notify(&self, msg: String) -> impl std::future::Future<Output = bool> + Send;
|N: Send + Sync says the notifier is thread-safe, but says nothing about the future its notify returns. An implementation might hold an Rc across an .await, and the compiler must allow for that.
8. The fix: -> impl Future + Send in the trait
Declare the method with the explicit return type and add + Send. Now every implementation must return a Send future (the compiler checks each impl), and generic callers can rely on it. Implementations can still use async fn.
use std::future::Future;
use std::sync::Arc;
use std::time::Duration;
trait Notifier {
fn notify(&self, msg: String) -> impl Future<Output = bool> + Send;
}
struct EmailNotifier {
address: String,
}
impl Notifier for EmailNotifier {
async fn notify(&self, msg: String) -> bool {
tokio::time::sleep(Duration::from_millis(10)).await;
println!("email to {}: {msg}", self.address);
true
}
}
fn notify_in_background<N: Notifier + Send + Sync + 'static>(n: Arc<N>, msg: String) -> tokio::task::JoinHandle<bool> {
tokio::spawn(async move { n.notify(msg).await })
}
#[tokio::main]
async fn main() {
let email = Arc::new(EmailNotifier { address: String::from("ops@example.com") });
let sent = notify_in_background(email, String::from("disk 90% full")).await.unwrap();
println!("sent: {sent}");
}email to ops@example.com: disk 90% full
sent: trueThis is the standard advice for library traits that users will call from multi-threaded runtimes: write fn method(...) -> impl Future<Output = T> + Send. For traits used only inside your own code with concrete types, plain async fn is fine.
9. An implementation that isn't Send is caught at the impl
With the + Send bound in the trait, a non-Send implementation is rejected where it's written, with a clear message, instead of at some distant spawn:
use std::future::Future;
use std::rc::Rc;
trait Notifier {
fn notify(&self, msg: String) -> impl Future<Output = bool> + Send;
}
struct LocalNotifier;
impl Notifier for LocalNotifier {
async fn notify(&self, msg: String) -> bool {
let log = Rc::new(msg); // Rc is not Send...
tokio::task::yield_now().await; // ...and it's held across an await
!log.is_empty()
}
}
fn main() {}error: future cannot be sent between threads safely
--> src/main.rs:11:44
|
11 | async fn notify(&self, msg: String) -> bool {
| ^^^^ future returned by `notify` is not `Send`
|
= help: within `impl Future<Output = bool>`, the trait `Send` is not implemented for `Rc<String>`
note: future is not `Send` as this value is used across an await
--> src/main.rs:13:34
|
12 | let log = Rc::new(msg); // Rc is not Send...
| --- has type `Rc<String>` which is not `Send`
13 | tokio::task::yield_now().await; // ...and it's held across an await
| ^^^^^ await occurs here, with `log` maybe used later
note: required by a bound in `Notifier::notify::{anon_assoc#0}`
--> src/main.rs:5:67
|
5 | fn notify(&self, msg: String) -> impl Future<Output = bool> + Send;
| ^^^^ required by this bound in `Notifier::notify::{anon_assoc#0}`10. dyn Trait doesn't work with async fn
Putting different implementations in one collection needs dyn Trait. But the compiler builds a vtable with one fixed signature per method, and "returns some future whose type depends on the implementation" can't be expressed that way:
trait Handler {
async fn handle(&self, path: &str) -> String;
}
struct Home;
impl Handler for Home {
async fn handle(&self, _path: &str) -> String {
String::from("home page")
}
}
fn main() {
let handlers: Vec<Box<dyn Handler>> = vec![Box::new(Home)];
println!("{}", handlers.len());
}error[E0038]: the trait `Handler` is not dyn compatible
--> src/main.rs:14:31
|
14 | let handlers: Vec<Box<dyn Handler>> = vec![Box::new(Home)];
| ^^^^^^^ `Handler` is not dyn compatible
|
note: for a trait to be dyn compatible it needs to allow building a vtable
for more information, visit <https://doc.rust-lang.org/reference/items/traits.html#dyn-compatibility>
--> src/main.rs:2:14
|
1 | trait Handler {
| ------- this trait is not dyn compatible...
2 | async fn handle(&self, path: &str) -> String;
| ^^^^^^ ...because method `handle` is `async`
= help: consider moving `handle` to another trait
= help: only type `Home` implements `Handler`; consider using it directly instead.The way to get "one type for all futures" is a boxed future.
11. Boxed futures make the trait dyn-compatible
Return Pin<Box<dyn Future<Output = T> + Send + 'a>>: a heap-allocated future behind a pointer. It has the same type for every implementation, so the trait works with dyn. Implementations wrap an async block in Box::pin.
use std::future::Future;
use std::pin::Pin;
trait Handler: Send + Sync {
fn handle<'a>(&'a self, path: &'a str) -> Pin<Box<dyn Future<Output = String> + Send + 'a>>;
}
struct Home;
struct Echo {
prefix: String,
}
impl Handler for Home {
fn handle<'a>(&'a self, _path: &'a str) -> Pin<Box<dyn Future<Output = String> + Send + 'a>> {
Box::pin(async { String::from("home page") })
}
}
impl Handler for Echo {
fn handle<'a>(&'a self, path: &'a str) -> Pin<Box<dyn Future<Output = String> + Send + 'a>> {
Box::pin(async move {
tokio::task::yield_now().await;
format!("{}{path}", self.prefix)
})
}
}
#[tokio::main]
async fn main() {
let handlers: Vec<Box<dyn Handler>> = vec![Box::new(Home), Box::new(Echo { prefix: String::from("you asked for ") })];
for h in &handlers {
println!("{}", h.handle("/docs").await);
}
}home page
you asked for /docsThe cost is one heap allocation per call.
12. BoxFuture shortens the signature
The futures crate has an alias, BoxFuture<'a, T>, for exactly that long type, and a .boxed() method (from FutureExt) instead of Box::pin.
use futures::future::{BoxFuture, FutureExt};
use std::time::Duration;
trait Storage: Send + Sync {
fn save<'a>(&'a self, key: &'a str, bytes: &'a [u8]) -> BoxFuture<'a, Result<usize, String>>;
}
struct Disk;
struct Memory;
impl Storage for Disk {
fn save<'a>(&'a self, key: &'a str, bytes: &'a [u8]) -> BoxFuture<'a, Result<usize, String>> {
async move {
tokio::time::sleep(Duration::from_millis(5)).await;
if key.contains('/') { Err(format!("disk: invalid key {key:?}")) } else { Ok(bytes.len()) }
}
.boxed()
}
}
impl Storage for Memory {
fn save<'a>(&'a self, _key: &'a str, bytes: &'a [u8]) -> BoxFuture<'a, Result<usize, String>> {
async move { Ok(bytes.len()) }.boxed()
}
}
#[tokio::main]
async fn main() {
let backends: Vec<(&str, Box<dyn Storage>)> = vec![("disk", Box::new(Disk)), ("memory", Box::new(Memory))];
for (name, backend) in &backends {
println!("{name}: {:?}", backend.save("a/b", b"hello").await);
}
}disk: Err("disk: invalid key \"a/b\"")
memory: Ok(5)13. The async-trait crate
Writing boxed signatures by hand is tedious. The widely used async-trait crate lets you write async fn and rewrites it into the boxed form for you. Put #[async_trait] on the trait and on every impl.
use async_trait::async_trait;
use std::time::Duration;
#[async_trait]
trait PaymentGateway: Send + Sync {
async fn charge(&self, cents: u64) -> Result<String, String>;
fn name(&self) -> &'static str;
}
struct Stripe;
struct PayPal;
#[async_trait]
impl PaymentGateway for Stripe {
async fn charge(&self, cents: u64) -> Result<String, String> {
tokio::time::sleep(Duration::from_millis(5)).await;
Ok(format!("ch_{cents}"))
}
fn name(&self) -> &'static str {
"stripe"
}
}
#[async_trait]
impl PaymentGateway for PayPal {
async fn charge(&self, cents: u64) -> Result<String, String> {
if cents > 50_000 { Err(String::from("amount exceeds limit")) } else { Ok(format!("PAY-{cents}")) }
}
fn name(&self) -> &'static str {
"paypal"
}
}
#[tokio::main]
async fn main() {
let gateways: Vec<Box<dyn PaymentGateway>> = vec![Box::new(Stripe), Box::new(PayPal)];
for g in &gateways {
println!("{}: {:?}", g.name(), g.charge(99_900).await);
}
}stripe: Ok("ch_99900")
paypal: Err("amount exceeds limit")[dependencies]
async-trait = "0.1"The generated futures are Send by default; #[async_trait(?Send)] drops that requirement for single-threaded use. Choose between the options like this:
| Need | Use |
|---|---|
| only generics / concrete types | native async fn in the trait |
| generics, and callers spawn the futures | fn ... -> impl Future<Output = T> + Send |
dyn Trait (plugins, collections, DI containers) |
boxed futures: #[async_trait] or BoxFuture by hand |
14. Shared services: Arc<dyn Trait>
For dependency injection across tasks, the usual shape is Arc<dyn Service>: one instance, shared by every request handler, chosen at startup.
use async_trait::async_trait;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
#[async_trait]
trait IdGenerator: Send + Sync {
async fn next_id(&self) -> String;
}
struct SequentialIds {
next: AtomicU32,
prefix: &'static str,
}
#[async_trait]
impl IdGenerator for SequentialIds {
async fn next_id(&self) -> String {
format!("{}-{:04}", self.prefix, self.next.fetch_add(1, Ordering::Relaxed))
}
}
async fn create_order(ids: Arc<dyn IdGenerator>, item: &str) -> String {
format!("{} for {item}", ids.next_id().await)
}
#[tokio::main]
async fn main() {
let ids: Arc<dyn IdGenerator> = Arc::new(SequentialIds { next: AtomicU32::new(1), prefix: "ORD" });
let mut handles = Vec::new();
for item in ["book", "lamp", "desk"] {
let ids = Arc::clone(&ids);
handles.push(tokio::spawn(async move { create_order(ids, item).await }));
}
let mut orders = Vec::new();
for h in handles {
orders.push(h.await.unwrap());
}
orders.sort(); // the tasks ran concurrently, so which item got which id varies
let ids: Vec<&str> = orders.iter().map(|o| &o[..8]).collect();
println!("{} orders with ids {ids:?}", orders.len());
}3 orders with ids ["ORD-0001", "ORD-0002", "ORD-0003"]15. Associated types in async traits
Async traits can have associated types, like any trait. A common one is the error type, so each implementation can report its own errors.
use std::fmt::Debug;
trait Fetcher {
type Error: Debug;
async fn fetch(&self, url: &str) -> Result<String, Self::Error>;
}
#[derive(Debug)]
enum HttpError {
NotFound,
Status(u16),
}
struct Http;
impl Fetcher for Http {
type Error = HttpError;
async fn fetch(&self, url: &str) -> Result<String, HttpError> {
match url {
"/ok" => Ok(String::from("<html>ok</html>")),
"/missing" => Err(HttpError::NotFound),
_ => Err(HttpError::Status(500)),
}
}
}
struct Files;
impl Fetcher for Files {
type Error = String;
async fn fetch(&self, url: &str) -> Result<String, String> {
Err(format!("no such file: {url}"))
}
}
async fn fetch_all<F: Fetcher>(f: &F, urls: &[&str]) -> Vec<Result<String, F::Error>> {
let mut out = Vec::new();
for u in urls {
out.push(f.fetch(u).await);
}
out
}
#[tokio::main]
async fn main() {
println!("{:?}", fetch_all(&Http, &["/ok", "/missing", "/boom"]).await);
println!("{:?}", fetch_all(&Files, &["/etc/app.toml"]).await);
}[Ok("<html>ok</html>"), Err(NotFound), Err(Status(500))]
[Err("no such file: /etc/app.toml")]16. &mut self in async methods
Async methods can take &mut self, which fits stateful resources like a connection or a cursor. The future borrows the object mutably until it completes.
trait Connection {
async fn execute(&mut self, sql: &str) -> Result<u64, String>;
async fn close(self) -> usize;
}
struct FakeConn {
statements: Vec<String>,
}
impl Connection for FakeConn {
async fn execute(&mut self, sql: &str) -> Result<u64, String> {
tokio::task::yield_now().await;
if sql.trim().is_empty() {
return Err(String::from("empty statement"));
}
self.statements.push(sql.to_string());
Ok(self.statements.len() as u64)
}
async fn close(self) -> usize {
self.statements.len()
}
}
async fn migrate(conn: &mut impl Connection) -> Result<(), String> {
conn.execute("CREATE TABLE users (id INT)").await?;
conn.execute("CREATE INDEX idx_id ON users (id)").await?;
Ok(())
}
#[tokio::main]
async fn main() {
let mut conn = FakeConn { statements: Vec::new() };
println!("{:?}", migrate(&mut conn).await);
println!("{:?}", conn.execute(" ").await);
println!("closed after {} statements", conn.close().await);
}Ok(())
Err("empty statement")
closed after 2 statements17. A decorator that adds logging
A decorator wraps another implementation of the same trait. Generic decorators (Logged<T: Trait>) are resolved at compile time and cost nothing extra.
use std::cell::RefCell;
trait UserRepo {
async fn find_name(&self, id: u32) -> Option<String>;
}
struct Db;
impl UserRepo for Db {
async fn find_name(&self, id: u32) -> Option<String> {
(id == 1).then(|| String::from("ana"))
}
}
struct Logged<R> {
inner: R,
log: RefCell<Vec<String>>,
}
impl<R: UserRepo> UserRepo for Logged<R> {
async fn find_name(&self, id: u32) -> Option<String> {
let result = self.inner.find_name(id).await;
self.log.borrow_mut().push(format!("find_name({id}) -> {}", if result.is_some() { "hit" } else { "miss" }));
result
}
}
#[tokio::main]
async fn main() {
let repo = Logged { inner: Db, log: RefCell::new(Vec::new()) };
println!("{:?} {:?}", repo.find_name(1).await, repo.find_name(2).await);
println!("{:#?}", repo.log.borrow());
}Some("ana") None
[
"find_name(1) -> hit",
"find_name(2) -> miss",
]18. A caching decorator
A decorator can also change behavior: this one caches results so the wrapped (slow) implementation is called once per key.
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
trait PriceService {
async fn price(&self, sku: &str) -> u32;
}
struct SlowCatalog {
calls: AtomicU32,
}
impl PriceService for SlowCatalog {
async fn price(&self, sku: &str) -> u32 {
self.calls.fetch_add(1, Ordering::Relaxed);
tokio::time::sleep(Duration::from_millis(20)).await;
sku.len() as u32 * 100
}
}
struct Cached<S> {
inner: S,
cache: Mutex<HashMap<String, u32>>,
}
impl<S: PriceService> PriceService for Cached<S> {
async fn price(&self, sku: &str) -> u32 {
if let Some(&p) = self.cache.lock().unwrap().get(sku) {
return p; // guard dropped before any await
}
let p = self.inner.price(sku).await;
self.cache.lock().unwrap().insert(sku.to_string(), p);
p
}
}
#[tokio::main]
async fn main() {
let svc = Cached { inner: SlowCatalog { calls: AtomicU32::new(0) }, cache: Mutex::new(HashMap::new()) };
let mut prices = Vec::new();
for sku in ["pen", "notebook", "pen", "pen", "notebook"] {
prices.push(svc.price(sku).await);
}
println!("{prices:?}");
println!("backend calls: {}", svc.inner.calls.load(Ordering::Relaxed));
}[300, 800, 300, 300, 800]
backend calls: 219. A retry decorator with dynamic dispatch
With boxed futures, decorators can wrap a Box<dyn Trait> chosen at runtime, and be stacked in any order.
use async_trait::async_trait;
use std::sync::atomic::{AtomicU32, Ordering};
#[async_trait]
trait Sender: Send + Sync {
async fn send(&self, msg: &str) -> Result<(), String>;
}
struct FlakySmtp {
attempts: AtomicU32,
}
#[async_trait]
impl Sender for FlakySmtp {
async fn send(&self, msg: &str) -> Result<(), String> {
let n = self.attempts.fetch_add(1, Ordering::Relaxed) + 1;
if n < 3 {
return Err(format!("attempt {n}: SMTP timeout"));
}
println!(" delivered {msg:?} on attempt {n}");
Ok(())
}
}
struct Retry {
inner: Box<dyn Sender>,
max: u32,
}
#[async_trait]
impl Sender for Retry {
async fn send(&self, msg: &str) -> Result<(), String> {
let mut last_err = String::new();
for _ in 0..self.max {
match self.inner.send(msg).await {
Ok(()) => return Ok(()),
Err(e) => last_err = e,
}
}
Err(format!("gave up: {last_err}"))
}
}
#[tokio::main]
async fn main() {
let sender: Box<dyn Sender> = Box::new(Retry { inner: Box::new(FlakySmtp { attempts: AtomicU32::new(0) }), max: 5 });
println!("{:?}", sender.send("welcome!").await);
let impatient: Box<dyn Sender> = Box::new(Retry { inner: Box::new(FlakySmtp { attempts: AtomicU32::new(0) }), max: 2 });
println!("{:?}", impatient.send("welcome!").await);
} delivered "welcome!" on attempt 3
Ok(())
Err("gave up: attempt 2: SMTP timeout")20. Enum dispatch: dyn without boxing
If you know every implementation up front, an enum with a match in each method replaces dyn Trait: no boxing, no Send puzzles, and native async fn works.
use std::time::Duration;
enum BlobStore {
Local { root: String },
S3 { bucket: String },
InMemory,
}
impl BlobStore {
async fn put(&self, key: &str, data: &[u8]) -> String {
match self {
BlobStore::Local { root } => format!("wrote {} bytes to {root}/{key}", data.len()),
BlobStore::S3 { bucket } => {
tokio::time::sleep(Duration::from_millis(10)).await; // upload
format!("uploaded {} bytes to s3://{bucket}/{key}", data.len())
}
BlobStore::InMemory => format!("kept {} bytes in memory", data.len()),
}
}
}
#[tokio::main]
async fn main() {
let stores = [
BlobStore::Local { root: String::from("/var/data") },
BlobStore::S3 { bucket: String::from("backups") },
BlobStore::InMemory,
];
for s in &stores {
println!("{}", s.put("report.pdf", &[0u8; 2048]).await);
}
}wrote 2048 bytes to /var/data/report.pdf
uploaded 2048 bytes to s3://backups/report.pdf
kept 2048 bytes in memory21. Async closures instead of one-method traits
A trait with a single async method is often just a function. An AsyncFn bound accepts any async closure, which is lighter than defining a trait and a struct.
async fn process_all<F>(items: &[&str], handler: F) -> Vec<String>
where
F: AsyncFn(&str) -> Result<String, String>,
{
let mut out = Vec::new();
for item in items {
out.push(match handler(item).await {
Ok(v) => v,
Err(e) => format!("error: {e}"),
});
}
out
}
#[tokio::main]
async fn main() {
let prefix = String::from("thumb_");
let make_thumbnail = async |name: &str| {
tokio::task::yield_now().await;
if name.ends_with(".png") { Ok(format!("{prefix}{name}")) } else { Err(format!("{name} is not a PNG")) }
};
println!("{:?}", process_all(&["a.png", "b.gif", "c.png"], make_thumbnail).await);
}["thumb_a.png", "error: b.gif is not a PNG", "thumb_c.png"]22. Testing code that uses an async trait
#[tokio::test] makes a test function async. Combined with a fake implementation of the trait, business logic can be tested without any network.
use std::collections::HashMap;
use std::sync::Mutex;
pub trait Inventory {
async fn reserve(&self, sku: &str, qty: u32) -> Result<(), String>;
}
pub async fn place_order<I: Inventory>(inv: &I, lines: &[(&str, u32)]) -> Result<usize, String> {
for (sku, qty) in lines {
inv.reserve(sku, *qty).await.map_err(|e| format!("order rejected: {e}"))?;
}
Ok(lines.len())
}
#[cfg(test)]
mod tests {
use super::*;
struct FakeInventory {
stock: Mutex<HashMap<&'static str, u32>>,
}
impl Inventory for FakeInventory {
async fn reserve(&self, sku: &str, qty: u32) -> Result<(), String> {
let mut stock = self.stock.lock().unwrap();
let left = stock.get_mut(sku).ok_or(format!("unknown sku {sku}"))?;
*left = left.checked_sub(qty).ok_or(format!("only {left} {sku} left"))?;
Ok(())
}
}
fn inventory() -> FakeInventory {
FakeInventory { stock: Mutex::new(HashMap::from([("pen", 10), ("ink", 1)])) }
}
#[tokio::test]
async fn places_order_when_in_stock() {
assert_eq!(place_order(&inventory(), &[("pen", 3), ("ink", 1)]).await, Ok(2));
}
#[tokio::test]
async fn rejects_when_out_of_stock() {
let err = place_order(&inventory(), &[("ink", 2)]).await.unwrap_err();
assert_eq!(err, "order rejected: only 1 ink left");
}
#[tokio::test]
async fn rejects_unknown_items() {
assert!(place_order(&inventory(), &[("laptop", 1)]).await.is_err());
}
}running 3 tests
test tests::places_order_when_in_stock ... ok
test tests::rejects_unknown_items ... ok
test tests::rejects_when_out_of_stock ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s23. A generic service over several async traits
Services usually depend on more than one abstraction. Generic parameters keep everything statically dispatched.
use std::sync::Mutex;
trait Users {
async fn email_of(&self, id: u32) -> Option<String>;
}
trait Mailer {
async fn send(&self, to: &str, body: &str) -> Result<(), String>;
}
struct PasswordReset<U, M> {
users: U,
mailer: M,
}
impl<U: Users, M: Mailer> PasswordReset<U, M> {
async fn request(&self, user_id: u32) -> Result<String, String> {
let email = self.users.email_of(user_id).await.ok_or(format!("no user {user_id}"))?;
let token = format!("tok{user_id:03}");
self.mailer.send(&email, &format!("reset link: /reset/{token}")).await?;
Ok(token)
}
}
struct StaticUsers;
impl Users for StaticUsers {
async fn email_of(&self, id: u32) -> Option<String> {
(id == 7).then(|| String::from("ana@example.com"))
}
}
struct OutboxMailer(Mutex<Vec<String>>);
impl Mailer for OutboxMailer {
async fn send(&self, to: &str, body: &str) -> Result<(), String> {
self.0.lock().unwrap().push(format!("{to}: {body}"));
Ok(())
}
}
#[tokio::main]
async fn main() {
let svc = PasswordReset { users: StaticUsers, mailer: OutboxMailer(Mutex::new(Vec::new())) };
println!("{:?}", svc.request(7).await);
println!("{:?}", svc.request(8).await);
println!("outbox: {:?}", svc.mailer.0.lock().unwrap());
}Ok("tok007")
Err("no user 8")
outbox: ["ana@example.com: reset link: /reset/tok007"]24. Calling several dyn implementations concurrently
With boxed futures, you can start calls on every implementation and await them together with join_all. Here a message goes out on all configured notification channels at once.
use async_trait::async_trait;
use futures::future::join_all;
use std::sync::Arc;
use std::time::Duration;
#[async_trait]
trait Channel: Send + Sync {
fn name(&self) -> &str;
async fn deliver(&self, msg: &str) -> Result<(), String>;
}
struct Slack;
struct Sms {
ms: u64,
}
struct Pager;
#[async_trait]
impl Channel for Slack {
fn name(&self) -> &str {
"slack"
}
async fn deliver(&self, _msg: &str) -> Result<(), String> {
tokio::time::sleep(Duration::from_millis(30)).await;
Ok(())
}
}
#[async_trait]
impl Channel for Sms {
fn name(&self) -> &str {
"sms"
}
async fn deliver(&self, msg: &str) -> Result<(), String> {
tokio::time::sleep(Duration::from_millis(self.ms)).await;
if msg.len() > 20 { Err(format!("message too long ({} chars)", msg.len())) } else { Ok(()) }
}
}
#[async_trait]
impl Channel for Pager {
fn name(&self) -> &str {
"pager"
}
async fn deliver(&self, _msg: &str) -> Result<(), String> {
Err(String::from("no one on call"))
}
}
#[tokio::main]
async fn main() {
let channels: Vec<Arc<dyn Channel>> = vec![Arc::new(Slack), Arc::new(Sms { ms: 50 }), Arc::new(Pager)];
let msg = "database is down in eu-west";
let results = join_all(channels.iter().map(|c| c.deliver(msg))).await;
for (c, r) in channels.iter().zip(results) {
println!("{:<6} {r:?}", c.name());
}
}slack Ok(())
sms Err("message too long (27 chars)")
pager Err("no one on call")25. Realistic: a router of async handlers
A tiny web-framework core: handlers implement an async trait, the router stores them as Box<dyn Handler> in a HashMap, and dispatches by path. This is roughly how axum and actix-web look underneath, minus a lot of generics.
use async_trait::async_trait;
use std::collections::HashMap;
struct Request {
path: String,
body: String,
}
#[derive(Debug)]
struct Response {
status: u16,
body: String,
}
#[async_trait]
trait Handler: Send + Sync {
async fn call(&self, req: &Request) -> Response;
}
struct Health;
struct CreateUser;
#[async_trait]
impl Handler for Health {
async fn call(&self, _req: &Request) -> Response {
Response { status: 200, body: String::from("ok") }
}
}
#[async_trait]
impl Handler for CreateUser {
async fn call(&self, req: &Request) -> Response {
tokio::task::yield_now().await; // would write to a database
if req.body.contains('@') {
Response { status: 201, body: format!("created {}", req.body) }
} else {
Response { status: 400, body: String::from("email required") }
}
}
}
struct Router {
routes: HashMap<&'static str, Box<dyn Handler>>,
}
impl Router {
fn new() -> Self {
Router { routes: HashMap::new() }
}
fn route(mut self, path: &'static str, h: impl Handler + 'static) -> Self {
self.routes.insert(path, Box::new(h));
self
}
async fn dispatch(&self, req: Request) -> Response {
match self.routes.get(req.path.as_str()) {
Some(h) => h.call(&req).await,
None => Response { status: 404, body: format!("no route for {}", req.path) },
}
}
}
#[tokio::main]
async fn main() {
let app = Router::new().route("/health", Health).route("/users", CreateUser);
for (path, body) in [("/health", ""), ("/users", "ana@example.com"), ("/users", "ana"), ("/admin", "")] {
let resp = app.dispatch(Request { path: path.into(), body: body.into() }).await;
println!("{path:<8} -> {} {}", resp.status, resp.body);
}
}/health -> 200 ok
/users -> 201 created ana@example.com
/users -> 400 email required
/admin -> 404 no route for /admin26. Realistic: plugins selected from configuration
Which storage backend to use is decided at runtime from config. A factory returns Arc<dyn Store>, and the rest of the application never knows which one it got.
use futures::future::{BoxFuture, FutureExt};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
trait Store: Send + Sync {
fn put<'a>(&'a self, key: &'a str, value: String) -> BoxFuture<'a, Result<(), String>>;
fn get<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Option<String>>;
}
#[derive(Default)]
struct MemoryStore(Mutex<HashMap<String, String>>);
impl Store for MemoryStore {
fn put<'a>(&'a self, key: &'a str, value: String) -> BoxFuture<'a, Result<(), String>> {
async move {
self.0.lock().unwrap().insert(key.to_string(), value);
Ok(())
}
.boxed()
}
fn get<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Option<String>> {
async move { self.0.lock().unwrap().get(key).cloned() }.boxed()
}
}
struct ReadOnlyStore;
impl Store for ReadOnlyStore {
fn put<'a>(&'a self, key: &'a str, _value: String) -> BoxFuture<'a, Result<(), String>> {
async move { Err(format!("read-only: cannot write {key}")) }.boxed()
}
fn get<'a>(&'a self, key: &'a str) -> BoxFuture<'a, Option<String>> {
async move { (key == "motd").then(|| String::from("maintenance tonight")) }.boxed()
}
}
fn store_from_config(kind: &str) -> Result<Arc<dyn Store>, String> {
match kind {
"memory" => Ok(Arc::new(MemoryStore::default())),
"readonly" => Ok(Arc::new(ReadOnlyStore)),
other => Err(format!("unknown store type {other:?}")),
}
}
async fn app(store: Arc<dyn Store>) -> String {
let write = store.put("motd", String::from("hello")).await;
let read = store.get("motd").await;
format!("write: {write:?}, read: {read:?}")
}
#[tokio::main]
async fn main() {
for kind in ["memory", "readonly", "redis"] {
match store_from_config(kind) {
Ok(store) => println!("{kind}: {}", app(store).await),
Err(e) => println!("{kind}: config error: {e}"),
}
}
}memory: write: Ok(()), read: Some("hello")
readonly: write: Err("read-only: cannot write motd"), read: Some("maintenance tonight")
redis: config error: unknown store type "redis"27. Realistic: a job scheduler over a job trait
Jobs implement an async trait; a scheduler runs them concurrently on Tokio with a timeout each, and collects a report. Arc<dyn Job> lets each spawned task own a handle to its job.
use async_trait::async_trait;
use std::sync::Arc;
use std::time::Duration;
use tokio::task::JoinSet;
#[async_trait]
trait Job: Send + Sync {
fn name(&self) -> &str;
async fn run(&self) -> Result<String, String>;
}
struct Sleepy {
name: &'static str,
ms: u64,
}
struct Failing;
#[async_trait]
impl Job for Sleepy {
fn name(&self) -> &str {
self.name
}
async fn run(&self) -> Result<String, String> {
tokio::time::sleep(Duration::from_millis(self.ms)).await;
Ok(format!("done in {} ms", self.ms))
}
}
#[async_trait]
impl Job for Failing {
fn name(&self) -> &str {
"import-csv"
}
async fn run(&self) -> Result<String, String> {
Err(String::from("line 17: invalid date"))
}
}
async fn run_all(jobs: Vec<Arc<dyn Job>>, limit: Duration) -> Vec<String> {
let mut set = JoinSet::new();
for job in jobs {
set.spawn(async move {
let outcome = match tokio::time::timeout(limit, job.run()).await {
Ok(Ok(msg)) => format!("ok: {msg}"),
Ok(Err(e)) => format!("failed: {e}"),
Err(_) => String::from("timed out"),
};
format!("{:<12} {outcome}", job.name())
});
}
let mut report = Vec::new();
while let Some(line) = set.join_next().await {
report.push(line.unwrap());
}
report.sort();
report
}
#[tokio::main]
async fn main() {
let jobs: Vec<Arc<dyn Job>> = vec![
Arc::new(Sleepy { name: "send-digest", ms: 20 }),
Arc::new(Failing),
Arc::new(Sleepy { name: "reindex", ms: 500 }),
];
for line in run_all(jobs, Duration::from_millis(100)).await {
println!("{line}");
}
}import-csv failed: line 17: invalid date
reindex timed out
send-digest ok: done in 20 ms28. Summary table
| Situation | Write |
|---|---|
| async methods, used with concrete types or generics | async fn in the trait |
same, but generic callers will tokio::spawn the futures |
fn m(&self) -> impl Future<Output = T> + Send |
trait objects: Box<dyn T>, Arc<dyn T>, collections, plugins |
#[async_trait], or -> BoxFuture<'_, T> by hand |
| a closed set of implementations | an enum with async fn methods |
| just one async operation to pass around | an AsyncFn closure bound |
Summary
async fnin traits works natively, with default methods, associated types, and&mut self. Generic callers get static dispatch and no allocations.- Each implementation returns its own future type. Consequence 1: generic code can't assume the future is
Send; declare-> impl Future<Output = T> + Sendin the trait when callers will spawn it. - Consequence 2: such traits aren't
dyn-compatible. Box the futures (Pin<Box<dyn Future + Send + 'a>>,BoxFuture) or use#[async_trait], which does it for you. - Decorators, fakes for tests, and runtime-selected plugins all work, with the choice between generics (zero-cost) and
dyn(flexible) made explicitly. #[tokio::test]runs async tests.
Next: Async Streams: the async version of Iterator.
