11 KiB
Intentional Occlusions from futures-lite
futures-lite has an API that is deliberately smaller than the futures
crate. This allows it to compile significantly faster and have fewer
dependencies.
This fact does not mean that futures-lite is not open to new feature
requests. However it does mean that any proposed new features are subject to
scrutiny to determine whether or not they are truly necessary for this crate.
In many cases there are much simpler ways to implement these features, or they
would be a much better fit for an external crate.
This document aims to describe all intentional feature occlusions and provide
suggestions for how these features can be used in the context of
futures-lite. If you have a feature request that you believe does not fall
under any of the following occlusions, please open an issue on the
official futures-lite bug tracker.
Simple Combinators
In general, anything that can be implemented in terms of async/await syntax
is not implemented in futures-lite. This is done to encourage the use of
modern async/await syntax rather than futures v1.0 combinator chaining.
As an example, take the map method in futures. It takes a future and
processes its output through a closure.
let my_future = async { 1 };
// Add one to the result of `my_future`.
let mapped_future = my_future.map(|x| x + 1);
assert_eq!(mapped_future.await, 2);
However, this does not need to be implemented in the form of a combinator. With
async/await syntax, you can simply await on my_future in an async
block, then process its output. The following code is equivalent to the above,
but doesn't use a combinator.
let my_future = async { 1 };
// Add one to the result of `my_future`.
let mapped_future = async move { my_future.await + 1 };
assert_eq!(mapped_future.await, 2);
By not implementing combinators that can be implemented in terms of async,
futures-lite has a significantly smaller API that still has roughly the
same amount of power as futures.
As part of this policy, the TryFutureExt trait is not implemented. All of
its methods can be implemented by just using async/await combined with
other simpler future combinators. For instance, consider and_then:
let my_future = async { Ok(2) };
let and_then = my_future.and_then(|x| async move {
Ok(x + 1)
});
assert_eq!(and_then.await.unwrap(), 3);
This can be implemented with an async block and the normal and_then
combinator.
let my_future = async { Ok(2) };
let and_then = async move {
let x = my_future.await;
x.and_then(|x| x + 1)
};
assert_eq!(and_then.await.unwrap(), 3);
One drawback of this approach is that async blocks are not named types. So
if a trait (like Service) requires a named future type it cannot be
returned.
impl Service for MyService {
type Future = /* ??? */;
fn call(&mut self) -> Self::Future {
async { 1 + 1 }
}
}
One possible solution is to box the future and return a dynamic dispatch object, but in many cases this adds non trivial overhead.
impl Service for MyService {
type Future = Pin<Box<dyn Future<Output = i32>>>;
fn call(&mut self) -> Self::Future {
async { 1 + 1 }.boxed_local()
}
}
This problem is expected to be resolved in the future, thanks to
async fn in traits and TAIT. At this point we would rather wait for these
better solutions than significantly expand futures-lite's API. If this is a
deal breaker for you, futures is probably better for your use case.
Asynchronous Closures
As a pattern, most combinators in futures-lite take regular closures rather
than async closures. For example:
// In `futures`, the `all` combinator takes a closure returning a future.
my_stream.all(|x| async move { x > 5 }).await;
// In `futures-lite`, the `all` combinator just takes a closure.
my_stream.all(|x| x > 5).await;
This strategy is taken for two primary reasons.
First of all, it is significantly simpler to implement. Since we don't need to
keep track of whether we are currently polling a future or not it makes the
combinators an order of magnitude easier to write.
Second of all it avoids the common futures wart of needing to pass trivial
values into async move { ... } or future::ready(...) for the vast
majority of operations.
For futures, combinators that would normally require async closures can
usually be implemented in terms of async/await. See the above section for
more information on that. For streams, the then combinator is one of the
few that actually takes an async closure, and can therefore be used to
implement operations that would normally need async closures.
// In `futures`.
my_stream.all(|x| my_async_fn(x)).await;
// In `futures-lite`, use `then` and pass the result to `all`.
my_stream.then(|x| my_async_fn(x)).all(|pass| pass).await;
Higher-Order Concurrency
futures provides a number of primitives and combinators that allow for
polling a significant number of futures at once. Examples of this include
for_each_concurrent and FuturesUnordered.
futures-lite provides simple primitives like race and zip. However
these don't really scale to handling more than two futures at once. It has
been proposed in the past to add deeper concurrency primitives to
futures-lite. However our current stance is that such primitives would
represent a significant uptick in complexity and thus is better suited to
other crates.
futures-concurrency provides a number of simple APIs for dealing with
fixed numbers of futures. For example, here is an example for waiting on
multiple futures to complete.
let (a, b, c) = /* assume these are all futures */;
// futures
let (x, y, z) = join!(a, b, c);
// futures-concurrency
use futures_concurrency::prelude::*;
let (x, y, z) = (a, b, c).join().await;
For large or variable numbers of futures it is recommended to use an executor
instead. smol provides both an Executor and a LocalExecutor
depending on the flavor of your program.
@notgull has a blog post describing this in greater detail.
To explicitly answer a frequently asked question, the popular select macro
can be implemented by using simple async/await and a race combinator.
let (a, b, c) = /* assume these are all futures */;
// futures
let x = select! {
a_res = a => a_res + 1,
_ = b => 0,
c_res = c => c_res + 3,
};
// futures-concurrency
let x = (
async move { a.await + 1 },
async move { b.await; 0 },
async move { c.await + 3 }
).race().await;
Sink Trait
futures offers a Sink trait that is in many ways the opposite of the
Stream trait. Rather than asynchronously producing values, the point of the
Sink is to asynchronously receive values.
futures-lite and the rest of smol intentionally does not support the
Sink trait. Sink is a relic from the old futures v0.1 days where
I/O was tied directly into the API. The Error subtype is wholly unnecessary
and makes the API significantly harder to use. In addition the multi-call
requirement makes the API harder to both use and implement. It increases the
complexity of any futures that use it significantly, and its API necessitates
that implementors have an internal buffer for objects.
In short, the ideal Sink API would be if it was replaced with this trait.
Sidenote: Stream, AsyncRead and AsyncWrite suffer from this same
problem to an extent. I think they could also be fixed by transforming their
fn poll_[X] functions into async fn [X] functions. However their APIs are
not broken to the point that Sink's is.
In order to avoid relying on a broken API, futures-lite does not import
Sink or expose any APIs that build upon Sink. Unfortunately some crates
make their only accessible API the Sink call. Ideally instead they would
just have an async fn send() function.
Out-of-scope modules
futures provides several sets of tools that are out of scope for
futures-lite. Usually these are implemented in external crates, some of
which depend on futures-lite themselves. Here are examples of these
primitives:
- Channels:
async-channelprovides an asynchronous MPMC channel, whileoneshotprovides an asynchronous oneshot channel. - Mutex:
async-lockprovides asynchronous mutexes, alongside other locking primitives. - Atomic Wakers:
atomic-wakerprovides standalone atomic wakers. - Executors:
async-executorprovidesExecutorto replaceThreadPoolandLocalExecutorto replaceLocalPool.