Assigned: | Wednesday, September 21. |
Groups Due: | Monday, September 26, 11:59 pm. |
Code Due: | Friday, October 7, 11:59 pm. |
Writeup Due: | Thursday, October 13, 11:59 pm. |
Collaboration Policy: | Level 1 |
Group Policy: | Pair-optional (strongly recommended) |
This project will give you experience writing a concurrent program using threads and synchronization primitives. Your task is to write a program that simulates an OS scheduler for disk requests. In your simulator, each thread that wants to use the disk will be able to issue requests to the disk scheduler concurrently with other threads. In addition to writing your scheduler, you will write a short report that discusses your technical design.
This project writeup consists of two parts: the first part describes the threading infrastructure you will use, while the second part describes the disk scheduler that you will write on top of the infrastructure.
The infrastructure for this project consists of two parts: a thread library that will handle the creation and management of threads, and an interrupt library that will manage (simulated) hardware interrupts triggering thread preemptions. Each part of the infrastructure is described below.
Rather than using an OS-provided threading interface like pthreads
, you will use
a user-level thread library that is provided to you. Your disk scheduler will use this library
to create threads and manage synchronization.
The functions provided by the thread library are described below.
Each of these functions returns 0 on success and -1 on failure,
except for thread_libinit
, which does not return at all on success.
int thread_libinit(thread_startfunc_t func, void* arg) thread_libinit initializes the thread library. A user program should call thread_libinit exactly once (before calling any other thread functions). thread_libinit creates and runs the first thread. This first thread is initialized to call the function pointed to by func with the single argument arg. Note that a successful call to thread_libinit will not return to the calling function. Instead, control transfers to func, and the function that calls thread_libinit will never execute again.
int thread_create(thread_startfunc_t func, void* arg) thread_create is used to create a new thread. When the newly created thread starts, it will call the function pointed to by func and pass it the single argument arg.
int thread_yield(void) thread_yield causes the current thread to yield the CPU to the next runnable thread. It has no effect if there are no other runnable threads. thread_yield is used to test the thread library. A normal concurrent program should not depend on thread_yield; nor should a normal concurrent program produce incorrect answers if thread_yield calls are inserted arbitrarily.
int thread_lock(unsigned lock) int thread_unlock(unsigned lock) int thread_wait(unsigned lock, unsigned cond) int thread_signal(unsigned lock, unsigned cond) int thread_broadcast(unsigned lock, unsigned cond) thread_lock, thread_unlock, thread_wait, thread_signal, and thread_broadcast implement mutex locks and condition variables with Mesa semantics in the thread library. A lock is identified by an unsigned integer (0 - 0xFFFFFFFF). Each lock may also have a set of condition variables associated with it (which are numbered 0 - 0xFFFFFFFF). Thus, a condition variable is uniquely identified by the tuple (lock number, cond number). Programs can use arbitrary numbers for locks and condition variables (i.e., they need not be numbered from 0 - n).
In addition to the thread library, you are provided with an interrupt library that can generate interrupts (resulting in thread preemptions) in various ways. Remember that a thread can be preempted by an interrupt at any time, and thus a concurrent program must be robust to arbitrary preemption. The interrupt library is useful in testing for synchronization bugs in a concurrent program (like your disk scheduler).
The interrupt library provides a single function for use by
application programs, start_preemptions
, which is detailed below:
void start_preemptions(bool async, bool sync, int random_seed) start_preemptions() can be used in testing to configure the generation of interrupts (which in turn lead to preemptions). The sync and async parameters allow several styles of preemptions: 1. async = true: generate asynchronous preemptions every 10 ms using SIGALRM. These are non-deterministic. 2. sync = true: generate synchronous, pseudo-random preemptions before interrupts are disabled and after interrupts are enabled by the thread library. You can generate different (but deterministic) preemption patterns by changing random_seed. start_preemptions() should be called at most once in the application function started by thread_libinit(). Make sure this is after the thread system is done being initialized. If start_preemptions() is not called, no interrupts will be generated.
In particular, note that you can enable two different types of preemptions (either separately or together). Asynchronous preemptions will trigger effectively random thread preemptions while running the program. Synchronous preemptions will trigger thread preemptions deterministically whenever the thread library enables or disables interrupts inside the library code; preemptions at these times often expose synchronization bugs if they exist.
Also observe that a thread preemption is essentially nothing more than an added call to thread_yield
, hence why a correct concurrent program should be able to function with arbitrarily added calls to thread_yield
at any point in the program.
Here is a short program that uses the thread library, shown along with the
output generated by the program. Make sure you understand how the CPU is
switching between two threads (both executing the loop
function). The local variable i
is on the stack and so is private to each thread, while g
is a global variable and
so is shared among the two threads.
#include <iostream> #include <cstdlib> #include <stdint.h> #include "thread.h" using namespace std; int g = 0; // global (shared by all threads) void loop(void* a) { char* id = (char*) a; cout << "loop called with id " << id << endl; for (int i = 0; i < 5; i++, g++) { cout << id << ":\t" << i << "\t" << g << endl; if (thread_yield()) { cout << "thread_yield failed\n"; exit(1); } } } void parent(void* a) { int arg = (intptr_t) a; cout << "parent called with arg " << arg << endl; if (thread_create((thread_startfunc_t) loop, (void*) "child thread")) { cout << "thread_create failed\n"; exit(1); } loop((void*) "parent thread"); } int main() { if (thread_libinit((thread_startfunc_t) parent, (void*) 100)) { cout << "thread_libinit failed\n"; exit(1); } }
Below is a sample output of running this program:
parent called with arg 100 loop called with id parent thread parent thread: 0 0 loop called with id child thread child thread: 0 0 parent thread: 1 1 child thread: 1 2 parent thread: 2 3 child thread: 2 4 parent thread: 3 5 child thread: 3 6 parent thread: 4 7 child thread: 4 8 Thread library exiting.
Note that the final line of output Thread library exiting.
comes from the thread library itself rather than the program above.
The disk scheduler in an operating system receives and schedules disk I/Os for multiple threads. The purpose of the disk scheduler is to provide concurrent but orderly access to the disk, as well as to ensure that all threads are able to complete their disk requests.
The disk consists of a set of tracks, representing particular locations on the disk. A disk request consists of a particuclar thread accessing a particular track number. For example, thread #5 might issue a request to access track #37. Once a request is made by a thread, that thread is normally blocked until the disk scheduler completes its request (synchronous I/O). The queue of pending disk requests can contain at most a particular number of requests; if the request queue is full, threads must wait to issue new requests.
Servicing a disk request consists of moving the disk head to the desired track. Since the disk head is a physical component of the disk, at any given time, the head is located at a particular track number of the disk. Servicing a request thus requires moving the disk head from the current track to the track of the next request. This movement is called seeking.
Note that requests in the disk queue are typically NOT serviced in FIFO order. For this project, your scheduler will always choose to service the request that is closest to the current track. This scheduling order is called Shortest Seek Time First (SSTF).
Your program should start by creating a specified number of requester threads to issue disk requests and one servicer thread (i.e., the actual scheduler) to service disk requests. Each requester thread should issue a series of requests for disk tracks as specified by an input file. Each request is synchronous; a requester thread must wait until the servicing thread finishes handling its last request before issuing its next request. A requester thread finishes (i.e., exits) after all the requests in its input file have been serviced. The servicer thread continues until all requester threads have finished.
The servicer thread should handle requests in SSTF order, with the disk initialized to a starting track of 0. In order to minimize average seek distance, your servicer thread should keep the disk queue as full as possible. Specifically, the servicer thread should only actually handle a request when the disk queue is full, or when all remaining requester threads have an outstanding request in the queue. This approach gives the servicer thread the largest number of requests to choose from (thus minimizing seek time).
Your program will be called with a variable number of command-line arguments. The
required first argument is an integer specifying the maximum number of requests that
the disk queue can hold. Each additional argument specifies the input file to be
used by a requester thread (and thus, the number of additional arguments determines
the initial number of requester threads). For example, if the scheduler executable
is called disk
, then it could be executed as follows:
./disk 2 disk.in0 disk.in1 disk.in2
In the above example, the size of the request queue is 2, and there are three
requester threads, reading from disk.in0
, disk.in1
, and disk.in2
,
respectively. Note that the size of the request queue is not related to the number of
requester threads, and vice versa.
The format of each requester input file is simple: each line of the input file specifies the track number of the next request, in the range 0 to 999. Examples of possible input files are given below. Your program may assume that all input files are formatted correctly.
Requester threads are assigned sequential IDs starting from zero, in the order they were given on the command line (e.g., requester 0 reads from disk.in0
in the above example). IDs are part of the output that the disk scheduler should produce,
as detailed below.
After issuing a request, a requester thread should generate output exactly as follows (note the spaces in the strings):
cout << "requester " << requester_id << " track " << track << endl;
Once this line is printed, the request is in the queue and available to be handled by the servicer thread.
After 'handling' a request, the servicer thread should generate output exactly as follows (again note the spaces in the strings):
cout << "service requester " << requester_id << " track " << track << endl;
Once this line is printed, a request is considered to be out of the request queue. Since the program is just a simulator, nothing on disk is actually read or written; handling a request simply consists of printing this message.
Your program should not generate any other output except for these lines (and the final Thread library exiting.
message that is automatically printed by the thread library).
Here is an example set of input files (disk.in0
through disk.in4
). These sample input files are also included in the project starter files.
disk.in0 disk.in1 disk.in2 disk.in3 disk.in4 -------- -------- -------- -------- -------- 53 914 827 302 631 785 350 567 230 11
Here is one of several possible correct outputs from running the disk scheduler with the following command (which uses a queue size of 3 and five requester threads):
$ ./disk 3 disk.in0 disk.in1 disk.in2 disk.in3 disk.in4 requester 0 track 53 requester 1 track 914 requester 2 track 827 service requester 0 track 53 requester 3 track 302 service requester 3 track 302 requester 4 track 631 service requester 4 track 631 requester 0 track 785 service requester 0 track 785 requester 3 track 230 service requester 2 track 827 requester 4 track 11 service requester 1 track 914 requester 2 track 567 service requester 2 track 567 requester 1 track 350 service requester 1 track 350 service requester 3 track 230 service requester 4 track 11 Thread library exiting.
Before you start writing your scheduler, make sure you fully understand the operation of the thread library described in part 1 and the example program given there. Ask questions if you aren't sure!
You should also plan in advance how you will implement the synchronization logic in your program (e.g., what the locks and condition variables will be and how they will be used by the various threads).
While writing your scheduler, you may find the following specific tips useful:
ifstream
rather than fstream
) to avoid overwriting the input files.start_preemptions
as described in part 1 of the writeup. While enabling preemptions does not guarantee that any synchronization bugs will be exposed, it makes it much more likely.valgrind
to check for memory safety issues, you are likely to encounter many warnings that
may not make sense. Valgrind gets confused by the user-level thread context switches that happen in the thread library, and therefore
outputs erroneous warnings. While it is possible to add in Valgrind-specific code hooks to address this issue, doing so is beyond the scope of the project. Therefore, Valgrind is likely to be of limited use for this project.gdb
functionality should work as expected for debugging (e.g., setting breakpoints, locating segfaults, etc.), although a few things, such as stepping through context switches, are best avoided.cerr
instead of cout
to avoid impacting the autograder.As for Project 1, starter code will be distributed via GitHub. GitHub repositories will be made available once groups are assigned (each group will share one repository).
As this is the first group project, you may be making more extensive use of git
for collaborative development than in the past. If you haven't done so previously, it is a good idea to go through Part 3 of the Git tutorial, which covers some specific topics applicable to collaboration (most significant of which is handling merge conflicts).
The starter code in your repository includes the thread library files thread.o
and libinterrupt.a
, a header file for the thread functions thread.h
, sample input files disk.in0
through disk.in4
, the scheduler source code file disk.cc
, and a Makefile
that will compile the program using the thread library files. Write your code in disk.cc
and do not modify any other source files. You are also encouraged to create your own test input files beyond those provided.
When writing your code, you may use any functions included in the standard C++ library and the STL (using the C++11 standard). You should not use any non-standard C++ libraries or code.
You can submit your program to the autograder as follows:
submit3310 2 disk.cc
Remember that autograder tracks submissions by group, not by user. Autograder submissions made by any member of a group will be sent to all group members and will count as the group's single daily submission (or one of the group's three bonus submissions).
In addition to implementing your disk scheduler, you will also write a short paper (2-3 pages or so) that describes your scheduler. Follow the basic format outlined below (which is typical for a systems-style paper such as this):
While you do not need to rigidly adhere to this structure, it is a good basic framework to follow. The most common type of feedback that students often receive on writeups like this is excessive emphasis on fine-grained coding details (e.g., listing all function names in the program, discussing the details of specific variables, etc). These sorts of details are usually better conveyed by your code itself, and they are also less important and less interesting than the higher-level design choices that you made. Some code-related details are appropriate to include in an implementation section, but don't go overboard on this section (and spare yourself the work of doing so!).
Your writeup should also clearly state anything that does not work correctly and any major problems that you encountered.
To submit your group's writeup, add your writeup to your Git repository as a PDF named writeup.pdf
(by the writeup deadline). Remember to commit and push and after adding your file. Typesetting your writeup in LaTeX is encouraged but not required.
Your project will be graded on program correctness, design, and style, as well as the quality of your project writeup. Remember that the autograder will only check the correctness of your program (and nothing else)!
You can (and should) consult the Coding Design & Style Guide for tips on design and style issues. Please ask if you have any questions about what constitutes good program design and/or style that are not covered by the guide.