Assignment 3: Thread Dispatcher

Assignment 3: Thread Dispatcher

Up until now you have used the std::thread class to create threads; these threads are implemented by the operating system. We'll refer to these threads as system threads because they are implemented by the operating system. For this project you will create a new implementation of threads in C++; these threads are implemented entirely at user-level (without the knowledge of the operating system). We'll refer to these threads as user-level threads. You will create a thread dispatcher that runs in a single system thread, which is what you get when a new process starts. 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.1246/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, it can access instance variables in that object directly using their names. When a static method executes, there is no "current object" so it can't reference instance variables directly. However, static methods often have access to objects using other variables, and they can use those pointers to read and write instance variables.

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.

In the section for this assignment you will discuss the program static.cc, which illustrates static variables and methods. The code for static.cc is present in your starter repo and make will build the static executable from static.cc, so you can run this program yourself to see how it works.

C++ Proficiency: this

In this assignment you will sometimes find that a C++ method needs a pointer to the object on which it was invoked; a special variable named this is implicitly available in every non-static method to provide that pointer. For example, if a class contains an instance variable foo, you can access that variable in a method either as foo or as this->foo (this example is just an illustration of how this works; we don't recommend actually using the this->foo notation).

There is no this variable in class static methods, since these methods are not invoked on a specific object.

Stack Management

We have created a Stack class 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 execution 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.

In section you will go over a sample program two_stacks.cc, which illustrates how to use Stack objects and the stack_switch function. Your starter repo contains a copy of two_stacks.cc, and make will build the executable two_stacks from it. After you have discussed this example in section, and before you start implementing this assignment, answer Questions 1A-1C in questions.txt.

Implementation Milestones

You won't need to write very much code for this assignment: our implementation has only about 30 lines (excluding comments). However, it's a bit tricky to figure out what these lines are, and each line is important. So, we have have designed a series of milestones to guide you through the implementation in small steps. After each step you should be able to pass additional tests.

Milestone 1: the First Stack

Unfortunately, the trickiest part of this assignment comes at the beginning: how to create the first Thread and start it executing? It's going to take multiple milestones to do this right. In this milestone you'll create a stack and start executing code on that stack. Every Thread has its own private stack, which will be implemented with the Stack class described above. For starters you can declare a Stack instance variable in thread.hh.

The next step is to initialize the Stack in the Thread constructor. This is where things start getting tricky. When constructing a Stack you must specify a start function, which is the top-level function that will execute on that stack. You might think this can just be the function specified by the main argument to the Thread, and we do eventually want to invoke main. However, it isn't possible to pass main as the argument to the Stack constructor, for two reasons. First, the types are incompatible: the Stack constructor supports only a bare-bones function with no arguments and no results, but the main argument to the Thread constructor is a std::function, which is a more powerful type (e.g. it supports lambdas). Second, you will need to do additional initialization before invoking main, as well as cleanup if main should ever return.

The solution to these issues is to introduce a wrapper function, which is passed to the Stack constructor. The wrapper will be invoked when the Stack begins executing, and it will then invoke the main function. It's called a wrapper because it will (eventually) contain additional code that runs before calling main and after main returns. We have created a skeleton wrapper function in Thread called wrapper; the initial code in wrapper just prints a message and exits the entire process. In future milestones you will replace that code, but leave it there for now.

Now that you know about wrapper, you can add an initializer for the Stack instance variable in your Thread constructor, specifying wrapper as the start argument.

The last step for this milestone is to start executing code on the new Stack. As a temporary hack for this milestone, invoke stack_switch in the Thread constructor. Normally, stack_switch is invoked when running on one Stack, and it will switch to a different Stack; it takes the current and new Stacks as arguments. However, during the first call to stack_switch there is no current Stack: the current stack is one allocated by the Linux kernel for the system thread, which is not a Stack object and cannot be passed to stack_switch. For now, pass nullptr as the first argument to stack_switch, to indicate "no current stack". The result is that we abandon the system thread's stack; after the first call to stack_switch we'll use only Stacks.

Once you've added code to invoke stack_switch, compile your code and run the enter test with the following command:

./test enter

This should print out the message from wrapper before exiting, means you have successfully executed code using a Stack object. This may not seem like much (the enter sanity test won't pass until you complete Milestone 3), but it is actually significant progress!

Milestone 2: the Ready Queue

In Milestone 1 you invoked stack_switch from the Thread constructor, but this isn't how the constructor is supposed to behave. The constructor should add the new Thread to the ready queue; stack_switch should not be invoked until the next invocation of the redispatch method.

For this milestone, implement the schedule and redispatch methods, and call schedule instead of stack_switch in the Thread constructor. We have already declared the ready queue as a class static variable ready_. The schedule method should add the Thread to the ready queue; redispatch should remove the first Thread from the ready queue and stack_switch into its stack. For now, keep using nullptr as the first argument to stack_switch.

Once you have implemented the above changes, try running the enter test again. It should still print same message, but now the Thread has been properly dispatched using the ready queue.

Milestone 3: Into the First Thread

In this milestone you will replace the code in the wrapper function with code to invoke the Thread's main function. This is easy to do, except that wrapper doesn't currently have access to the function to invoke. Making this information available requires two steps. The first step is to modify the Thread class so that the constructor saves its main argument in the Thread (do this now). The second step is for wrapper to use the information saved in the Thread to invoke main.

Unfortunately, wrapper is a static function and it is invoked with no arguments, so it doesn't have immediate access to any Thread objects. It needs a way to find out which Thread is currently executing. It turns out that several other Thread methods, such as exit, yield, and redispatch, will also need a way to identify the current thread.

So, the main work for this milestone is to keep track of the Thread that is currently executing. To do this, you'll need to define a new variable in the Thread class that holds a pointer to the current Thread (should this be an instance variable or a class static variable?). Then you will need to add code to update this variable whenever the current thread changes. Once you've done this, implement Thread::current, which is a static method that returns a pointer to the current Thread. Finally, invoke Thread::current in the wrapper method and use the result to invoke the Thread's main program. In addition, call Thread::exit if main returns.

Now try running the enter test again. This time it should print a different message: "Entered thread". The message is printed by the main program specified in the test, so this means you have successfully dispatched into your first Thread; congratulations!

The enter test should now pass when run under sanitycheck.

Milestone 4: Multiple Threads

In this milestone you will add functionality to support multiple threads and switch between them. To do this, you must implement the yield method. In addition, you will need to fix the stack_switch simplification from Milestones 1 and 2, where you always passed nullptr as the prev argument. From now on, you should pass in nullptr only if there is no current thread (the first time you invoke stack_switch, or if the "current" thread just exited); otherwise you should 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 ping_pong, round_robin, and block sanity tests.

Milestone 5: Thread Exit

In this milestone you will implement enough functionality for threads to exit cleanly. A Thread exits by calling Thread::exit: either the thread invokes Thread::exit directly, or it returns from its main function, in which case wrapper invokes Thread::exit. Implement Thread::exit now: it should delete the Thread and then call redispatch to run a different Thread.

Unfortunately the Stack creates complications for handling Thread exit. As currently implemented, when you delete a Thread, its Stack will also be deleted, and that's not safe. When the Thread object is deleted, the code that deletes it is actually running on the Stack, it will keep running on that Stack until Thread::redispatch changes to a different Stack. 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). To do this, you'll need to implement a destructor for Thread objects. When the destructor is called, it deletes the Stack from the previous Thread and saves the Stack of the current Thread 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.

There are a couple of tricky aspects in implementing deferred stack deletion. First, we need to separate the lifetime of a Thread from that of its Stack (the Stack must live on, even after the Thread has been destroyed). As a result, you can't declare the stack instance variable as a Stack in the Thread class, since that would cause the Stack to be deleted when the Thread is deleted. Instead, you'll need to declare the instance variable as a Stack*, and then initialize it by calling new to dynamically allocate a Stack object. This object won't be freed until delete is called on it, so it can live on after the Thread has been destroyed.

You will also need to figure out where to store the old stack pointer so that it can be deleted when the next Thread is destroyed. Hint: this information will need to be accessible to all Threads, since we can't predict which one will be the next to exit.

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

Milestone 6: Enabling Interrupts

So far, your dispatcher has been non-preemptive: a thread can run as long as it likes. The final two milestones will add preemption, so that your dispatcher switches between threads using a round-robin scheduling mechanism with time slices. In this milestone you will add code to enable and disable interrupts properly, but no actual interrupts will occur. The next milestone will activate a timer and handle the interrupts that it generates.

Interrupts must be enabled in order to receive timer interrupts as part of round-robin scheduling. But interrupts must also be disabled at times in order for the thread dispatcher to operate safely. Recall from the lecture discussion of lock implementation that the lowest-level code that implements threads, locks, and condition variables cannot use locks for synchronization (locks don't exist at this level). In systems with only a single core, disabling interrupts provides protection equivalent to acquiring a lock, since the only way a conflicting thread could run is if an interrupt results in a context switch. This assignment is similar to a single-core system, since we are using a single system thread to implement multiple user-level threads: if interrupts are disabled, then we can safely execute code without possible interference from other threads.

Here are the rules for disabling and enabling interrupts:

  • Interrupts must be disabled whenever modifying state that is shared among threads (such as the ready queue).
  • Interrupts must be disabled whenever redispatch is invoked, and they must (eventually) be reenabled after redispatch returns.
  • When a thread starts up for the first time in the wrapper function, it receives control from the dispatcher as if a call to redispatch returned, so interrupts will be disabled; wrapper must reenable interrupts.

We have provided two ways for you to disable interrupts. The best way is to create an IntrGuard object, which is analogous to std::lock_guard. When an IntrGuard object is constructed, it records whether interrupts are currently disabled, and in any case it disables interrupts. When an IntrGuard object is destroyed, it checks the recorded state: if interrupts were enabled when the object was created, then the destructor reenables interrupts; if interrupts were previously disabled, then it leaves them disabled. You should use IntrGuard objects whenever practical.

However, IntrGuard objects can't be used in situations where the code needs to either enable or disable interrupts, but not both. In these few cases you may use the following function:

intr_enable(bool on)

If the argument is false, it defers all interrupts. If the argument is true, it enables interrupts and immediately dispatches any deferred interrupts. Again, use intr_enable only where IntrGuard is not practical.

In section you will discuss two programs, interrupt.cc and interrupt2.cc, which illustrate interrupts and how to disable them. Your starter repo contains both of these files and make will build executables interrupt and interrupt2 from them. After section, but before completing this milestone, answer Question 2 in questions.txt.

Add code to thread.cc to enable and disable interrupts as needed. Once you have added this code, the enable_interrupts sanity test should pass. This test covers some, but not all, of the situations where interrupts must be enabled or disabled, so be sure to look through your code and reason about whether interrupts must be disabled.

Milestone 7: Preemption

To complete the implementation of preemption and round-Robin scheduling, you will need to 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)

You only invoke this method once; after that, timer interrupts will occur every usec microseconds. During each timer interrupt, handler will be invoked.

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. 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.

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. This program 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 also invoke samples/test_soln with the same parameter in order to see the expected output, and you can run test under gdb 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.
  • Do not use std::thread in this assignment; the Thread class is a replacement for std::thread.
  • 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:

  • questions.txt: answer all of the questions.
  • 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!

Credits: this assignment was created by David Mazières.