Project 3: Preemptive Scheduling
Overview
You will extend the
provided kernel with
preemptive multitasking and implement several synchronization primitives.
CJ Bell is the TA in charge.
Design Review
First, take a look at
what we
expect from you in general.
Each of the following is worth 1 point, for a total of 5 points.
-
System Calls:
Study the new system call mechanism and explain how it works. You should
look at entry.S,
interrupt.c, and syslib.c.
-
irq0_entry:
Using what you know of system calls, explain in even greater detail what
this does and how to use it.
-
Condition Variables
-
Semaphores
-
Barriers
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 these versions.
We have provided the program,
tasks which prepares
tasks for your OS to run. Initially, you are given two sets of tasks to
choose from:
given and
tsk_test. For example, to prepare a set of tasks for your OS, run
./tasks given; 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 test your OS with no
processes running, you could type:
$ cp -r given noprocesses
$ emacs noprocesses/tasks.c # edit tasks.c to not have any processes
$ ./tasks noprocesses
Use this to test your kernel.
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 below
-
If nested_count was zero, leave the kernel
the way a system call would
-
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
threads, processes, and system calls that take too long are preempted.
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.
|
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.
|
semaphore_up(sem) |
Increments the semaphore.
|
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. Immediate 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.
|
Checklist
Note: this is not intended to be a thorough list; just a quick
reference.
Grading
The following are each worth 2 points:
- Preemptively scheduled threads and processes
- Condition Variables
- Semaphores
- Barriers
- Writeup, style, and submission mechanics
The 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 discessed in the
design review.
Your writeup does not need to cover how to build and run your code; we
expect those details to remain the same.
We don't have strict rules concerning style; strive for clear code with
meaningful comments.
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
scheduler.h.
-
If you implemented the blocking-sleep extra credit, make sure that
sleep spins before t=15 (before
do_enable_blocking is called) and block afterwards.
When you are ready to submit, first run
make
distclean.
Submit via
blackboard; 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.
-
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.
Extra Credit
Blocking Sleeps
The functions
sleep and
do_sleep work by spinning on
num_ticks.
We did this partially to test your implementation of system call preemption
and partially so you can earn 1 point of extra credit by implementing a
blocking
sleep function. Add the system call
enable_blocking and function
do_enable_blocking such that
sleep spins
for all tasks before
enable_blocking is first
called; after the call,
sleep blocks.
If you implement blocking sleep, add
#define
EC_BLOCKING_SLEEP to
scheduler.h.
Prioritized Task Scheduling
For an additional 1 point of extra credit, change the scheduler algorithm so
that it allocates precessor time proportionally to each task's priority.
If implemented, add
#define EC_PRIORITIES to
scheduler.h