Waker and Context
Overview:
- Understand how the Waker object is constructed
- Learn how the runtime knows when a leaf-future can resume
- Learn the basics of dynamic dispatch and trait objects
The
Waker
type is described as part of RFC#2592.
The Waker
The Waker
type allows for a loose coupling between the reactor-part and the executor-part of a runtime.
By having a wake up mechanism that is not tied to the thing that executes the future, runtime-implementors can come up with interesting new wake-up mechanisms. An example of this can be spawning a thread to do some work that eventually notifies the future, completely independent of the current runtime.
Without a waker, the executor would be the only way to notify a running task, whereas with the waker, we get a loose coupling where it's easy to extend the ecosystem with new leaf-level tasks.
If you want to read more about the reasoning behind the
Waker
type I can recommend Withoutboats articles series about them.
The Context type
As the docs state as of now this type only wraps a Waker
, but it gives some
flexibility for future evolutions of the API in Rust. The context can for example hold task-local storage and provide space for debugging hooks in later iterations.
Understanding the Waker
One of the most confusing things we encounter when implementing our own Future
s
is how we implement a Waker
. Creating a Waker
involves creating a vtable
which allows us to use dynamic dispatch to call methods on a type erased trait
object we construct ourselves.
The Waker
implementation is specific to the type of executor in use, but all Wakers share a similar interface. It's useful to think of it as a Trait
. It's not implemented as such since that would require us to treat it like a trait object like &dyn Waker
or Arc<dyn Waker>
which either restricts the API by requiring a &dyn Waker
trait object, or would require an Arc<dyn Waker>
which in turn requires a heap allocation which a lot of embedded-like systems can't do.
Having the Waker implemented the way it is supports users creating a statically-allocated wakers and even more exotic mechanisms to on platforms where that makes sense.
If you want to know more about dynamic dispatch in Rust I can recommend an article written by Adam Schwalm called Exploring Dynamic Dispatch in Rust.
Let's explain this a bit more in detail.
Fat pointers in Rust
To get a better understanding of how we implement the Waker
in Rust, we need
to take a step back and talk about some fundamentals. Let's start by taking a
look at the size of some different pointer types in Rust.
Run the following code (You'll have to press "play" to see the output):
use std::mem::size_of; trait SomeTrait { } fn main() { println!("======== The size of different pointers in Rust: ========"); println!("&dyn Trait:------{}", size_of::<&dyn SomeTrait>()); println!("&[&dyn Trait]:---{}", size_of::<&[&dyn SomeTrait]>()); println!("Box<Trait>:------{}", size_of::<Box<SomeTrait>>()); println!("Box<Box<Trait>>:-{}", size_of::<Box<Box<SomeTrait>>>()); println!("&i32:------------{}", size_of::<&i32>()); println!("&[i32]:----------{}", size_of::<&[i32]>()); println!("Box<i32>:--------{}", size_of::<Box<i32>>()); println!("&Box<i32>:-------{}", size_of::<&Box<i32>>()); println!("[&dyn Trait;4]:--{}", size_of::<[&dyn SomeTrait; 4]>()); println!("[i32;4]:---------{}", size_of::<[i32; 4]>()); }
As you see from the output after running this, the sizes of the references varies. Many are 8 bytes (which is a pointer size on 64 bit systems), but some are 16 bytes.
The 16 byte sized pointers are called "fat pointers" since they carry extra information.
Example &[i32]
:
- The first 8 bytes is the actual pointer to the first element in the array (or part of an array the slice refers to)
- The second 8 bytes is the length of the slice.
Example &dyn SomeTrait
:
This is the type of fat pointer we'll concern ourselves about going forward.
&dyn SomeTrait
is a reference to a trait, or what Rust calls a trait object.
The layout for a pointer to a trait object looks like this:
- The first 8 bytes points to the
data
for the trait object - The second 8 bytes points to the
vtable
for the trait object
The reason for this is to allow us to refer to an object we know nothing about except that it implements the methods defined by our trait. To accomplish this we use dynamic dispatch.
Let's explain this in code instead of words by implementing our own trait object from these parts:
use std::mem::{align_of, size_of}; // A reference to a trait object is a fat pointer: (data_ptr, vtable_ptr) trait Test { fn add(&self) -> i32; fn sub(&self) -> i32; fn mul(&self) -> i32; } // This will represent our home-brewed fat pointer to a trait object #[repr(C)] struct FatPointer<'a> { /// A reference is a pointer to an instantiated `Data` instance data: &'a mut Data, /// Since we need to pass in literal values like length and alignment it's /// easiest for us to convert pointers to usize-integers instead of the other way around. vtable: *const usize, } // This is the data in our trait object. It's just two numbers we want to operate on. struct Data { a: i32, b: i32, } // ====== function definitions ====== fn add(s: &Data) -> i32 { s.a + s.b } fn sub(s: &Data) -> i32 { s.a - s.b } fn mul(s: &Data) -> i32 { s.a * s.b } fn main() { let mut data = Data {a: 3, b: 2}; // vtable is like special purpose array of pointer-length types with a fixed // format where the three first values contains some general information like // a pointer to drop and the length and data alignment of `data`. let vtable = vec![ 0, // pointer to `Drop` (which we're not implementing here) size_of::<Data>(), // length of data align_of::<Data>(), // alignment of data // we need to make sure we add these in the same order as defined in the Trait. add as usize, // function pointer - try changing the order of `add` sub as usize, // function pointer - and `sub` to see what happens mul as usize, // function pointer ]; let fat_pointer = FatPointer { data: &mut data, vtable: vtable.as_ptr()}; let test = unsafe { std::mem::transmute::<FatPointer, &dyn Test>(fat_pointer) }; // And voalá, it's now a trait object we can call methods on println!("Add: 3 + 2 = {}", test.add()); println!("Sub: 3 - 2 = {}", test.sub()); println!("Mul: 3 * 2 = {}", test.mul()); }
Later on, when we implement our own Waker
we'll actually set up a vtable
like we do here. The way we create it is slightly different, but now that you know
how regular trait objects work you will probably recognize what we're doing which
makes it much less mysterious.
Bonus section
You might wonder why the Waker
was implemented like this and not just as a
normal trait?
The reason is flexibility. Implementing the Waker the way we do here gives a lot of flexibility of choosing what memory management scheme to use.
The "normal" way is by using an Arc
to use reference count keep track of when
a Waker object can be dropped. However, this is not the only way, you could also
use purely global functions and state, or any other way you wish.
This leaves a lot of options on the table for runtime implementors.