Watch this space for announcements. See also the FAQ (Frequently Asked Questions) page.
Assignments 4 and 5 will use a common software structure called MiniKernel, which simulates a simple operating system. In this assignment, you will manage multiple programs accessing a disk and improve their performance by adding a disk buffer cache. In the next assignment, you will build a file system.
There is a FAQ page for this project. Check it frequently for updates.
First, read and understand the MiniKernel documentation. Copy the code from ~cs537-1/project4 and run the examples.
cd ~/private mkdir p4 cd p4 cp ~cs537-1/public/project4/* . makeTo make sure you understand how all the pieces fit together, implement getTime() as described in the documentation.
Disks come in lots of different shapes and sizes, so we'll also need some facility for the user program to inquire the geometry of the disk. Add two system calls that report the number of blocks on the disk and the number of bytes in a disk block. The library interface to the system calls should look like this:
Add two more system calls that allow reading and writing of individual blocks.
These systems calls are blocking. They return 0 on success and a non-zero value to indicate an error. The calling process is blocked until the operation completes. However the Disk has only non-blocking methods beginRead and beginWrite, so you will need to write a monitor to schedule requests to the disk. Call this monitor DiskQueue.java. At first, make this class very simple: It has three methods read, write and endIO. The read method waits until the disk is idle, records information about the current disk operation, calls Disk.beginRead, and waits for the disk operation to complete. The write method is similar. The endIO method is called by Kernel.interrupt() when it gets an INTERRUPT_DISK interrupt indicating that the current disk operation has completed and uses notifyAll to tell any waiting threads that they should re-check to see if it is time for them to do something.
You will need to modify the Kernel to create a DiskQueue instance in Kernel.doPowerOn and to call its read, write, and endIO methods at the appropriate points.
Modify your DiskQueue class to implement the (2-way) Elevator scheduling algorithm. If the read or write method finds the disk busy, it should enter its request in a list of pending operations and wait until it is chosen. The endIO method should notify the thread that started the I/O operation, and then, if there are other requests in the queue, choose one and allow it to make another beginRead or beginWrite call. The request “list” can be any data structure you think is appropriate. Each time an I/O operation finishes, endIO needs to choose the request closest to current head position in its current direction of travel. The distance is simply the difference between the block number of the request and the block number most recently read or written. Ties may be broken any way you like. Assume the head starts out at block zero. Don't get too fancy. The queue should never be very long, so the cost of searching is unlikely to be important.
Add a method DiskQueue.shutdown() that delays the caller until all pending disk operations have completed. Add a call to this method to Kernel.doShutdown() just before the call to disk.flush().
Programs tend to request the same blocks of a disk over and over again. The kernel can help speed things up by making a buffer cache of Kernel.cacheSize disk blocks. Create a class DiskCache that maintains an array of buffers each of which has space to store the contents of a disk block as well as information about the block, such as its location on disk, whether it is “dirty.” Define methods DiskCache.read and DiskCache.write and have the Kernel call these methods instead of DiskQueue.read and DiskQueue.write.
Use the Clock (Second Chance) algorithm to allocate buffers in the cache. For each block in the cache, you will need a byte array to hold its data, the block number of the corresponding block on disk, an indication whether the buffer is “clean”, “dirty”, or “empty,” and a “ref” flag. Whenever read or write is called and the requested block is already in the cache, set the ref flag to true and copy the data to or from the cached array of bytes (you may find System.arraycopy handy for this). If an operation requests a block that is not in the cache, use the Clock algorithm to choose a buffer from the cache and use it instead. Look at each buffer in the cache, starting wherever you left off last time. If the buffer is clean but the ref flag is true, set it to false and go on to the next buffer. If it is dirty, schedule a write operation to write its contents out to disk. If it is both clean and unreferenced, use it. If the operation that requested the block was a read operation, you have to read the block from disk before returning.
Your DiskCache class also needs a method shutdown() that writes all dirty blocks back to disk. Add a call to this method to Kernel.doShutdown(). Note that this method doesn't have to be very fancy. Because it is called only at system shutdown, it doesn't have to be very efficient, and it doesn't have to worry about new requests arriving. When the MiniKernel shuts down cleanly it leaves behind a Unix file called DISK that contains the contents of the whole simulated disk. The next time you run MiniKernel, it will read this file to restore the contents of the simulated disk. If there is no DISK file, the simulated disk will start out with random data.
“User” programs have a habit of misbehaving. The Library and the Kernel should be vigilant in detecting invalid operations and returning an appropriate error value. You may define new error values if you like. For debugging purposes, you can use System.err.println or System.err.printf to print error messages, but you should be aware that the only way a “real” operating system kernel indicates errors is by returning error result codes from system calls (unless you count the blue screen of death as “indicating errors”:-).
Be very careful about race conditions and deadlocks. Use all the skills you learned from project 2. The synchronization for this project is surprisingly hard to get right. See the Hints section below and the FAQ for more advice.
DiskTester [options] idwhere id is a positive integer. If you run it without any arguments, it will print a usage message listing all the available options. To compile it with make, remove the ‘#’ symbol from the second line of the Makefile. Note that you will not be able to compile it until you have added the methods getTime(), writeDiskBlock(), etc. to Library.java.
The disk starts out loaded with random (or at least unpredictable) data. You can use the command DiskTester -i 123 to initialize the contents of each block with a header that includes the id 123, the block number, and the number zero. If you include the -V option, the tester will verify each read to make sure the block has a header with the right block number.
If you specificy a block count with -n count, the tester will access count random blocks. If you specify -r each access will read the block, show the header (if you specified -v) and verify that it contains the right block number (if you specified -V). If you specify -w, each access will modify the header of the chosen block to include the id of this disk tester, the block number, and a sequence number. If you do not specify either -r or -w, each access will be a read or write with equal probability.
By default, random accesses are uniform: Each access may choose any block on the disk with equal probability. If you specify -c, 10% of the blocks on the disk are considered “hot.” Each read or write will select a “hot” block with 90% proability and a “cold” block with 10% probablity. The particular blocks considered hot depend on the last digit of the id specified on the command line. If the id ends with 0, the first 10% of the blocks on disk are “hot”. If it ends with 1, the next 10% are “hot,” and so on.
Copyright © 1996-2007 by Marvin Solomon.