NOTE: this website is out of date. This is the course web site from a past quarter. If you are a current student taking the course, you should visit the current class web site instead. If the current website is not yet visible by going to cs111.stanford.edu, it may be accessible by visiting this link until the new page is mounted at this address. Please be advised that courses' policies change with each new quarter and instructor, and any information on this out-of-date page may not apply to you.
Due: Thu Mar 7 11:59 pm
Late submissions accepted until Sat Mar 9 11:59 pm
Assignment by David Mazières and John Ousterhout, with modifications by Nick Troccoli.
In this assignment you will implement a simple threading mechanism in C++
and use that to implement your own version of locks and condition variables.
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
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.
Learning Goals
The goal of this project is for you to get experience managing thread dispatching, scheduling and synchronization. This assignment will help you:
- 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.
- Learn how locks and condition variables can be implemented in a singe-core system.
Getting Started
To get started on the assignment, clone the starter project using the command
git clone /afs/ir/class/cs111/repos/assign5/$USER assign5
This line creates a new folder called assign5 in your current working directory containing a copy of the starter code.
Here are the relevant files for your work on the assignment:
thread.hh/cc: the implementation of theThreadclass for part 1 of the assignment that represents a single thread, and also dispatches/schedules threads to run. You will edit the header file to add necessary state, and edit the cc file to implement the provided methods.stack.hh: a provided implementation of aStackvariable type that represents a thread stack and manages the stack memory, the saved stack pointer, and what function to initially run.timer.hh: a provided implementation of a timer that can call a specified handler function at a specified time interval.sync.hh/cc: the implementation of theMutexandConditionclass for parts 2 and 3 of the assignment that represents mutex and condition variable types. You will edit the header file to add necessary state, and edit the cc file to implement the provided methods.test.cc: a test file containing various tests that spawnThreads and use mutexes and condition variables in different ways.samples: a symbolic link to the shared directory for this assignment. It contains:test_soln: a provided sampletestsolution executable that uses a fully-implementedThread/Mutex/Conditionclass.
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 invoke test or samples/test_soln
with the same parameter in order to see the output/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).
This project consists of three parts - implementing Thread, implementing Mutex, and implementing Condition. Each part builds on the previous one (Mutex relies on Thread methods, and Condition relies on Mutexes and Thread methods), so make sure to thoroughly test each part before moving on to the next.
Additionally, this project builds on our use of C++ classes, methods and instance variables, and uses some additional C++ features, some of which you may have used before, and others that might be new. The resources in our C/C++ guide, including the C++ classes review video, may be helpful in reviewing details of classes in C++. Also check out this C++ guide/refresher to get up to speed on static!
Important note: one of the new C++ features mentioned in the guide above is static class variables. When deciding whether to make something static, carefully consider how many copies of that field you need; do you need one per object, or one shared across all objects? Making something static when it shouldn't be can be the source of many a gnarly bug, since it will subtly share one copy instead of keeping separate copies!
Additionally, make sure to distinguish between using this and using Thread::current(). this refers to the current object in a non-static method; e.g. within Mutex::lock, it will refer to the current Mutex.
Assignment Overview Video
We've created a short video that provides an overview of the assignment. It's not required to watch to complete the assignment, but rather just provides a walkthrough of some of the information included in this spec, plus tips/hints/tricks for different parts. We hope you find it helpful! (You can also view the video on Canvas, in the same place as the lecture videos). Slides here, code here (or make a copy of /usr/class/cs111/lecture-code/assign5).
1. Thread
First, you'll implement a C++ class Thread that represents a single thread. This is an expanded version of the struct Thread used in lecture and section with more features. Specifically, in addition to managing internal state (such as its stack), the Thread class also manages dispatching/scheduling of threads. Specifically, it can context switch between threads, it keeps track of a ready list of ready threads to run, and it keeps track of the current running thread.
Check out thread.hh for the list of public methods on the Thread class that you will implement - the documentation in the header file provides more information about each method's behavior.
Stack Management
Each thread will have its own stack, and
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)(Thread *), Thread *t);
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, and will be passed the specified thread as its parameter.
void stack_switch(Stack *current, Stack *next)
This function performs a context switch by switching executing from
one stack to another. This is just like the context_switch function
from lecture, except that its parameters are pointers to Stacks
instead of Thread references. 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 interesting to read through -
if you're curious, check out the code in stack.hh and
stack.cc in the starter 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 make sure your code frees things it allocates.
- Your code may (and is encouraged to, where appropriate) call existing
Threadmethods as needed instead of re-implementing logic. - Our full implementation is roughly 30 lines (excluding comments, and only including lines in the .cc file)
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
schedulemethod (so the constructor can schedule the new thread). - The
redispatchstatic method, which the first test will invoke to context switch into the new thread. - The
thread_startwrapper method, discussed below.
Don't worry about any of the other methods right now, and don't worry about disabling interrupts.
The constructor must initialize the thread's instance variables as needed, and schedule the thread so it can run. There are a few nuanced parts to initialize a thread's state:
- You'll see why in a future milestone, but a thread's stack needs to remain allocated even when the thread itself is deleted. For this reason, the stack must be allocated on the heap. Don't worry about freeing it for now. See our C++ guide for info on heap allocation.
- When you initialize a stack, you must specify the initial function it should run. We might think this can just be the function specified by the
mainargument, but later on we will need to do additional prep and cleanup in addition to runningmain, so we can't just pass inmainitself. For that reason, we've provided a wrapper function calledthread_startin the starter code - this is the function that every stack should initially run, and is passed the running thread as a parameter (so you can access its instance variables). Right now, that wrapper function should just execute the thread's main function (though you will also need to leave thewhile (1) {}line as the last line for now, because ultimatelythread_startmust not return), but later we will add additional code to this wrapper function (e.g. when handling interrupts). Note: if you have a variablefuncof typestd::function, you can run it like this:func(). - When using the provided ready queue called
ready_in the starter code, check out a handy C++ reference to learn more about the C++queuetype (common gotchas;front()does NOT remove the element from the queue, andpop()does NOT return the popped element). - To implement
redispatch, you should perform a context switch to the next ready thread usingstack_switch. When the program starts up, noThreadis currently running (we do not count the real system thread as aThread). Therefore, the first time we callredispatch, we will be switching from no thread to a thread, and then subsequent context switches will switch from one thread to another. For this milestone (we'll update this later), assume that only one thread is ever scheduled - in other words, when you callstack_switch, the first parameter should always benullptr(which indicates thatstack_switchshould not save the existing stack pointer). What this means is, once your Threads start executing, you'll abandon the system thread's stack and just use the Stacks you created for each Thread.
Once you've written this code, you should be able to pass the enter test to dispatch your first thread!
Milestone 2: Multiple Threads
In this milestone you will add functionality to switch between threads.
To do this, you will implement the yield method and current 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
if there is no current thread running, and otherwise it should be the current thread's stack.
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 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 (as of this milestone,
there should be no more need for the while (1) {} line in your wrapper).
Thread::exit() should delete the current thread, update the state to
reflect there is currently no thread running, and then switch to the next
ready thread to run. As part of this, we might think that deleting the
current thread should also mean deleting its stack. However, that isn't
safe, because Thread::exit() 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've written this code, you should be able to pass the exit and stack tests!
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 thread.hh
information about this method and what it should do.
A timer interrupt handler.
We'll also need to ensure we properly handle interrupts throughout
the Thread methods.
Your implementation of preemption should use the following function,
provided by the starter code in timer.cc
(the same timer as seen in lecture), 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.
You must decide what the handler should do/be, and specify its name as the handler parameter (prefix it with Thread:: if you specify a thread method).
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::unique_lock. When an IntrGuardobject 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 prefer using IntrGuard objects instead of calling
intr_enable whenever possible to guarantee proper enabling/disabling.
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
redispatchis invoked - 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.
Congratulations, you have a fully-functional thread dispatcher/scheduler!
2. Mutex
Your next task is to implement something you're familiar with as a client - but now you have the tools (specifically your dispatcher from part 1!) to build one yourself. The Mutex class is extremely similar to the standard C++ mutex, with the addition of a mine method. Check out sync.hh for the list of public methods on the Mutex class that you will implement - the documentation in the header file provides more information about each method's behavior.
Complete the declaration of the Mutex class in sync.hh, and
fill in the bodies of the Mutex constructor and the methods
lock, unlock, and mine.
You may assume that your code will only run on single-core systems, so disabling interrupts ensures that no other thread will run. Your solution should look similar to the uniprocessor implementation described in lecture, but must also keep track of its owner thread.
As part of this assignment, you will need to use the public methods you
implemented in the Thread class. For example, a thread can block
itself by calling Thread::redispatch (with interrupts disabled,
of course). Later on, some other thread can wake it up by invoking
the schedule method on the blocked thread. Carefully consider
how some of those methods will come in handy to perform the thread
operations you want to perform.
Make sure to properly enable and disable interrupts as needed to avoid race conditions.
At this point the tests mutex_basic, many_threads and mine should
pass!
3. Condition
Your final task is to implement something else you're familiar with as a client - a condition variable. Now that you have both a dispatcher and a mutex implementation, you have the tools to build one yourself. The Condition class is extremely similar to the standard C++ condition_variable_any. Check out sync.hh for the list of public methods on the Condition class that you will implement - the documentation in the header file provides more information about each method's behavior.
Before you start coding, think a bit about what sort of information you'll need to keep in Condition structures. There are some similarities with the Mutex implementation to consider.
Like with Mutex, the thread operations (blocking a thread, etc.) will require using the public methods from your Thread dispatcher. Carefully consider how some of those methods will come in handy to perform the thread operations you want to perform. Your implementation will also use the public methods of your Mutex class.
There should be no need for any of the Condition methods
to access the private fields of a Mutex object, or vice versa.
Complete the declaration of the Condition class in sync.hh, and
fill in the bodies of the Condition constructor and the methods
wait, notify_one, and notify_all.
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.
Make sure to properly enable and disable interrupts as needed to avoid race conditions.
At this point the remaining tests (cond_basic, two_conds, notify_all, and lock_vs_notify) should pass!
Submitting
Once you are finished working and have saved all your changes, submit by running the submit tool in the tools folder: ./tools/submit. We recommend you do a trial submit in advance of the deadline to allow time to work through any snags. You may submit as many times as you would like; we will grade the latest submission. Submitting a stable but unpolished/unfinished is like an insurance policy. If the unexpected happens and you miss the deadline to submit your final version, this previous 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.hhandthread.cc: flesh out theThreadclass; it should work in both preemptive and non-preemptive modes.sync.hhandsync.cc: implementMutexandCondition- 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!