useful but not prerequisite tutorial here.
Let's get a quick overview of how async in Rust works (and some little edge cases that we'll have to consider) before diving into the details of this post.
When given an async function.
async fn belated_say_hi() {
let a = 1;
tokio::time::sleep(Duration::from_secs(1)).await;
let b = 2;
let c = &b;
println!("Hello!");
}
Rust converts that async function into a state machine.
enum BelatedSayHiFSM {
BeforeSleep {
a: i32,
},
AfterSleep {
b: i32,
c: &i32,
},
}
Which can then be polled to advance it.
let fut = belated_say_hi();
let context = &mut Context::from_waker(Waker::noop());
loop {
match fut.poll(context) {
Poll::Ready(value) => println!("Done! {value:?}"),
Poll::Pending => println!("Still more work to do..."),
}
}
The Context struct contains a Waker, and the Waker allows the Future to notify when it is ready to be polled again. Here we used Waker::noop for no-operation which just ignores any notifications the Future sends.
Here's one edge-case:
let fut = async {
async { thread::sleep(Duration::from_secs(1)) }.await;
async { thread::sleep(Duration::from_secs(1)) }.await;
async { thread::sleep(Duration::from_secs(1)) }.await;
};
loop {
match fut.poll(context) {
Poll::Ready(()) => break,
Poll::Pending => println!("Pending"),
}
}
You'd think that this would print Pending 3 times, but no! Since thread::sleep is synchronous and blocking, Rust will just wait 3 seconds and finish on the first poll.
If we use tokio::time::sleep instead it would work as expected. This is because tokio::time::sleep is asynchronous and returns Poll::Pending
let fut = async {
tokio::time::sleep(Duration::from_secs(1)).await;
tokio::time::sleep(Duration::from_secs(1)).await;
tokio::time::sleep(Duration::from_secs(1)).await;
};
loop {
match fut.poll(context) {
Poll::Ready(()) => break,
Poll::Pending => println!("Pending"),
}
}
In the implementation of tokio::time::sleep somewhere...
...
if still sleeping {
return Poll::Pending;
}
...
This is actually a good thing! Less unneccessary polling means more performance. But more importantly, this gives us more control over when our future "pends" because we have to explicitly await a future that returns Poll::Pending.
Now we can start.
I'm making a dialect of lisp and I'd like step-by-step execution. Instead of executing a big chunk of lisp all in one go. I want to be able to step-through each instruction as it's being executed.
I had already wrote the lisp eval-er and I didn't want to rewrite it[1]. So I had an evil idea. I could convert it into an async function and poll it!
The code looked something like this:
fn eval(ast) {
match ast {
Vector(vec) => {
for item in vec {
eval(item)
}
}
... etc
}
}
And it was very easy to convert it into async.
async fn eval(ast) {
ForcePending::new().await;
match ast {
Vector(vec) => {
for item in vec {
eval(item).await
}
}
... etc
}
}
Since the rest of the code is synchronous, it will only stop executing and return control to the poller when it hits ForcePending.
ForcePending is implemented like so:
struct ForcePending {
completed: bool,
}
impl ForcePending {
fn new() -> Self {
Self { completed: false }
}
}
impl Future for ForcePending {
fn poll(self, context) -> Poll {
if self.completed {
Poll::Ready(())
} else {
self.completed = true;
Poll::Pending
}
}
}
And with some abuse of Context extension data. I can return information back to the poller to inform it what I was eval-ing.
fn eval(ast) {
let metadata = (current_line_number, current_column_number);
ForcePending::new(metadata).await;
...
}
struct ForcePending {
metadata: (i32, i32),
completed: bool,
}
impl Future for ForcePending {
fn poll(self, context) {
*context.ext() = self.metadata;
... etc
}
}
Then the poller could.
let fut = eval();
let context = ContextBuilder::from_waker(Waker::noop())
.ext(&mut metadata) // <-- pass in data
.build();
loop {
println!("Step");
match fut.poll(context) {
Poll::Ready(value) => break,
Poll::Pending => {},
}
}
I had to enable the local_waker and context_ext features because those are still unstable.
Closing thoughts (and footnotes)
As you can guess, the code is pretty ugly, so I won't be showing it here or linking to the source. It's also very very unsafe becaues I decided the lisp language should have garbage collection, which was a whole other can of worms. I think the main take-away from this blog post is that you shouldn't trust me to write code. I'll make abominations like what you're seeing here.
[1] Especially because, ugh, can you imagine writing a lisp executor as a state machine, by hand? Even if I did want to rewrite it, I still wouldn't want to write it as a state machine.