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 fromredispatch
. - 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 theThread
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!