CMSC 412
Project 3: Multiprogramming, Part II
Due: April 1, 1998
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 proc2.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.
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 could be in one of two states. It could be 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.
Typically, a process is blocked when it is waiting for I/O (which is slow) to complete. Such a process would normally busy wait if it were run. By placing a PCB on a blocked queue, busy waiting is prevented. The scheduler only selects processes from the ready queue, not the blocked queues. An interrupt will usually signal that the I/O operation is complete and unblock the process (place it in the ready queue). A process can also be blocked by calling P() when the value of the semaphore is 0.
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 and placing it on one of several semaphore queues. The parameter, semaphoreID , indicates which queue to place it on. A suggestion for implementing the semaphore queues is to use an array. The array will be indexed by semaphoreID . Since the running process is being put to sleep, you will need to schedule a new process 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.
The V() system call will 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 many 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.
Ksleep should only be called while executing a system call (i.e., in System_service), or while in an interrupt handler associated with a system call (specifically, Yield_process). 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 a 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.
There are three modes of execution: interrupt, kernel and user. 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 in kernel mode, and the process is slated to be context switched, this will occur at the end of the kernel code, and the system call will be allowed to complete. We will describe when and how to switch processes if it occurs in kernel mode.
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 (about 55 milliseconds). You will replace the DOS timer ISR with your own. You 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); When a timer interrupt occurs, your ISR determines whether or not the quantum has expired. You will set the quantum to be a positive integer (say, 4). Three possibilities exist:
You must implement a command line switch which allows you to set the quantum. If you 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 uses up its quantum, and is then scheduled to be context switched. 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;
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().
Get_time_of_day() will return the value of a global variable called Ticks. Ticks will 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. You can do this 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. For example, if your process context switches out, then runs a second process, the second process’s time during the context switch will be included in the first process’s total time. This is known as "wall clock" time. One can also just calculate the time used by the process itself. However, you will not do this.
Implementing Multilevel Feedback
There are many scheduling algorithms. You will implement a FIFO scheduling algorithm (the same as in project 2, but includes preemption) as well as a multilevel feedback scheduler. In a FIFO implementation, all PCBs sit in a FIFO queue. In a multi-level feedback scheduler, you will use 4 queues instead of 1. Each queue 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 the 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 (1, if the process was new). Each time a process completes a full quantum, it will be placed on the ready queue with 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, the priority level will remain the same, i.e., you will not decrement the priority, when the process is placed back on the ready queue.
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. Scheduling always attempts to look at the highest priority queue and work down. 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.
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 accomplished 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. 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.
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()will 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.
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.)
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.