Assignment 3: Thread Dispatcher

Assignment 3: Thread Dispatcher

In this assignment you will implement a simple threading mechanism in C++. Normally, threads are implemented in the operating system, but for this assignment you will implement them inside a user-level application. Your thread dispatcher will run in a single system thread, which is what you get when a new process starts or when you create a std::thread object. Your dispatcher will use that system thread to create and run any number of user-level threads. These user-level threads will have all the features of system threads (each has its own stack, they can be scheduled independently, and in Assignment 4 you will implement locks and condition variables for them), but the operating system doesn't know about them: it only knows about the one system thread. Your code to implement user-level threads will be very similar to the code that implements system threads in an operating system running on a single core.

This assignment is an example of virtualization: you'll take one system thread and use it to implement multiple user-level threads, each of which has (almost) the same capabilities as a system thread.

Here are the learning goals for this assignment:

  • Learn how dispatching works and what code is required to create, execute, and delete threads.
  • Learn how timer interrupts can be used to implement a round-robin scheduler.

Getting Started

To get started on this assignment, login to the myth cluster and clone the starter repo with this command:

git clone /afs/ir/class/archive/cs/cs111/cs111.1236/repos/assign3/$USER assign3

This will create a new directory assign3 in your current directory and it will clone a Git starter repository into that directory. Do your work for the assignment in this directory . The files thread.hh and thread.cc contain a skeleton for all of the code you need to write. Open those files in an editor; you'll see various places with comments such as "You have to implement this". You'll eventually replace each of those comments with code. The other files in the directory provide libraries that you'll use in your solution (such as stack.hh and stack.cc) or examples that will be discussed in section (such as static.cc and interrupt.cc).

The directory contains a Makefile; if you type make, it will compile your code along with the libraries and a test program, creating an executable test. You can invoke the command tools/sanitycheck to run a series of basic tests on your solution. Try this now: the starter code should compile but almost all the tests will fail.

The Thread Class

In this assignment you will create a C++ class Thread with the following methods, all of which are declared in thread.hh:

Thread(std::function<void()> main)

This is the constructor. It creates a new thread that will run main as its top-level function (the function must take no arguments and return no result). The new thread will be added to the ready queue, so the dispatcher will eventually execute it. If main returns, the thread will be terminated just as if it had invoked Thread::exit.

~Thread()

This is the destructor for Thread objects. It will be invoked only from Thread::exit (it is declared private in thread.hh, which prevents code outside the Thread class from invoking it).

void schedule()

When this method is invoked, the associated thread is added to the back of the ready queue; the thread must not currently be on the ready queue.

void Thread::redispatch()

Dispatches the next ready thread without rescheduling the current thread; the current thread will block unless schedule was invoked for it. This method must be invoked with interrupts disabled. This is a static method.

void Thread::exit()

Terminates the thread that is currently running and destroys its Thread object. This does not exit the entire application: other user-level threads can continue to run. This is a static method.

void Thread::yield()

Invokes schedule() on the current thread, followed by redispatch(): the current thread will remain ready, but other threads will get a chance to execute. This is a static method.

Thread* Thread::current()

Returns a pointer to the thread that is currently executing, or nullptr if no thread has ever been dispatched. This is a static method.

C++ Proficiency: Static Methods and Variables

Note that most of the methods of the Thread class are declared as static; this means that the methods are associated with the Thread class rather than a specific Thread object. For a normal method, such as schedule, you invoke it using an instance of the class, like this:

Thread *thread = new Thread(myFunc);
...
thread->schedule();

A static method is declared with the keyword static. For example, here is the declaration of current in thread.hh:

static Thread *current();

You invoke it using the class, like this:

Thread *t = Thread::current();

When a normal method is executing, a pointer to the current object is available as the variable this, and you can access instance variables in that object directly. When a static method executes, there is no current object and no this variable available to the code of the method. A static method can't reference instance variables of the "current object" because there is no "current object".

Static methods are useful in situations where there isn't an object available to use for invoking the method, or where the functionality provided by the method doesn't relate to a specific instance of the class. The static methods in the Thread class are declared as static because they can be invoked in places where there is no Thread object available; most of the static methods operate implicitly on the thread that is currently running.

Classes can also contain static variables. A static variable is one that is declared with the keyword static. For example, in the Thread class we have defined the ready queue as a static variable with this declaration:

static std::queue<Thread*> ready_;

Normal variables defined in classes (called instance variables or member variables) are part of objects: there is a separate instance of the variable in each instance of the object. When a method refers to an instance variable, it accesses the variable associated with the current object. A static variable has only one instance, whose storage is allocated outside any object. When a method refers to a static variable, it will access the same variable regardless of the current object. Static variables are useful for holding information that is shared across all of the objects of the class; for example, the ready queue is shared by all threads. Static methods often access static variables (for example, the redispatch method will need to access ready_.)

A quirk of static variables is that they must be defined as well as declared. The code above declares the variable ready_ but does not actually define it (i.e. allocate storage for it). The definition of ready_ is in the file thread.cc:

std::queue<Thread*> Thread::ready_;

Notice that the variable is named with the prefix Thread::, similar to a static method.

Stack Management

We have created a class Stack to help you manage stacks and context switching. A Stack object contains enough space for a moderate size call stack, plus a place to store the stack pointer register when the stack is not active. The constructor for the class looks like this:

Stack(void(*start)())

This constructor will initialize the object so that the function start will be invoked the first time stack_switch is called to activate the stack.

void stack_switch(Stack *current, Stack *next)

This function performs a context switch by switching executing from one stack to another. current is the Stack object that is active (the stack pointer register points somewhere in this object), and next is the stack to switch to. The function will save registers on the current stack, save the current stack pointer in current, switch to the stack pointer stored in next, and restore the registers saved on that stack. When the function returns, it will be executing on the new stack. The first time that stack_switch switches into a stack, it will start executing code at the start function for that stack; after that switching into a stack will cause execution to resume where it left off (returning from the stack_switch call that switched out of that stack). Note: it is fine for current and next to be the same. As a special case, it is also OK for current to be nullptr; when this happens, the current stack pointer register is not saved (you'll see later why this is needed). You will invoke this function in your implementation of Thread::redispatch.

The code that implements Stack is pretty simple (and interesting!); if you're curious, check out the code in stack.hh and stack.cc in the starter code.

Initial Milestones

We have designed the tests so that you can implement this assignment in stages, testing each stage before going on to the next. Here are the first milestones to implement:

Milestone 1: the First Thread

Before writing any code, take a few minutes to think about what information will need to be stored in each Thread object in order to implement the required methods (hint: it's not much!).

In this milestone you will create just enough functionality to create a thread and dispatch it, so that the thread's top-level function starts executing. To pass this milestone, you'll need to implement the following features:

  • The Thread constructor.
  • The schedule method (so the constructor can schedule the new thread).
  • The redispatch static method, which the first test will invoke to context switch into the new thread.
  • A wrapper method, discussed below.
  • The current static method, which will be needed by some of the other methods above.

Don't worry about any of the other methods right now, and don't worry about disabling interrupts.

There are two tricky parts in getting started. First, when creating a Stack for a Thread, you can't pass the main argument from the Thread constructor as the start argument to the Stack constructor, because they are different types (the main argument is a std::function, which supports powerful functionality such as closures; the argument to Stack is just the name of a function that takes no arguments and returns no result). Thus you'll need to write a separate static method (called a "wrapper") that you pass to the Stack constructor; when the wrapper is invoked, it invokes the std::function passed into the Thread constructor. We have declared the wrapper method as Thread::thread_start in thread.hh; you'll need to fill in its body in thread.cc. You will need to save the main argument from the Thread constructor so that Thread::thread_start can invoke it.

One of the ways a Thread can exit is for its main function to return. When this happens, control will pass back into Thread::thread_start, at which point it should call Thread::exit.

The second tricky part in getting started concerns stacks. The test program starts up in a system thread provided by Linux. The system thread already has a stack allocated by the Linux kernel, but this stack is not in a Stack object. Once your Threads start executing, you'll abandon the system thread's stack and just use the Stacks you created for each Thread. To do this, the first time your code calls stack_switch you should pass nullptr as the prev argument, since the current stack isn't part of a Stack object. For this milestone, you can always pass nullptr as the prev argument to stack_switch. You'll then need to fix this for Milestone 2.

Once you've written this code, you should be able to pass the Enter sanity test.

The description for this milestone is a bit long, but the code you'll have to write is short: it only takes about 20 lines of code in thread.cc, plus a few lines in thread.hh.

Milestone 2: Multiple Threads

In this milestone you will add functionality to switch between threads. To do this, you will implement the yield method. In addition, you will need to fix the stack_switch simplification from Milestone 1, where you always passed nullptr as the prev argument. From now on, you should pass in nullptr only the first time you invoke stack_switch; after that, pass the Stack of the current Thread.

Note: if at any point the redispatch method finds that there are no threads left to schedule, then it should invoke std::exit(0) to terminate the program. Exiting the program makes sense because once there are no runnable threads, there is no way for a thread ever to become runnable (even if there are blocked threads, some other thread would have to run in order to unblock them). In a real operating system, device interrupts could cause blocked threads to become runnable, but there are no devices in this assignment.

Once you've written this code, you should be able to pass the PingPong, RoundRobin, and Block sanity tests.

Milestone 3: Thread Exit

In this milestone you will implement enough functionality for threads to exit cleanly. A thread can exit either by calling Thread::exit or by returning from its top-level function back into your wrapper. In either case, you should delete the Thread object; this will invoke the Thread destructor (which you'll now have to write).

The Thread destructor must clean up any state associated with the thread. It might seem that you should delete the Thread's Stack in the destructor. However, that isn't safe, because the destructor is actually running on the Thread's stack and will continue to do so until Thread::redispatch changes to a different Thread. Thus, the stack cannot be freed immediately. However, it must eventually be freed, once you can be sure it's no longer in use. There are several ways to do this, but one simple way is to delay freeing a Thread's Stack until the next Thread is destroyed (at which point we know the old thread can't still be running). When each Thread exits, it deletes the Stack from the previous thread and saves its Stack for the next exiting thread to delete. This means there will always be one Stack that hasn't yet been deleted, but that's OK. The only tricky aspect of this milestone is figuring out how to implement this deferred Stack deletion (hint: you'll probably want to use a static variable).

Once you have written this code, you should be able to pass the Exit and Stack sanity tests; almost done!

Milestone 4: Preemption

So far, your dispatcher is non-preemptive: a thread can run as long as it likes. In the final milestone for this assignment you will add preemption to switch between threads using a round-robin scheduling mechanism with time slices. You'll use timer interrupts to do this, and you'll implement two methods:

  • Thread::preempt_init, which initializes preemption (see below).
  • A timer interrupt handler.

The Thread::preempt_init method has the following interface:

Thread::preempt_init(std::uint64_t slice_usec)

The slice_usec argument indicates how long time slices should be, in microseconds. This method will be invoked once during initialization (by code outside the Thread class) if preemption is desired. If this method is not invoked, then your dispatcher should be non-preemptive, as it has been up until now.

Your implementation of preemption should use the following function, provided by the starter code in timer.cc, to generate timer interrupts:

void timer_init(std::uint64_t usec, std::function<void()> handler)

Once you have invoked this function, timer interrupts will occur every usec microseconds (unless interrupts are disabled as described below). During each timer interrupt, handler will be invoked.

The starter code also provides the following functions to disable and reenable interrupts:

intr_enable(bool on)

If the argument is false, defers all interrupts. If the argument is true, enables interrupts and immediately dispatches any deferred interrupts.

intr_enabled()

Returns true if interrupts are currently enabled, false otherwise. Interrupts are initially enabled.

The starter code also defines an IntrGuard class, analagous to std::lock_guard. When an IntrGuard object is constructed, the current interrupt-enabled state is saved and interrupts will be disabled; when an IntrGuard object is destroyed, the interrupt-enabled state will be restored to the value saved by the constructor. You should use IntrGuard objects whenever practical. Note: the interrupt handling code that invokes handler will create an IntrGuard (see the timer_interrupt function in timer.cc), so interrupts will be disabled as long as your handler is executing. Before returning from an interrupt the IntrGuard will be destroyed, which reenables interrupts.

If the timer fires at a time when interrupts have been disabled, this occurrence will be remembered and an interrupt will occur as soon as interrupts are re-enabled.

To complete this milestone, implement the preempt_init method and the timer handler function. You will also need to add code throughout the Thread class to disable and enable interrupts when appropriate. Carefully review the expected behavior of each Thread method to decide when interrupts should be enabled and disabled. Here are a few hints:

  • Interrupts can cause problems when you are modifying state that is shared between threads. Consider which pieces of state are shared and which are private to a thread.
  • Interrupts must be disabled whenever redispatch is invoked, and they must be re-enabled on return from redispatch.
  • When a thread starts up for the first time in your wrapper function, it receives control from the dispatcher just as if it had invoked redispatch, so interrupts will be disabled; your code will need to reenable interrupts.

Once you have made these changes, the Preempt sanity test should pass. At this point, if you invoke tools/sanitycheck all the tests should pass. Note: the sanity tests do not test for properly disabling and reenabling interrupts in all situations; that's something that will require additional reasoning and checking in your code to ensure correctness.

Testing

The testing framework for this assignment is similar to what you have used in other assignments. When you invoke make, it will compile your code along with the file test.cc to form an executable program test. test.cc contains various tests that invoke your code to exercise features of the Thread class. The sanity checker will invoke test with different parameters to run a series of tests, and it will compare the output of those tests with the output produced by our sample solution, which is in samples/test_soln. The sanity checker prints out the command that it runs for each test, so if a test fails, you can run that command in isolation to repeat the test You can invoke samples/test_soln with the same parameter in order to see the expected output. You can also run test under the gdb debugger to study its behavior in more detail.

When you're debugging, it may be useful to look at the actual test code so you can see exactly what it's trying to do. All of the test code is in the file test.cc. Each test has a name such as enter, which is included on the command line to run the test, and the test is implemented by a function with the same name. Open test.cc and find the function enter, which is the first test that the sanity checker will run. As you can see, it is trying to create a new thread with enter_thread as the top-level method; then it invokes the dispatcher so the thread will run. You can find the code for enter_thread just above the code for enter; it prints a message and then exits the test application.

Note: we don't guarantee that the tests we have provided are exhaustive, so passing all of the tests is not necessarily sufficient to ensure a perfect score (CAs may discover other problems in reading through your code).

Miscellaneous Notes

  • You may assume that your code will only run on single-core systems, so disabling interrupts is sufficient to ensure that no other thread will run.
  • Don't worry about Valgrind errors (stack switching can cause false positives). Just try and make sure your code frees things it allocates.

Submitting

Once you are finished working and have saved all your changes, submit by running tools/submit.

We recommend you do a trial submission in advance of the deadline to allow time to work through any snags. You may submit as many times as you like; we will grade the latest submission. Submitting a stable but unpolished/unfinished version is like an insurance policy. If the unexpected happens and you miss the deadline to submit your final version, the earlier submit will earn points. Without a submission, we cannot grade your work. You can confirm the timestamp of your latest submission in your course gradebook.

Grading

Here is a recap of the work that will be graded on this assignment:

  • thread.hh and thread.cc: flesh out the Thread class; it should work in both preemptive and non-preemptive modes.
  • Code review for style.

We will grade your code using the provided sanity check tests and possible additional autograder tests. We will also review your code for other possible errors and for style. Check out our course style guide for tips and guidelines for writing code with good style!