CMSC 412

Project 3: Multiprogramming, Part II

Due: April 1, 1996

Introduction

In this assignment you will enhance your current operating system by adding three new features: (1) quantum-based preemption (making your scheduler implement a Round Robin strategy and a multi-level feedback strategy), (2) semaphore operations, and (3) blocking I/O with your keyboard handler.

Files Provided

We have provided files for your use. These files include:

You will use proj2.c to run tests on the two scheduling algorithms. The other files have been updated and should be warning-free under Borland C++ 4.5.

Technical Description

Sleep and Wakeup

Most of the work in this project will go into implementing ``sleep'' and ``wakeup'' mechanisms. Your kernel will support the following two functions:

   Ksleep( int semaphoreID )
   Kwakeup( int semaphoreID )

These functions are not top-level system calls, such as Proc_start. These are function calls that the kernel will use to help implement system calls such as P() and V().

In project 2, a process was in one of two states. Either it was running or ready. There were queues associated with each of these states. In this project, a process can be in a third state. It can be blocked. A blocked process is not ready to run, and is usually waiting for some event (such as an interrupt) to wake the process up. If a process is blocked, it is placed on a blocked queue.

A process is blocked if it would have busy waited if it ran. This occurs in two cases. A process is either waiting for some I/O operation to complete (the interrupt handler will then wake a process when the operation completes), or it is waiting for some other process to execute a V() operation if it is blocked because of a call to P(). A blocked process will have its PCB placed on a blocked queue (also called a sleep queue), and hence will not be considered by the scheduler when it comes time to pick a new process's PCB to place on the run queue. The scheduler will only choose PCBs from the ready queue. By blocking a process, we make more efficient use of the CPU.

You will implement two functions that are only visible to the kernel (they are not system calls) which will place PCBs into a semaphore (blocked) queue, and remove them from the semaphore queue. These functions are Ksleep() and Kwakeup().

Ksleep() will put the running process to sleep by moving the PCB from the run queue to one of several semaphore queues. The parameter, semaphoreID , will determine which queue to place the PCB on. A suggestion for implementing the semaphore queues is to use an array. The array will be indexed by semaphoreID . Since Ksleep() puts the running process to sleep, it should schedule a new process to run by calling Scheduler().

Kwakeup()will be used wake a process up. You will use semaphoreID to index into the semaphore array, as mentioned previously, find the appropriate semaphore queue, and dequeue the first PCB (if there is one) from this queue, and place it on the ready queue. Kwakeup() will normally be called during an interrupt handler (and System_service()). The process that called Kwakeup() will eventually resume (assuming it isn't context switched). Contrast this with Ksleep() which puts the running process to sleep, and schedules a new process.

Ksleep() should only be called while executing a system call (i.e., in System_service). Do not use Ksleep() while executing a general ISR such as Key_handler().

Why? Recall that an interrupt handler can go off at any time. Ksleep() puts the current running process to sleep (i.e., blocks the current process). If you use Ksleep() in an interrupt handler like Key_handler(), you will be randomly putting processes to sleep. This is not a good idea. The user process should decide when it goes to sleep. Either it goes to sleep from a P() call or a system call (typically, dealing with I/O).

On the other hand, you can call Kwakeup() from either a system call or an interrupt service routine -- but not directly from a user program. The reason you are allowed to call Kwakeup() from an interrupt handler is because it merely moves a PCB from a semaphore queue to the ready queue. The process being interrupted will continue to execute after the interrupt completes. Kwakeup()will be used in the implementation of V() which will be explained later.

Modes

There are three modes of execution: interrupt, kernel and user. However, you will only keep track of two modes: kernel mode and non-kernel mode (which will be called user mode). In kernel mode (i.e., while servicing a system call), the running process may not be context switched. If a timer interrupt goes off while executing in kernel mode, and if the process is slated to be context-switched, this action will be postponed until the end of the kernel code, and the system call will be allowed to run until completion. We will describe when and how to switch processes if it occurs in kernel mode.

Timer Preemption

DOS provides a timer interrupt service routine (ISR) for the timer chip in the PC that runs continuously with a frequency of 18.2 Hz (the period is about 55 milliseconds). You will replace the DOS timer ISR with your own. You will do this by resetting the interrupt vector 8 to your ISR. Like all other interrupt handlers, make sure that you save the address of the old timer handler -- e.g., in a variable declared as void interrupt (*OldtimerISR)(void). A new process will be initialized to have a full quantum, which is some integer multiple of the number of times the timer interrupt goes off before the process is context-switched. For example, this value might be 4. When a timer interrupt occurs, three possibilities exist:

  1. if the quantum has not expired, decrement its value by 1.
  2. if the quantum has expired (value is 0), and the current process is running in USER mode, you will preempt the current process by moving it from the run queue to the ready queue, and invoking the scheduler. The Yield_process() interrupt can be used to switch processes in this case. The newly scheduled process should be have its quantum reset (say, back to 4).
  3. if the quantum has expired, but the current process is running in KERNEL mode, then a flag called preempt is set to 1 to indicate expiration. This flag is examined at the end of System_service, and the current process will be preempted at that time. Again, preemption can be performed via Yield_process().

You must implement a command line switch which allows you to set the quantum. If your executable were called kernel, then, you should be able to do the following:

   % kernel -q 4

The -q option takes a single integer which represents the number of times the timer interrupt goes off before the user process is context switched. For example, if this value was set to 4, then a process that has just placed on the run queue will have its quantum set to 4. After the fourth occurrence of the timer interrupt, the process will be preempted, and a new process scheduled to run. This new process will also be given a full quantum of 4 units. The only time a process does not complete its full quantum is if it is blocked or terminates. If the -q option is not there, use a default value. Make sure to check if the quantum is positive. If not, use the default value.

There is one more function that must be performed in all three circumstances. Note that the old timer ISR is in charge of maintaining the TOD clock, the floppy disk drive, as well as other duties. To keep this functionality, you can make a direct "call" to the old ISR by simply executing OldtimerISR(). The Borland compiler is smart enough to generate code to simulate an INT instruction, while bypassing the CPU's interrupt architecture, i.e., it translates a call to OldtimerISR() to:

     OldtimerISR(); ---> PUSHF               ;  push flags
                         CALLF  oldtimerISR  ;  make call

Since the old timer ISR sends the external interrupt controller the EOF signal ("outp(0x20, 0x20)"), you should not do this in your timer ISR. You can make a call to OldtimerISR() as the first statement in your timer interrupt handler. If so desired, the same direct calling technique can be used to interface with Yield_process() from the timer ISR.

The Effect of Modes on Scheduling

Each PCB should now have a new field that describes the current execution mode (either USER mode or KERNEL mode). When System_service is entered, save the mode, then set it to KERNEL. Restore the saved mode on exiting. System_service now looks like this:

save_mode = run->mode;

run->mode = KERNEL;

switch(type) {
...
}
run->mode = save_mode;
if (preempt && run->mode == USER){
preempt = FALSE;
geninterrupt(0x63); /* To Yield_process */
}
return;

Timing

One way to compare scheduling algorithms is to see how long it takes a process to complete from the time of creation to the termination of the process. You will investigate these differences by implementing a system call, Get_time_of_day() which is modelled after the UNIX system call, gettimeofday().

Get_time_of_day() will return the value of a global variable called Ticks. Ticks should be initialized to 0 in main(). Each time the timer interrupt goes off, increment Ticks. You can use this system call to determine how long a process has run in terms of ticks. This can be accomplished by calling Get_time_of_day()once at the beginning of the process (in the user code) and once at the end. You can calculate how long the process took to run, as well as when the process first got scheduled (based on ticks). Notice that there is no attempt to remove time spent by other processes when calculating how much time is used by a process. For example, if process A context switches out, then process B runs, process B's quantum will be included in the amount of time used by process A. This is known as "wall clock" time. It is possible to only count the amount of time the process spends in the run queue and ignore the time used by other processes. However, you will not do this.

Implementing Multilevel Feedback

There are many scheduling algorithms, each exhibiting its own behavior. You will implement a FIFO scheduler (the same as in project 2, but includes preemption) as well as a multilevel feedback scheduler. In the implementation of a FIFO scheduler, you will implement the ready queue as a single FIFO queue (as in project 2). For the multi-level feedback scheduler, you will use four FIFO queues instead of one to implement a ready queue. Each queue in a multilevel feedback scheduler is assigned a priority level. The queues will be numbered 0 through 3, with 0 being the highest priority, and 3 being the lowest.

A newly created process's PCB will be placed on the ready queue of highest priority (i.e., 0). If a process remains on the run queue for the full quantum, then when it is slated to be placed back on the ready queue, it will be placed on the next lowest priority queue (1, if the process was new).

Each time a process completes a full quantum, it will be placed on the ready queue at the next lowest priority until it is at priority 3, at which point it can not go any lower. Hence, CPU intensive processes will be eventually placed on the lowest priority queue. If the process is blocked, record the priority level prior to blocking. When this process becomes unblocked, you will place it on the ready queue with this priority number.

To schedule a new PCB to run, look at the head of the highest priority queue. If there is a PCB there, place it on the run queue. If not, go to the next lowest priority queue, and keep repeating until you find a PCB. The scheduler always picks the PCB with the highest priority to run next. This may mean low priority processes are starved.

You must implement a command line argument for choosing between multilevel feedback versus FIFO. Use a -f command line option to indicate FIFO, and a -m command line option to implement the multilevel feedback. FIFO should be used if neither of these switches are specified.

The choice between which scheduler to use should be made within the function Scheduler() using some sort of if statement. Any function that calls the Scheduler() should be unaware which scheduling algorithm is being used (i.e., do not pass the scheduling type as an argument). It should only be aware that some PCB from is being placed from the ready queue to the run queue, and started up. The scheduler, however, will obviously need to know which scheduling algorithm is being used (as well as other functions moving the PCB into and out of the ready queue). It will determine the scheduling algorithm by referring to a global variable. This variable, which you define, should be set once in main() based on command line arguments (or a default value if no scheduling algorithm is specified).

Keyboard Handler

Your Get_char() routine should now be implemented as a system call. (This should be done as before, where Get_char() activates System_service, etc.)

When a process attempts to read a character from the character queue, and the queue is empty, you should block the process via Ksleep(). Likewise, when the keyboard ISR sees that the queue is empty, and then puts a character in, it does a corresponding Kwakeup(). These calls are made by the kernel and keyboard handler. Note P() and V() are not called.

Semaphores

You will add the following system calls to your kernel:

   int Create_semaphore(char *name, int ival)
   int P (int s)
   int V (int s)

Blocking and unblocking by semaphore operations will be implmented in System-service via Ksleep() and Kwakeup().

Create_semaphore(name,ival) is a request by the current process to use a semaphore. A process can not call P() or V() unless it calls Create_semaphore(). Think of it as a constructor. The user gives a name for the semaphore, as well as the semaphore's initial value. It will get back a semaphore ID, an integer between 0 and N-1. You should be able to handle at least 20 semaphores. If there are no semaphores left (i.e., there were N semaphores with unique names already given), a negative number can be returned indicating an error.

In System_service(), you will check if another process has made this system call with the same name. If so, you must return back the semaphore ID (SID) associated with this name. ival is ignored in this case. The SID value returned will allow the user process to tell the kernel which semaphore it wants to use. You will also add this SID to the list of semaphores the current process can use, as well increment the count of registered users which are permitted to use the semaphore.

If this is the first time Create_semaphore() has been called by the name passed in, then find an unused SID, and initialize the value of the semaphore variable to ival. Again, add the SID to the list of semaphores that the current process can use, as well as incrementing the semaphore's count of registered users.

Whenever a user process calls P() or V(), the kernel will check if the user has permission to make this call. It will do so by checking if the process has the SID in its list of SIDs that it can access (which is why you needed to create such a list). If it is there, it will be allowed to execute P() or V(). If not, the kernel should return back a negative value.

You will want to implement the semaphores as described in the text (Silberschatz and Galvin) with two exceptions. First, unlike the text, the integer passed to P() or V() is NOT a semaphore variable. It is a semaphore ID. The kernel will be able to associate the semaphore ID with the semaphore's value. That way, you aren't permitted to look at the value of the variable. Second, you should decrement the value after you make the test, not before. This way, the semaphore value never goes negative.

The V()system call should use Kwakeup() as part of its implementation. The semantics of V, as defined in your textbook, does not say anything about which process is woken up. All it says is that the value of the semaphore variable is incremented by 1.

The implementation of V() can be done in one of several ways. In all variations, the value of the semaphore variable is incremented by 1. You will implement a version of V() which only wakes up the PCB at the head of the semaphore queue. (To wake up a process means to place its PCB in the ready queue). Alternatively, one could wake up an arbitrary PCB in the queue, or even place all the PCBs from the semaphore queue to the ready queue. You should find out why no problems occur when all PCBs are placed on the ready queue (all but one should go to sleep again). This version is somewhat inefficient but allows the scheduler to decide which process to wake up. You will not implement this version.

PCB and Proc_term Modification

A new field will be added to your PCB; that of a semaphore list. This list will contain all SIDs returned from a Create_semaphore() called by that process. When a process terminates, you will need to free up the semaphores it created. You can write a function called Free_semaphores() to handle this. You will need to update the number of registered users for the semaphores freed. If this number drops to 0, then you can add the SID to the list of free SIDs that can be used.

Scheduler

When the scheduler picks a new process to from the ready queue to place on the run queue, it will initialize the quantum, and then call Dispatch(). The scheduler will base the choice of scheduling algorithm on a global variable indicating whether it should use a FIFO scheme or multi-level feedback scheme.

If there is no process to execute and processes are still blocked, then the kernel will switch to a special stack and halt (normally, interrupts are handled on the stack of the currently running process, but if there isn't one, there's no stack either). When an interrupt breaks the CPU out of the halt, the scheduler checks the ready queue and schedules a process if there is one. This is how processes that are awakened in an interrupt service routine get to run.

while (no process on ready queue) asm HLT;
schedule a process

Information You Should Have Learned By Now -- Interrupts

Normally, the kernel operates with interrupts enabled. In previous projects, you have left the interrupts disabled, but this means decreased opportunities for concurrency in the kernel. There is no need to disable interrupts for mutual exclusion with other processes executing in kernel mode, since kernel mode cannot be preempted.

However, interrupts need to be turned off when a system call (such as Get_char()) manipulates the same data structures as its corresponding ISR (e.g., Key_handler()). Interrupts should be turned off when global kernel data structures are manipulated (in Ksleep() and Kwakeup(), for example).

Interrupts also need to be turned off when context switching and when switching stacks (such as in the scheduler). Interrupts need to be on when the scheduler halts, since that's the only way to get out of the halted condition (e.g., the timer interrupt can start up the CPU again).

The end-of-interrupt signal should be sent to the PIC (port 0x20) when the current interrupt condition is cleared. (Again, the old interrupt handler, which yours should call, does this.)

What to turn in

As with project 2, all files necessary to run your project must be located a directory labeled by the project number (say, P3) of your 3.5" diskette. The only required filenames are proc.c, proc2.c, and makefile. Note: your new processes need to use P() and V() to insure that printed output is legible. With timer-driven scheduling, you never know when a process will be switched. If this occurs in the middle of a "Cprintf" invocation, your output may look like garbage.

Also, run several tests on proc2.c, varying the quantum length, as well as the two scheduling algorithms. Provide a hardcopy write-up listing the results, as well as explaining why the results occurred. The exercise is meant to let you consider the effects of quantum length and scheduling algorithms on the run of several processes.

You should run the tests with quantum values of 1, 5, 10, and 100, at the very least, and if you have time, run it with times of 25, 50, and 75, as well. Use both algorithms. This should give you 14 tests to run.