This week I wanted to learn Rust for the second time, so I read some of the pages on the Rust book, once I've reached async-related topics I noticed that it is a bit different from what other languages do. I come from a background in Go, JS, and Python.
I thought that writing yet another terminal utility with Rust that does network requests would be a good introduction to Rust async programming.
Rust std lib provides the futures crate to write asynchronous code, but those futures do not execute by themselves and you need to poll them to further advance their execution. That's what async runtimes do and for the moment the std lib does not provide one, it has been left to the community to implement it. I've chosen tokio since it is the most used one and I have no preference or idea about what I need.
But I had one thing clear, the only things that my TUI has to do is:
- Read from stdin.
- Do a network request.
- Write to stdout.
It is little work, very simple, the only extra work we could add here is to take into account that
once you receive a response from the request you have to deserialize the response into memory.
Since Rust is known for its performance I wanted to write this TUI around the idea of limiting it to
a single thread. I don't want the tokio runtime to have to handle thread synchronization, for this simple
task a single thread should be more than enough and still provide a non-blocking interface to the user.
Tokio provides a macro that you can use on your functions that unwraps into a runtime initialization:
#[tokio::main]
And it has variants to specify things like the number of threads to use, or like in my case, if you want to
only use the current thread:
#[tokio::main(flavor = "current_thread")]
I wanted to first set up a test playground to get a grasp of really how Rust and tokio work to see how I could write an app with them.
This piece of code just defines an imaginary UI that has a draw
method that is very demanding
and panics if has elapsed more than 200ms since the last time that it has been called.
use std::time;
struct UI {
last_draw: time::Instant,
}
impl UI {
fn default() -> UI {
UI {
last_draw: time::Instant::now(),
}
}
fn draw (&mut self) {
let now = time::Instant::now();
assert!(
now.duration_since(self.last_draw).as_millis() <= 200,
"It has been too long since last draw!"
);
self.last_draw = now;
}
}
Then we define a method that will simulate the network request, it has 3 sleep
calls of 100ms each,
so we will have to be sure that our UI::draw
method gets called at least in the middle of some of these sleep
.
async fn network_request() {
println!("sending HTTP request...");
tokio::time::sleep(time::Duration::from_millis(100)).await;
println!("deserializing JSON...");
tokio::time::sleep(time::Duration::from_millis(100)).await;
println!("some more sleep...");
tokio::time::sleep(time::Duration::from_millis(100)).await;
}
And in my first attempt, I failed shamefully. In my incorrect mental model, I thought that in some way, once
it reached one of the await
within network_request
it would somehow halt execution there and continue with the
next loop to keep calling ui.draw()
.
#[tokio::main(flavor = "current_thread")]
async fn main() {
let mut ui = UI::default();
loop {
ui.draw();
network_request().await;
}
}
Now I see how blind I was, here the async
primitive just allows us to write asynchronous logic
in a synchronous way, so the call to network_request
takes 300ms and it panics.
Because I wasn't understanding why it wasn't working I decided to look at what other TUIs were doing, but surprisingly I haven't found any Rust file containing "current_thread" and "tui" (I am writing the TUI with tui-rs and wanted to see how to integrate it with single-threaded tokio).
But after reading a little bit I've understood that I need to offer the tokio runtime a chance to work on multiple tasks to intersperse them.
In the previous example, I was only generating a single task but if we generate 2 tasks, one for the interface and the other to handle requests, the tokio runtime will be able to run them concurrently.
async fn main() {
tokio::join!(
async { // interface
let ui = UI::default();
loop {
ui.draw();
}
},
async { // network requests
loop {
network_request().await;
}
}
);
}
Here tokio::join!
is running the provided async blocks and waiting for them to finish before exiting.
Although the previous snippet doesn't work, since the interface loop is not asynchronous so it is not giving
a chance to the network task to execute, so we need a wait to "turn it into async" and stop it.
The first thing that came to my mind was just to use tokio::time::sleep
(tokio provides async std lib alternatives) but
it seemed seedy to me, I just needed a semantic to specify that I want to stop the current execution of the task to run other
possible concurrent tasks.
I found tokio::task::yield_now
and it looked exactly what I was looking
for but the truth is that it didn't work, the interface task was in fact stopping but the tokio runtime was polling
the interface task again instead of executing the request
task, so the behavior was the same as not using it. I don't know
if that's the expected behavior or if I misunderstood something.
Then I also found consume_budget
and It looked like a good candidate,
unfortunately, it didn't work either, anyhow it exists under an unstable-feature
flag, and if you have to access unstable features when learning
a new tool it is probably because you are not approaching it in the way that it was intended.
I also tried tokio::time::interval
and tick events
to provide something like a refresh rate, but I found it to work much less reliable than sleep
and something the
draw
call panicked because it was called too late.
async { // interface
let mut interval = tokio::time::interval(
time::Duration::from_millis(100)
);
loop {
interval.tick().await;
ui.draw();
}
}
After feeling defeated I ended up including the async sleep call and everything worked
async { // interface
loop {
ui.draw();
tokio::time::sleep(
std::time::Duration::new(0, 0)
).await; // I am ugly but I do work.
}
}
Being a little more practical, both tasks will need to access the UI state, so I'll need to share the UI instance between them, since Rust needs to guarantee safe memory access on compile time, it is not that easy to share memory between different parts of your code for newcomers.
I've found the following repository which contains a very good decision tree to determine which memory primitive to use for a given case.
Encapsulating our UI instance in a std::cell::RefCell
will do the trick and will allow us to access
it in both tasks and even mutate it.
async fn main() {
let ui = std::cell::RefCell::new(UI::default());
tokio::join!(
async {
loop {
ui.borrow_mut().draw();
tokio::time::sleep(
time::Duration::new(0, 0)
).await;
}
},
async {
loop {
network_request().await;
ui.borrow_mut().value += 1;
}
}
);
}
This is how I ended up organizing my TUI code which can be viewed at github, it has
an abusive usage of unwrap
but I just wanted to get it done within the weekend.
I would love to hear about how I could improve it and if I've had some misconceptions in my mental model of how
Rust and async work.
DISCUSS ON REDDIT