Project 3: Preemptive Scheduling
Due: Midnight 10 November 2009
Overview
You will extend the
provided kernel with
preemptive multitasking and implement several synchronization primitives.
This project is managed by Nick Johnson.
Design Review
First, take a look at
what we
expect from you in general.
The design review is worth a total of 5 points.
-
System Calls: 1pt.
Study the new system call mechanism and explain how it works. You should
look at entry.S,
interrupt.c, and syslib.c.
-
irq0_entry: 1pt.
Using what you know of system calls, explain in even greater detail what
this does and how to use it.
-
Blocking Sleep: 1pt.
You should be able to explain how to implement blocking
sleep, as well as its relationship with the ready queue.
-
Condition Variables: 1pt.
-
Semaphores 1/2pt.
-
Barriers 1/2pt.
For the above three synchronization primitives, specify
- the fields of the structures that you will use, and
- the pseudo-code for each operation.
For example, this is an acceptable explanation for the
previous
system call mechanism:
_start() writes the address of
kernel_entry() to address
0xf00. When a process makes a system call, it
looks up the address of kernel_entry() at
0xf00 and calls it with the ID number of the
system call. kernel_entry() saves the user
context in the PCB, restores the kernel stack, then calls
kernel_entry_helper().
kernel_entry_helper() multiplexes
(switch) its argument and invokes the "do_"
version of the system call. When kernel_entry_helper()
returns, kernel_entry() saves the kernel
stack, restores the user context, then returns to the process.
This is an example of an acceptable explanation of the
previous
implementation of lock:
The lock structure contains a bit indicating whether the lock is held or
not and a queue of threads waiting for the lock.
lock_init() sets the lock to not held and sets up an empty queue.
lock_acquire() enters a critical section and
tests if the lock is held. If it is, then it adds itself to the lock queue
and blocks. Otherwise, it sets the held bit. Finally, it leaves the
critical section. lock_release() also enters a
critical section and tests if there are any threads waiting on the lock.
If there are, it removes the next thread from the front of the queue and
unblocks it; otherwise, it clears the held bit. Finally, it leaves the
critical section.
Getting Started
Download the starter code.
Refer to previous projects for information on building and testing your
project. Some versions of
gcc may not support the
-fno-stack-protector flag; it is safe to remove
this flag from
Makefile if you are using one
of those versions.
We have provided the program,
tasks which prepares
tasks for your OS to run. Its usage is
./tasks taskset.
We have also provided five test sets:
- test_regs
observes the value of all registers both before
and after a loop. Presuming that the process
is preempted at some time during the loop, it
will compare the values before and after, to
see if any registers were improperly clobbered
by preemption. Although this will test all
registers (and flags), note that some classes
of errors may manifest themselves rarely,
and so passing this test does not mean that
your code is perfect.
- test_preempt
creates two
processes which spin as quickly as
possible, but never yield or sleep.
If you see that
both counters are incrementing, then preemption is working
(at least a little bit).
- test_blocksleep
creates two threads, and one goes to sleep.
It tests four things: (i) that the scheduler
survives when all processes are sleeping,
(ii) that a process is not re-entered
during sleep, (iii) that sleep lasts about
the right amount of time, and (iv) the
non-sleeping process is scheduled during
the sleep.
- test_barrier
creates 26 threads and 1 process. The threads
all sleep for a random period of time, and then
barrier_wait on
a common barrier. They repeat this process
several times.
- tsk_test tests
priorities, locks, condition variables,
semaphores, barriers, etc. Its output is self-explanatory.
Note: it does not test the deadlock detection extra credit.
For example, to prepare a set of tasks for your OS, run
./tasks test_barrier and then
make clean. This will update the symbolic links
in your main project directory so that, upon recompiling the project, your
OS will run those tasks.
If you wanted to create your
own test set, or to create
a test set with no processes,
you could copy one of these test
sets to a new directory, and
then modify it to suite your needs.
Use this to test your kernel. Over the course of the project, we may
release additional task-sets to aid your testing.
Preemptive Scheduling
The x86 interrupt mechanism is quite complicated, so we will present
only the details relevant to this project. Interrupts can be triggered
in several different ways: hardware, software, exceptions. The timer
interrupt, IRQ 0, is a hardware interrupt, as is IRQ 7. The system call
mechanism, which uses the
int instruction, is
a software interrupt. Exceptions are generated for a variety of invalid
actions: dividing by zero, for example, will cause an exception.
The support code installs handlers for IRQ 0, IRQ 7, the system call
interrupt, and the various exceptions. Your job for this part is to
complete the handler for IRQ 0,
entry.S:
irq0_entry;
the others are given to you as examples. The main file you will be
working with is entry.S, but you may need to consult other files,
especially
interrupt.[ch] and
scheduler.[ch]. In particular, the definition of
the PCB structure has moved to
scheduler.h.
The processor takes the following actions on an interrupt:
- Disable interrupts
-
Push the flags register, CS, and return IP (in that order)
- Jump to the interrupt handler
Once the interrupt handler has run, it returns control to the interrupted task with
the
iret instruction, which pops the instruction pointer
and the flags register, then reenables interrupts.
As with
entry.S:
syscall_entry, it may be some time before
entry.S:
irq0_entry
actually performs the
iret, since other
tasks may execute in the middle. IRQ 0 is a hardware interrupt,
so you should acknowledge it quickly using the macro
entry.S:
SEND_EOI --
otherwise the hardware won't deliver any more timer interrupts. The
other tasks
entry.S:
irq0_entry must perform are
-
Increment the 64-bit counter,
num_ticks
-
Increment entry.S:disable_count to reflect the fact that interrupts
are off
-
If nested_count is zero, enter the kernel the
way a system call would
Yield.
Take note of the hint about calling C functions.
-
Decrement
entry.S:disable_count
in anticipation of
iret
nested_count is a field in the PCB. For
processes, it is 1 during a system call and 0 otherwise. For threads, it is
always 1. You should study how
interrupt.c
changes this field during a system call.
This may not look very hard, but the tricky part is managing when
interrupts are on, and when they are off. Your solution should satisfy the
following two properties:
-
Safety: To avoid race conditions, interrupts should be off
whenever your code is accessing kernel data structures, primarily the
PCBs and task queues.
-
Liveness: Interrupts should be on most of the time so that
processes that take too long are preempted.
Blocking Sleep
In the provided code, function
sleep
and
do_sleep work by spinning on
num_ticks.
Unfortunately, this loop runs within the kernel, and so it cannot be preempted.
As a result, if one task sleeps, all tasks sleep.
You must modify the provided implementation so that calls
to sleep will block the current process (i.e. take it out of
the ready queue until some appropriate time in the future).
Synchronization Primitives
The main file you will edit here is
sync.[ch], but you will need to consult
queue.[ch] and
scheduler.[ch]. You have to implement condition
variables, semaphores, and barriers. We've given you an implementation of
locks as a reference. These primitives must operate correctly in the face of
preemption by turning interrupts on and off with the functions
enter_critical and
leave_critical.
In the descriptions below, if an operation is performed on a uninitialized
structure, the results are up to you. All unblocks should place the
unblocked thread at the end of the ready queue.
Condition Variables
| condition_init(cond) |
Initializes the specified memory to hold a condition variable.
|
| condition_wait(lock, cond) |
Releases lock, blocks until signalled,
then reacquires lock. The calling thread is
responsible for acquiring lock before
calling this function.
Please see the hints section.
|
| condition_signal(cond) |
Unblocks the first thread to have blocked on
condition_wait(lock,cond). If no thread is blocked on
cond, then do nothing.
|
| condition_broadcast(cond) |
Unblocks all threads that have blocked on
condition_wait(lock,cond). The order in which threads are
unblocked is not important.
|
Semaphores
| semaphore_init(sem, value) |
Initializes the specified memory to hold a semaphore whose starting
value is value; a non-negative integer.
|
semaphore_down(sem) |
If the semaphore is not positive, then block. Decrement the semaphore
once it is positive.
Please see the hints section.
|
| semaphore_up(sem) |
Increments the semaphore.
Please see the hints section.
|
Fairness requirement: in a trace with exactly
n
ups, the downs that complete successfully are the first
n invocations. Calling
semaphore_init(sem, value) counts as
value
ups.
Barriers
| barrier_init(bar, n) |
Initializes a barrier for use by n threads.
|
| barrier_wait(bar) |
Blocks until a total of n threads have
called barrier_wait(bar). The order in which
threads are unblocked is unimportant. Immediately after the last call to
barrier_wait(bar) returns, and possibly
before the other calls have returned, the barrier is available for
another round of waits.
Please see the hints section.
|
Checklist
Note: this is not intended to be a thorough list; just a quick
reference.
-
Preemptive Scheduling
- implement irq0_entry
- yields the current process
- edit: entry.S
-
Blocking Sleep
- Implement do_sleep
- Compute some future time at which the process should unblock.
- Remove the current process from the ready queue, save it somewhere else
- Periodically check if these blocked processes can be added to the ready queue
- edit: scheduler.c
-
Synchronization Primitives
-
Operations on uninitialized structures are undefined; you may
handle such cases as you like.
-
Unblocks place the current process at the end of the ready queue
- Must work with preemption
- Condition Variables
- Semaphores
- Barriers
- edit: sync.c
Grading
This assignment is worth 10 points.
There are 2 points of extra credit,
and so the highest possible grade is 12/10.
Each part of the assignment is
assigned points as follows:
- Preemptively scheduled processes. 2pts.
- (A01) Processes can be preempted
- (A02) Preemption preserves all registers
- Blocking Sleep. 2pt.
- (B01) Processes block on sleep
- (B02) Threads block on do_sleep
- (B03) Scheduler survives when all processes are sleeping
- (B04) Sleep counts time correctly
- Condition Variables. 2pt.
- (C01) Condition.signal wakes one
- (C02) Condition.broadcast wakes all
- (C03) Producer/consumer works when building a queue using your CV.signal
- (C04) Producer/consumer works when building a queue using your CV.broadcast
- Semaphores. 2pt.
- (D01) Producer/consumer works when building a queue using your semaphores
- (D02) Semaphores are fair
- Barriers. 2pt.
- (E01) Barriers successfully wait for all processes
- (E02) Barriers are automatically renewed
You may also submit a readme file.
The readme should be at most 500 words. It should
be plaintext (not DOC, not PDF, not RTF, not DOCX, not HTML,
not XML, not SGML, not TeX, etc). Your writeup should include:
-
A statement regarding the correctness of your project. Are there any
parts that you have not completed, that do not follow the specification,
or otherwise don't work? In what way? If a shortcoming you discuss
cascades into a disproportionate number of failures against test cases,
we may restore some credit. State if you believe your implementation to
be correct in all ways.
-
An overview of your design, modulo aspects that we discussed in the
design review.
Your writeup does not need to cover build procedures;
an unmodified copy of the Makefile will suffice.
Regarding code style:
We don't have strict rules concerning style; strive for clear code with
meaningful comments.
We think simple is better than complicated, but leave that choice up
to you.
However, code which does not compile---or only
does so with warnings---is unacceptable.
Submission
Don't forget to complete the general
requirements for each project!
To ensure compatability with our tests, do the following:
- ./tasks tsk_test
- make clean; make
-
Fix any compilation errors without modifying files within tsk_test. Upon running, the screen should
display which extra credits you have attempted. If the display is
incorrect, then fix your #defines in
common.h.
-
We encourage you to print out diagnostics as a way to
inspect your code. However, please do so in a way that
leaves the output of the supplied tasks intact.
When you are ready to submit, You will submit
only these
files:
- common.h
- entry.S
- helper.S
- interrupt.h
- interrupt.c
- kernel.h
- kernel.c
- queue.h
- queue.c
- scheduler.h
- scheduler.c
- sync.h
- sync.c
- syslib.h
- syslib.c
- util.h
- util.c
Please restrict your changes to these files.
Submit via dropbox; only
one person per group should submit.
Hints
- We have provided a printf function.
-
C functions are allowed to trash registers
%eax, %ecx, and
%edx. Keep this in mind when calling them from assembly.
-
If every process and every thread is sleeping,
then the ready queue is empty. How can you handle
this case? If interrupts are disabled, will
the kernel clock num_ticks
continue to increment?
-
This example suffers from a race condition:
void condition_wait(lock_t *lock, condition_t *cond) {
lock_release(lock);
/* 1 */
enter_critical();
block(&cond->wait_queue);
leave_critical();
lock_acquire(lock);
}
At location 1, we may be preempted, then another thread can acquire the
lock and signal, resulting in a lost wakeup.
-
As does this:
void semaphore_up(semaphore_t *sem) {
enter_critical();
++sem->value;
unblock_one(&sem->wait_queue);
leave_critical();
}
void semaphore_down(semaphore_t *sem) {
enter_critical();
if(sem->value <= 0) {
block(&sem->wait_queue);
}
--sem->value;
leave_critical();
}
Consider this trace: thread 1 calls down, but the value is 0, so it
blocks; thread 2 calls up, unblocking thread 1; before thread 1 can run,
however, thread 3 calls down; thread 1 now decrements the value to -1.
Consider how to satisfy the fairness condition before applying the
obvious fix.
-
And this:
void barrier_wait(barrier_t *bar) {
enter_critical();
if(--bar->value > 0) {
block(&bar->wait_queue);
++bar->value;
} else {
unblock_all(&bar->wait_queue);
++bar->value;
}
leave_critical();
}
Suppose bar->value is initially 4, and thread
4 is the last of four threads to call
barrier_wait(bar). Thread 4 will unblock the other threads and
increment bar->value to 1. Now, if thread 4
immediately calls barrier_wait(bar) again, it
will return immediately, contrary to the specification.
-
We have provided a system call named write_serial,
which writes a character to bochs' serial port. Additionally, we have configured
bochs so that everything written to its serial port is in turn written to a file
named serial.out on the real computer.
This could be helpful for debugging.
However, please ensure that the code you submit does not
use write_serial,
since we may use it for testing purposes.
-
Remember that you can attach GDB to the
programs running within bochs. The provided files include
a properly configured .gdbinit file.
To enable debugging, uncomment the gdbstub line
at the top of bochsrc, then run bochs as
usual. After bochs has started, and in a separate terminal, start gdb with the command
gdb kernel, then issue the command
target remote localhost:1234.
Type c (for continue) to run; at any time, you
can press CTRL-C in gdb to pause the simulator, and then use commands
such as bt (for backtrace), b
(for set breakpoint), etc.
Extra Credit
Prioritized Task Scheduling
For an additional 1 point of extra credit, change the scheduler algorithm so
that it allocates precessor time proportional to each task's priority.
If you choose to do this, we recommend Lottery Scheduling as described in the book.
One way to test your implementation of priority scheduling
would be to modify test_preempt,
so that each process has a different priority. You can then
watch how the two counters grow at different rates.
You do not need to submit these testcases.
If implemented, add #define EC_PRIORITIES to
common.h.
Automatic Deadlock Detection
For an additional 1 point of extra credit, write an algorithm which automatically
tests for a particular class of deadlock. This algorithm
is conceptually simple, but may be difficult to implement:
Imagine a directed graph in which every process and every
lock is a vertex.
Draw an edge pi → lj
if process pi blocks while trying to acquire lock lj.
Draw an edge lm → pn if lock lm is held by process pn.
Whenever this graph contains a cycle, a deadlock has occurred.
You don't need to implement this as a graph algorithm per se,
but for extra credit you do need to implement something which
is at least as sensitive as this this algorithm, and which has
no false positives.
Your implementation should only test for a deadlock if
it does not successfully acquire a lock after a one
second timeout. It should check again after every
second of waiting. This way, the cost of detection is
amortized away.
If a deadlock is detected, lock_acquire
fails with return value 1. In all other situations,
lock_acquire returns 0.
Only one call to lock_acquire
should fail for a single deadlock situation.
A failed lock_acquire should
not affect the state of the lock (it should remain locked).
This enables one process to recover from failure,
and all other processes to continue as normal.
For instance, that process may choose to release all
locks its locks and then retry.
This style of deadlock detection is only relevant to
locks, and is not relevant to semaphores, condition variables
or barriers. In fact, it's only relevant to a restricted
use of locks, in which locks are only unlocked by the
same process which locks them.
To test your implementation of deadlock detection,
create a new test set containing processes which
will necessarily deadlock. These processes should report if
lock_acquire ever
returns failure. You do not need to submit these
new testcases.
If you implement this extra credit, you should
add #define EC_DEADLOCK to
common.h.