Rust doesn't actually follow its Golden Rule
(when it comes to async functions)
A couple of days ago, Steve Klabnik published an article discussing Rust's Golden Rule, arguing that Rust's function signatures provide a clear contract that doesn't depend on the function's contents, which aids reasoning about the code. In particular, function signatures are never inferred.
However, the Rust language has evolved so that it violates this Golden Rule.
While impl Trait
return types by themselves are fine,
they combine with auto-traits such as Send
in an unfortunate manner.
This is a noticeable limitation when it comes to writing async Rust code.
What is an impl Trait
return type?
See also: Abstract return types in the Rust Reference
Normally, a Rust function signature returns some concrete named type:
fn foo(arg: Arg) -> Value { todo!() }
However, it may be desirable to return an "anonymous" or "existential" type:
- when limiting the contract offered by this function
- when the type cannot be named, for example when it is a closure
Thus, an impl
return type can be used,
to let the compiler infer the concrete type from the method body.
This is similar to a C++ auto
function, but with an important difference:
the Rust function must constrain that inferred return type to explicit traits,
thus guaranteeing a clear interface.
For example, a function might produce a data sequence:
pub fn data() -> Vec<usize> {
vec![0, 1, 1, 2, 3, 5, 8, 13]
}
However, this lets the caller use random access on the data and any other Vec
methods,
which limits how we can evolve this function in the future
(if we want to maintain API-compatibility).
Instead, an impl
return type can be used to tell callers that they can only iterate over this data:
pub fn data() -> impl IntoIterator<Item = usize> {
vec![0, 1, 1, 2, 3, 5, 8, 13]
}
This is still compiled as if we returned a vector,
but calling anything other than .into_iter()
will fail the type-checker.
To be clear, this feature is good: it helps tremendously with satisfying Rust's Golden Rule by limiting which interfaces/traits are promised.
What are auto traits?
See also: Auto Traits in the Rust Reference
Normally, Rust traits must be implemented or derived explicitly for each type:
#[derive(Debug)] // <-- here
struct Foo;
impl Display for Foo { // <-- here
fn fmt(&self, f: &mut Formatter) -> Result<()> {
todo!()
}
}
However, certain traits have special properties in the Rust type system,
and by default they are implicitly derived according to pre-defined rules.
These auto-traits are
Send
, Sync
, Unpin
, UnwindSafe
, and RefUnwindSafe
.
For example, the Send
trait informs us that a value
can be safely transferred to another thread.
This trait allows us to safely describe useful performance optimizations,
like using reference counting (Rc<T>
) instead of atomic reference counting (Arc<T>
)
when a type need not be Send
.
On the flip side, lots of functionality is multi-threaded and needs all involved data to be Send
.
For example, parallelizing a computation via Rayon
requires that input/output data must be Send
so that it can be sent to worker threads,
and the computation must be Send + Sync
so that it can be executed concurrently on worker threads.
The Rust async
ecosystem has a number of executors.
Some of them are single-threaded and can deal with non-Send
computation.
But that is very limiting.
Tokio's default executor is a multi-threaded executor,
and computations might be moved between worker threads.
This is great for performance,
but requires that the computations are Send
.
For the purpose of this article, this raises the question:
does Rust's Golden Rule mean that the signature of async functions
clearly show if they are Send
?
Async functions violate the Golden Rule
Unfortunately, the signatures of async functions do not contain their true return type!
Let's consider a function such as:
async fn foo(arg: Arg) -> Value {
todo!()
}
This function does not return a Value
!
What it returns is a future, that, when awaited, yields a Value
.
The compiler transforms the contents of the async function into a state machine
that conforms to the Future
trait,
This anonymous type cannot be named, though.
So the true type of this async function is more like:
fn foo(arg: Arg) -> impl Future<Output = Value> {
async move {
todo!()
}
}
When using this more explicit notation,
we can add additional trait constraints,
for example requiring that the returned future must be Send
:
fn foo(arg: Arg) -> impl Future<Output = Value> + Send { ... }
But in an async fn
, this is impossible.
Why this is annoying
When developing large async Rust applications,
it is possible to get an unwelcome surprise later during development
because you've accidentally made an async function non-Send
.
This can be as easy as holding an Rc<T>
value across an await
point.
You do not get any warning about the function being non-Send
.
You can write unit tests for the function,
and they will probably work just fine with a thread-local executor
like futures::executor::block_on()
from the futures
crate.
An async function being non-Send
is infectious.
If function outer()
awaits the future returned by function inner()
,
and inner()
is not Send
, then outer()
will not be Send
either.
When the Rust compiler detects this,
the error message points to the place where you are using outer()
– which may be far, far away from the place where inner()
was made non-Send
.
The compiler does try to point out the root cause,
but in complicated cases that only goes so far
– and these functions may even be in different crates.
How to deal with this
Using async
functions is convenient, and probably shouldn't be given up
just because this Golden Rule violation makes it a bit more difficult to deal with Rust code.
Even with this limitation, the Rust type system is a joy to work with,
and helps tremendously with writing safe software,
and with evolving such software systems safely.
What I occasionally do is to write static assertions.
Another option is to give up on the syntactic sugar offered by async
functions
– it's not actually that bad since you can still use async {}
blocks.
Statically asserting that a function is Send
The purpose of a static assertion is to fail the compilation
if some property is not upheld.
Here, we want to assert that an async fn
implements the Send
trait (or any other trait).
Rust makes this static assertion difficult
For simple cases, the static_assertions
crate can help.
It allows us to write assertions like:
assert_impl_all!(NamedType: SomeTrait);
But this is not a simple case, because the future returned by an async fn
cannot be named.
Ideally, Rust would have a feature like C++'s decltype()
that allows us to reference the type, e.g.:
// check that foo(arg) implements SomeTrait
assert_impl_all!(decltype(foo(declval::<Arg>())): SomeTrait);
// alternatively, since Rust doesn't have C++ style overloading:
assert_impl_all!(<decltype(foo) as Fn>::Output: SomeTrait);
But absent such a feature, we need workarounds.
Asserting type constraints using generics
Instead of using the static-assertions crate,
we can express the type constraint via a generic function.
We can define a helper function _is_send()
:
fn _is_send<T: Send>(_: T) {}
// ^^^^^^^
This function can only be called with arguments that satisfy Send
.
The function name has a leading underscore to prevent warnings,
as it will never be used in running code.
Now, we can invoke the function that we want to make assertions about:
async fn foo(arg: Arg) -> Value { todo!() }
fn _foo_is_send() {
_is_send(foo(Arg::default()));
}
We have to put this invocation into some function just for reasons of syntax, but again this function is never invoked.
This trick
– binding the unnameable function return type to our type parameter T
–
can be used to work around Rust's lack of C++-style decltype()
.
Providing function arguments in static assertions.
There is a small difficulty that our function foo()
needs an argument.
In simple cases, we can write an expression that constructs a value of that type,
in particular if the argument types are Default
.
In more complex cases, we can use a trick: as this function just serves as a static assertion for the type checker and will never be actually invoked, we can pretend that we already have a suitable value:
async fn foo(arg: Arg) -> Value { todo!() }
fn _foo_is_send(arg: Arg) {
// ^^^^^^^^
_is_send(foo(arg));
}
This trick can be used to work around Rust's lack of C++-style declval<T>()
.
Resulting error message
If this static assertion fails, we get an error message that looks like this:
error: future cannot be sent between threads safely
--> src/lib.rs:26:41
|
26 | fn _foo_is_send(arg: Arg) { _is_send(foo(arg)) }
| ^^^^^^^^ future returned by `foo` is not `Send`
|
= help: within `impl futures::Future<Output = ()>`, the trait `std::marker::Send` is not implemented for `Rc<i32>`
note: future is not `Send` as this value is used across an await
--> src/lib.rs:8:31
|
7 | let rc = Rc::new(42);
| -- has type `Rc<i32>` which is not `Send`
8 | futures::future::ready(()).await;
| ^^^^^^ await occurs here, with `rc` maybe used later
9 | }
| - `rc` is later dropped here
note: required by a bound in `_is_send`
--> src/lib.rs:23:20
|
23 | fn _is_send<T: Send>(_: T) {}
| ^^^^ required by this bound in `_is_send`
Not too pretty, but it gets the job done.
Writing async functions without sugar
It is entirely possible to write async Rust without using async fn
.
This does require a bit more boilerplate,
but this can be helpful in order to make an API absolutely clear
– in particular at crate boundaries.
Using async {}
blocks
Rust doesn't just let us write entire async
functions.
We can only create individual blocks that are async
.
That block will then evaluate to an anonymous Future
,
but otherwise behaves a lot like a no-argument closure.
This means that in many cases,
we can transform an async fn
:
async fn example(arg: Arg) -> T {
...
}
into a normal function that contains an async {}
block:
fn example(arg: Arg) -> impl Future<Output = T> {
async move {
...
}
}
We can use that to also specify additional trait bounds on the future,
in particular that it must be Send
:
fn example(arg: Arg) -> impl Future<Output = T> + Send {
// ^^^^^^
async move {
...
}
}
I recommend this approach for public async functions, since it avoids overhead and is rather explicit. The downside is a bit of additional boilerplate.
It is usually appropriate to create an async move {}
block
that moves ownership of captured values (in particular function arguments)
into the future.
If this is not desired,
it usually makes sense to shadow the owned variable with a reference instead.
Using a BoxFuture
Especially at crate boundaries,
it might be preferable to use dyn Trait
objects
rather than an (unnameable but concrete) impl Trait
type.
This prevents auto-traits from leaking out,
and can speed up compilation,
at the cost of a bit of indirection.
The futures
crate provides the BoxFuture
and LocalBoxFuture
type aliases
that are useful here.
Approximately, they describe a Box<dyn Future>
trait object.
More precisely, BoxFuture
is defined as:
pub type BoxFuture<'a, T> =
Pin<Box<dyn Future<Output = T> + Send + 'a>>;
So this takes care of a lot of details (lifetimes, pinning), but the important part is that this is a very useful shortcut for describing:
- a boxed trait object
- that is a future
- that returns a value of type
T
- and is
Send
.
The LocalBoxFuture
type alias is equivalent,
but drops the Send
requirement.
This means we can refactor the above function
fn example(arg: Arg) -> impl Future<Output = T> + Send {
async move {
...
}
}
as follows:
use futures::prelude::*;
fn example(arg: Arg) -> BoxFuture<T> {
async move {
...
}
.boxed()
}
I greatly recommend this approach
whenever the Box<_>
overhead isn't a concern.
Conclusion
Rust is a complex language, and that sometimes makes it complicated.
Here, two features (impl Trait
return types and auto-traits)
combine in a way that makes the development experience a bit un-rusty.
However, there are many ways for dealing with this,
in particular by using a BoxFuture
instead.
It will be interesting to see whether generator functions will run into similar problems (and solutions) if and when they're stabilized.
- next post: Available for hire
- previous post: Brexit deal and GDPR: no adequacy yet, but transfers can continue for a while…