Implementing the runtime – Creating Our Own Fibers

Note

The registers we save in our ThreadContext struct are the registers that are marked as callee saved in Figure 5.1. We need to save these since the ABI states that the callee (which will be our switch function from the perspective of the OS) needs to restore them before the caller is resumed.

Next up is how we initialize the data to a newly created thread:
impl Thread {
    fn new() -> Self {
        Thread {
            stack: vec![0_u8; DEFAULT_STACK_SIZE],
            ctx: ThreadContext::default(),
            state: State::Available,
        }
    }
}

This is pretty easy. A new thread starts in the Available state, indicating it is ready to be assigned a task.

One thing I want to point out here is that we allocate our stack here. That is not needed and is not an optimal use of our resources since we allocate memory for threads we might need instead of allocating on first use. However, this lowers the complexity in the parts of our code that have a more important focus than allocating memory for our stack.

Note

Once a stack is allocated it must not move! No push() on the vector or any other methods that might trigger a reallocation. If the stack is reallocated, any pointers that we hold to it are invalidated.

It’s worth mentioning that Vec<T> has a method called into_boxed_slice(), which returns a reference to an allocated slice Box<[T]>. Slices can’t grow, so if we store that instead, we can avoid the reallocation problem. There are several other ways to make this safer, but we’ll not focus on those in this example.

Implementing the runtime

The first thing we need to do is to initialize a new runtime to a base state. The next code segments all belong to the impl Runtime block, and I’ll make sure to let you know when the block ends since it can be hard to spot the closing bracket when we divide it up as much as we do here.

The first thing we do is to implement a new function on our Runtime struct:
impl Runtime {
  pub fn new() -> Self {
    let base_thread = Thread {
      stack: vec![0_u8; DEFAULT_STACK_SIZE],
      ctx: ThreadContext::default(),
      state: State::Running,
    };
    let mut threads = vec![base_thread];
    let mut available_threads: Vec<Thread> = (1..MAX_THREADS).map(|_| Thread::new()).collect();
    threads.append(&mut available_threads);
    Runtime {
      threads,
      current: 0,
    }
  }

When we instantiate our Runtime, we set up a base thread. This thread will be set to the Running state and will make sure we keep the runtime running until all tasks are finished.

Then, we instantiate the rest of the threads and set the current thread (the base thread) to 0.

The next thing we do is admittedly a little bit hacky since we do something that’s usually a no-go in Rust. As I mentioned when we went through the constants, we want to access our runtime struct from anywhere in our code so that we can call yield on it at any point in our code. There are ways to do this safely, but the topic at hand is already complex, so even though we’re juggling with knives here, I will do everything I can to keep everything that’s not the main focal point of this example as simple as it can be.

After we call initialize on the Runtime, we have to make sure we don’t do anything that can invalidate the pointer we take to self once it’s initialized.
    pub fn init(&self) {
        unsafe {
            let r_ptr: *const Runtime = self;
            RUNTIME = r_ptr as usize;
        }
    }

This is where we start running our runtime. It will continually call t_yield() until it returns false, which means that there is no more work to do and we can exit the process:
    pub fn run(&mut self) -> !
{
        while self.t_yield() {}
        std::process::exit(0);
    }

Leave a Reply

Your email address will not be published. Required fields are marked *

Related Post