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's standard library provides the Future trait to represent asynchronous computations, but it does not provide an async runtime. Futures do not execute by themselves and you need something 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:

  1. Read from stdin.
  2. Do a network request.
  3. 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")]

Small caveat: using the current_thread runtime means that Tokio's scheduler runs tasks on the current thread. It does not necessarily mean that the entire process will never use helper threads. For example, depending on how you read from stdin, Tokio may still use blocking work behind the scenes.

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-looking way. By awaiting network_request directly inside the loop, I am waiting for that future to finish before drawing again. The sleeps themselves are non-blocking, but there is no other branch in this program that keeps drawing the UI while the request future is pending, so the UI is not drawn for ~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 driving a single future. But if we generate 2 async branches, one for the interface and the other to handle requests, the tokio runtime will be able to run them concurrently.

#[tokio::main(flavor = "current_thread")]
async fn main() {
	tokio::join!(
		async { // interface
			let mut 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. It is important to understand that join! is not spawning independent Tokio tasks here. It multiplexes those async branches inside the same task, which is exactly the kind of thing I wanted for this experiment: concurrency without parallelism. 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 for this experiment. The interface branch was in fact stopping, but the runtime was free to poll the interface branch again instead of executing the request branch, so the behavior looked the same as not using it. After reading the docs, this is not something you should build correctness on: Tokio does not guarantee the exact scheduling order after yield_now. 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. This is probably the more semantic primitive for a UI refresh loop, but in my experiment I still managed to miss the deadline sometimes and the draw call panicked because it was called too late. I probably had some blocking work or some wrong assumption in my mental model somewhere.

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.

Rust memory container cheat-sheet
Rust memory container cheat-sheet

Encapsulating our UI instance in a std::cell::RefCell will do the trick and will allow us to access it in both async branches and even mutate it. This works here because I am not spawning real independent Tokio tasks and I am not holding the mutable borrow across an .await. If I moved this to tokio::spawn, this would become a different problem and RefCell would not be enough by itself.

#[tokio::main(flavor = "current_thread")]
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.