Async on Embedded: Present & Future
In our last post, we've seen that async
can help reduce power consumption in embedded programs. The async machinery is much more fine-grained at switching to a different task than we reasonably could be. Embassy schedules the work intelligently, which means the work is completed faster and we race to sleep. Our application actually gets more readable because we programmers mostly don't need to worry about breaking up our functions into tasks and switching between them. Any await
is a possible switching point.
Now, we want to actually start using async in our programs. Sadly there are currently some limitations. In this post, we'll look at the current workarounds, the tradeoffs, and how the limitations might be partially resolved in the near future.
Current limitations
At the moment, traits cannot contain async fn
methods. By extension, async embedded programs cannot use the rich ecosystem of traits that exists for synchronous Embedded Rust development.
That may seem odd: async has been available in stable rust for years. There were however good technical reasons for why this feature was purposefully omitted from the initial async
stabilization, set out here.
Luckily, good progress seems to be made on these issues recently, for instance Generic Associated Types (GATs) may become stable this year. This feature allows us to get close to having async fn
methods, and stabilizations means we can publish packages that use this feature (published packages cannot use unstable features).
But we're impatient. The nightly version of Rust (a version where experimental unstable features are enabled) already implements the features we need. Let's see how close to async functions in traits we can get with nightly.
A glimpse of the future
The I²C protocol is one of the common ways to communicate with a sensor. Messages can be sent to the sensor, and can also be received by the host. In Rust, this behavior is captured by a trait. A simplified I2c
trait from embassy
looks like this:
pub trait I2c<A: AddressMode = SevenBitAddress> {
/// Error type
type Error;
type ReadFuture<'a>: Future<Output = Result<(), Self::Error>> + 'a
where
Self: 'a;
fn read<'a>(&'a mut self, addr: A, bs: &'a mut [u8]) -> Self::ReadFuture<'a>;
}
We have a read
function (the other I2C primitives are omitted here) that returns a future. This future is explicitly named with an associated type ReadFuture
and this type is generic over a lifetime 'a
, hence we need Generic Associated Types, which are currently a nightly-only feature.
The read function is not an async fn
at the moment, because the async
keyword is not allowed in that position inside a trait definition. Not to worry though, an implementation can just use an async block like so:
fn read<'a>(&'a mut self, addr: u8, bs: &'a mut [u8]) -> Self::ReadFuture<'a> {
async move {
// implementation
}
}
Inside the async block we can use async
and .await
just like in an async fn
body.
In sum, for embedded needs, stabilizing GATs (and friends) as they currently exist on nightly
would be sufficient to provide a basis for writing async driver crates, effectively giving the ecosystem a boost. Remaining rough edges can be polished over time.
Making a sensor package async
But even though we can make such async traits on nightly today, we can't publish them or implementations of them for concrete sensors. Therefore, we have to make our own async version of existing sync implementations. Let's look at what it takes today to make an async version of a sensor.
First of all, we need async communication with the sensor. When I first tried this, I was using a sensor that could only communicate over I2C. But there was no async implementation of the I2c
trait for our hardware, so I made one. It was quite fun to work on this level of the stack, and the async version is mostly a copy of the sync version, with the crucial exception that it allows switching to another task while waiting for a response from the sensor.
Next, we have to turn normal functions into async ones. Usually, this is as simple as changing the very foundational IO functions (something like read_register
and write_register
) to be async fn
and then follow the compiler messages from there.
The big exception is impl SomeTrait for MyType
blocks, because trait methods cannot be turned into async fn
, as we have seen. My solution so far has been to turn impl SomeTrait for MyType
into a impl MyType
. For example this impl Trait
block:
impl<CORE> Accelerometer for Lis3dh<CORE>
where
...
{
fn accel_norm(&mut self) -> Result<F32x3, AccelerometerError<Self::Error>> {
...
}
...
}
Can be replaced with just a impl
, allowing us to use async fn
:
impl Lis3dh<CORE>
where
...
{
async fn accel_norm(&mut self) -> Result<F32x3, AccelerometerError<Self::Error>> {
...
}
...
}
This works fine if the consumer of the API was only using a concrete version of the sensor anyway. It can be quite laborious though.
Alternatively, I could have made an async version of the trait with the approach from the previous section, but that did not seem worth it so far.
Is it worth it
Perhaps.
The conversion process is laborious. So far, I've only done it for relatively simple sensors that implement one or two external traits.
For a more complex project, there are two major hurdles: first you have to translate all your code to use async, including (transitive) dependencies, and then, whenever you want to add another dependency, you need to translate that as well. The translation is usually not complex, but it's a huge time sink.
But, this will all change when GATs (and type alias impl trait) become stable. Things are looking good on that front, and the technical work may be done late this year or early next year. Then it is up to the community to create a rich ecosystem of async versions of the familiar traits. With that in place, async on embedded will be a very effective technique indeed!
In preparation, now might be an excellent moment to start playing around with async traits on nightly. Once GATs are stabilized, we can build the Rust Embedded async ecosystem together 🦀❤️🦀.
This is the 3rd article in a series on async embedded development with Rust: