Introduction
Hello, Rustaceans! I'm quite sure many of you use async Rust every day in your projects, but do you actually know how it works under the hood? In this article I'm going to show you how async Rust works, explaining the Future trait, the Pin type, and the Context and Poll types. Let's get started!
Aren't there like other thousands of articles like this?
Yes, but this one is mine.
Why do we need async?
Basically we can say that we need async when we are unable to know when that task finishes, which usually means that our application is not directly responsible of taking care of it, instead its execution depends on an external system or resource which may be subject to delays.
Async, is good for at least these three cases:
- I/O: reading from and writing to the filesystem depends on the filesystem itself. Usually this doesn't have significant delays, but it's still something we don't have control of and so it's good for async. We schedule an I/O operation to the OS and we poll for it to finish.
- Network: any interaction with the network creates a lot of delays depending on many external resources. Just a HTTP GET request goes through several components that don't depend on our application. We ask the OS to create a socket for us, we write on the socket, the packet is enrouted from our gateway it goes through the entire internet and finally it reaches the HTTP server we want and that point it goes through the OS socket and finally it reaches the web server application which processes the request and finally it has to do everything backward (and trust me I've skipped many many steps). This creates tons of delays.
- Time: sometimes we ignore that, but time is async and can't be resolved immediately. If we need to wait 5 seconds, we depend on an external system, which is the Universe I guess, so async is also good for that.
💡 About I/O: you may not know that, but I/O is not always async actually. For example on Linux, the async fs functions just make a select on the file system using libc. That's because Linux provides async IO with uring, but it's too complex for everyday use, so we still rely on the ol' good libc.
Now that we have covered the basics, let's see how async Rust works.
Running Async without Tokio
The first time you've encountered async code you may have tried to execute it directly inside a sync function, but it didn't work, because the compiler told you that you must be inside an async function to use the await
keyword.
So what you did at that point was either wrapping everything in tokio::main
or use block_on
from the futures crate.
Actually, though, we can see that executing an async in a non-async context, is quite simple actually. Let's implement our simple DumbRuntime
:
use std::pin::Pin;use std::task::{Context, Poll, Waker};use std::time::Duration;/// A simple runtime to execute async codepub struct DumbRuntime;impl DumbRuntime {pub fn block_on<F>(mut f: F) -> F::OutputwhereF: Future,{let mut f = unsafe { Pin::new_unchecked(&mut f) };let mut ctx = Context::from_waker(Waker::noop());loop {println!("polling future");match f.as_mut().poll(&mut ctx) {Poll::Ready(val) => {println!("future is ready");return val;}Poll::Pending => {std::thread::sleep(Duration::from_micros(10)); // it should not even happen}}}}}
So we want to have just a block_on
function for now, which will be able to execute async functions inside a sync context.
You may be confused by this snippet, because it's quite different from what you're used to.

But what's going on there? Let's break it down:
-
First we have a
block_on
function that takes a mutableFuture
and returns its output.So what's a
Future
then? AFuture
is a trait that represents an asynchronous computation that may or may not be completed yet. It has a method calledpoll
that takes a mutableContext
and returns aPoll
enum. ThePoll
enum can be eitherReady
orPending
. When it'sReady
, it contains the value of the computation, otherwise it'sPending
and it means that the computation is not done yet. -
We create a
Pin
from the mutable reference of the future. APin
is a type that is used to ensure that an object is not moved in memory, which is important for async code because the future must not be moved in memory while it's being executed, otherwise we could get a Segmentation Fault or other nasty bugs. -
We create a
Context
from aWaker
. AWaker
is a type that is used to wake up a task when it's ready to be executed -
We enter a loop where we poll the future. If the future is ready, we return the value, otherwise we sleep for 10 microseconds and poll again.
At this point we can execute async code in a sync context:
mod runtime;use self::runtime::DumbRuntime;fn main() {DumbRuntime::block_on(async_main());}async fn async_main() {println!("Hello, world!");}
This will print Hello, world!
once, and polling future
and future is ready
once, since we only have one future.
What about nested async calls?
What if we add a nested async call?
mod runtime;use self::runtime::DumbRuntime;fn main() {DumbRuntime::block_on(async_main());}async fn async_main() {let res = async_fn().await;println!("Hello, world! {res}");}async fn async_fn() -> i32 {42}
How many print of polling future
will be printed before the program exits?
Just one actually, so how is the internal async function executed?

How is async code executed?
To understand this we first need to understand what an async function is.
An async
function is actually a syntax sugar for a function with this signature:
fn async_fn() -> impl Future<Output = i32> {std::future::ready(42)}
So it's just a function that returns something that implements the Future
trait.
As we've said before, the Future
trait has a poll
method that returns a Poll
enum. When we call await
on a future, we're actually calling the poll
method on it.
This call is handled by our Runtime though, either tokio or our DumbRuntime
, which will keep calling poll
over the future until it's ready.
Creating an async task
Indeed we can create a more interesting asynchronous task, instead of just returning 42
.
We want to create a Future which takes a n: u64
and returns Pending
n - 1
times, and then Ready
.
use std::pin::Pin;use std::sync::Arc;use std::sync::atomic::AtomicU64;use std::task::{Context, Poll};pub struct Counter {pub counter: Arc<AtomicU64>,max: u64,}impl Future for Counter {type Output = u64;fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {self.counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);let value = self.counter.load(std::sync::atomic::Ordering::SeqCst);println!("polled with current value: {value}");if value >= self.max {Poll::Ready(value)} else {// wake up the futurecx.waker().wake_by_ref();Poll::Pending}}}
So we can see that in order to have an async task, we can't use just a function, but we actually need a struct that implements the Future
trait.
At this point we can implement the Future
trait for it. In this case we have a Counter
struct that has a counter
field and a max
field. The poll
method will increment the counter and return Ready
if the counter is greater than or equal to max
, otherwise it will return Pending
.
Of course this still doesn't make sense for an async task, because we only need async for things that depend on external resources, like I/O operations or timeouts.
At this point we can create our async function which executes the task:
fn count(to: u64) -> impl Future<Output = u64> {Counter { counter: Arc::new(AtomicU64::new(0)), max: to }}
What it's quite curious, is that we return a struct, instead of a result, that's because our runtime will call poll for us when we call .await
on it.
So is that all? Well, the basic concepts are these, but of course you may think that this runtime, as the name suggests, is quite dumb and much far from a real async runtime, like tokio.
So let's see how can we implement something more serious.
Implementing a decent async runtime
There are basically three things a runtime must do:
- spawning new tasks and returning a handle for it to get the result
- a worker that will poll the tasks
- a way to block on a future
First of all we need to define a Task struct which will hold the future and a sender to send the result back:
struct Task<T>whereT: Send,{future: Pin<Box<dyn Future<Output = T> + Send>>,sender: SyncSender<T>,}
Here T
is the return type of the task
Then we define the runtime, which will have a sender to send tasks to the worker:
pub struct TaskRuntime<T>whereT: Send,{sender: Sender<Task<T>>,}
We define an handle to join for the result
pub struct TaskHandle<T>whereT: Send,{receiver: Receiver<T>,}impl<T> TaskHandle<T>whereT: Send,{pub fn join(self) -> T {self.receiver.recv().expect("failed to receive result")}}
And finally we implement our task runtime
impl<T> TaskRuntime<T>whereT: Send + 'static,{pub fn new() -> Self {let (sender, receiver) = channel();// run worker which will spawn tasksstd::thread::spawn(move || Self::run(receiver));Self { sender }}/// Spawn a new task and get a [`TaskHandle`] to join the resultpub fn spawn<F>(&mut self, f: F) -> TaskHandle<T>whereF: Future<Output = T> + Send + 'static,T: Send,{let (result_sender, result_receiver) = sync_channel(1);let task = Task {future: Box::pin(f),sender: result_sender,};self.sender.send(task).expect("failed to spawn");TaskHandle {receiver: result_receiver,}}/// Block on a future and return the resultpub fn block_on<F>(mut f: F) -> F::OutputwhereF: Future,{let mut f = unsafe { Pin::new_unchecked(&mut f) };let thread = std::thread::current();let waker = Arc::new(SimpleWaker { thread }).into();let mut ctx = Context::from_waker(&waker);loop {println!("polling future");match f.as_mut().poll(&mut ctx) {Poll::Ready(val) => {println!("future is ready");return val;}Poll::Pending => {std::thread::park();println!("parked");}}}}/// Run the runtime internal worker./// Everytime a task is received, spawn a new thread to run itfn run(receiver: Receiver<Task<T>>) {while let Ok(task) = receiver.recv() {std::thread::spawn(move || Self::run_task(task));}}/// Run a task awaiting for it to be ready and send the result backfn run_task(task: Task<T>) {let res = Self::block_on(task.future);task.sender.send(res).expect("failed to send result");}}
Eventually we can spawn our async tasks and await for them to be ready
fn main() {let mut runtime = TaskRuntime::new();let handle_1 = runtime.spawn(async_fn());let handle2 = runtime.spawn(async_fn2());let res = handle_1.join();let res2 = handle2.join();println!("res: {res}");println!("res2: {res2}");}async fn async_fn() -> u64 {let res = count(10).await;println!("async_fn {res}");res}async fn async_fn2() -> u64 {let res = count(23).await;println!("async_fn2 {res}");res}fn count(max: u64) -> impl Future<Output = u64> {println!("count");Counter {counter: Arc::new(AtomicU64::new(0)),max,}}
And that's it! We've implemented a simple async runtime that can execute async tasks.
There are still many features missing and currently it's using a lot of threads of course, also we cannot currently have different return types for tasks, but it's a good starting point to understand how async Rust works and I didn't want to make it too complex.
Appendix
Let's add some final notes on two things that we've not covered yet: Context and Waker.
Context
The Context
type is used to pass information to the future when it's polled. It is just used to access to the Waker
which can be used to wake the current task.
Waker
A waker is a handle for waking up a task, notifying its executor that is ready to be run.
Indeed when poll
is called, we receive the Context
as an argument, and so we can access to Waker
by using ctx.waker()
.
The poll function should, in case of Pending
is returned, wake up the task by calling cx.waker().wake_by_ref()
, like we've done in our Counter
struct.
That's because the runtime will keep calling poll
until it returns Ready
, and so we need to wake up the task when it's ready to be polled again, and it's the Waker
that will do this job.
Conclusion
I hope you've enjoyed this article and that you've learned something new about async Rust. This is actually an introduction to further deep dives into async Rust, and I hope you'll be able to understand better how async Rust works under the hood.