CS 537
Programming Assignment 4
Frequently Asked Questions

Last modified Tue Apr 13 06:39:06 CDT 2004

Q1:
I'm overwhelmed. Where do I start?

A:
First, implement the getTime() system call as described in the MiniKerel User's Guide. That will help you understand how a request gets from an application program through the library and Kernel.interrupt to the kernel and how the kernel satisfies the request.

Next implement readDiskBlock and writeDiskBlock. You will need to write class Elevator and a helper class Request, which represents a read or write request that has not yet completed. When a new request arrives, Kernel.interrupt() should call Elevator.read or Elevator.write. This method creates a Request, adds it to queue of requests, and if the disk isn't busy, calls Disk.beginRead or Disk.beginWrite to start an operation. It then calls wait to wait until its request has completed. When a disk completion interrupt occurs, Kernel.interrupt() calls Elevator.endIO, which wakes up the process that requested the just-completed operation so that it can return from the readDiskBlock or writeDiskBlock call.

The easiest way to wake up the right process is to make Request a monitor. The requesting process calls request.await(), which calls wait, and Elevator.endIO calls request.finish(), which calls notify. Elevator.endIO also checks to see if the queue of requests is empty, and if not, it chooses another request and tells the disk to start servicing it. At first, you might want to use a FIFO queue (e.g. LinkedList) to record requests. Once everything is working well, you can then modify the code that adds or removes requests to implement the Elevator algorithm. This approach allows you to see whether the Elevator algorithm actually improves performance.

After you get this part of the project working, write BufferPool and put it "between" the Kernel and the Elevator by making the kernel call BufferPool.read or BufferPool.write, instead of Elevator.read or Elevator.write. When a request arrives, BufferPool first tries to satisfy it from the cache. If the requested block isn't in the cache, it chooses the least recently used buffer, calls Elevator.write to clean it if necessary, and calls Elevator.read to re-fill it with the requested block if the original call was BufferPool.read. Also write a method BufferPool.flush to be used for writing all remaining dirty blocks back to disk at shutdown.


Q2:
When does data get written to the disk?

A:
Before you implement BufferPool (see Q1), each writeDiskBlock request will immediately call Elevator.write. If the disk is idle, this method immediately calls Disk.beginWrite. Otherwise it adds the request to the queue and the data will be written whenever Elevator.endIO chooses it. When you add BufferPool, a block will get written to disk only when a buffer needs to be cleaned, or in BufferPool.flush.

Q3:
How much performance analysis is expected?

A:
Unlike the previous project, you do not have to do a detailed performance analysis. Do enough testing to convince yourself that you implementation is correct. If it is correct, adding Elevator scheduling and the BufferPool should each give a significant improvement in speed.

See also Q10 and Q16.


Q4:
Should readDiskBlock and writeDiskBlock be blocking?

A:
Yes. A call to readDiskBlock should not return until the requested data has been copied into the caller's buffer. Similarly, writeDiskBlock should not return until the data has been copied out of the caller's buffer and onto disk (or into a buffer in the disk cache).

See also Q7, Q17, and Q18.


Q5:
We found that the performance of Elevator is no better than FIFO. When we added some debugging output to see why, we discovered that the queue of requests is always empty. What are we doing wrong?

A:
Because readDiskBlock and writeDiskBlock are blocking (see question Q4), if there is only one application program, there can never be more than one request outstanding at a time. When Elevator.read or Elevator.write is called, it will always find the disk idle, start it up, and wait for it to finish. Elevator.endIO will find that there is only that one request waiting, with no others in the queue, so Elevator is no different from FIFO. For Elevator to have any effect, you will need to run multiple DiskTester programs at once. Use the & feature of the Shell to run multiple commands concurrently.

See also Q17.


Q6:
I've noticed that a disk interrupt doesn't have any useful information. That is, on a call of Kernel.interrupt(INTERRUPT_DISK, ...), all of the other arguments are zero or null. Also, all of the fields of class Disk, including Disk.currentBlock, Disk.busy, etc., are private. How can I find out the current state of the disk?

A:
You will have to keep track of that yourself. For example, you could have a field Elevator.diskIsBusy, which you set to true before calling Disk.beginRead or Disk.beginWrite, and which you set to false when you get a disk interrupt. See also Q19.

Q7:
In Q5, you said that read and write calls should be blocking. Are Disk.beginRead and Disk.beginWrite blocking?

A:
No. That's why they are called beginRead and beginWrite. The simulated disk implemented by class Disk mimics a real disk in this way. When you call Disk.beginRead, the disk starts seeking to the indicated block, and when it gets there, it transfers data from the disk to the byte array passed as the second argument. When all the data has been safely transferred to the array, it signals that the transfer has completed by calling Kernel.interrupt.

Q8:
What should we pass as the second argument to Disk.beginRead and Disk.beginWrite?

A:
You should pass a byte array of length Disk.BLOCK_SIZE, allocated somewhere with new byte[Disk.BLOCK_SIZE]. Before you add BufferPool, this array will probably be an array allocated by the application program (e.g. DiskTester) and passed as an argument to readDiskBlock or writeDiskBlock. When you add BufferPool, the array will be one of the buffers in the cache allocated in the BufferPool constructor, and you will need to copy data to or from the application program's array.

Like a real disk, the simulated disk can only read and write whole blocks. If you look at the source of class Disk, you will see that it only checks that the length of the array is at least BLOCK_SIZE. If you give it a bigger array, it will only read or write the first BLOCK_SIZE bytes. However, there is no reason to use a larger array.


Q9:
How, exactly, does the Shell (see Q6) behave?

A:
If called without arguments, the Shell repeatedly prints a prompt and waits for you to type a command line such as
    command1 & command2 & command3
All three commands will be started concurrently, by calling Kernel.exec. The Shell then calls Kernel.join to wait for each command to complete before issuing the next prompt.

You can test your program with something like

    % java Boot 10 Disk 100 Shell
    Shell> DiskTester sequential & DiskTester random
    Shell> exit
    %
This example assumes your DiskTester.main looks at args[0] to see what kind of test to run.

You can also include the command line as an argument to the Shell, for example

    % java Boot 10 Disk 100 Shell 'command1 & command2 & command3'
This is handy for tests to be run over and over again. Create a file called runtest containing the single line
    java Boot 10 Disk 100 Shell 'command1 & command2 & command3'
and make it executable by typing
    chmod +x runtest
Then you can simply type "runtest" to run your test.

Q10:
The project description says to write a DiskTester program to test the implementation. Where does this program get the data to write to the disk? How does it decide which block to read or write?

A:
You should start with an extremely simple tester, something like this:
    class DiskTester {
        public static void main(String[] args) {
            int blockSize = Library.getDiskBlockSize();
            byte[] buffer = new byte[blockSize];
            Library.writeDiskBlock(1, buffer);
            Library.readDiskBlock(1, buffer);
        }
    }
This test makes sure you can get through all the layers of software. Java fills a newly allocated array of bytes with nulls, this test simply clears block 1 of the disk to nulls and then reads back that block of nulls. The next step would be to fill the block with a known test pattern before you write the data to disk and check the data you read from a particular block to make sure it contains the "right" pattern.
    private void setPattern(int blockNumber, byte[] buffer) {
        for (int i = 0; i < buffer.length; i++) {
            buffer[i] = (byte) (blockNumber + i);
        }
    }
    private boolean checkPattern(int blockNumber, byte[] buffer) {
        for (int i = 0; i < buffer.length; i++) {
            if (buffer[i] != (byte) (blockNumber + i))
                return false;
        }
        return true;
    }
When you have this all working and you start to add performance enhancements to your project (Elevator; the BufferPool), you can make your tester write several different blocks in various patterns to check whether you get the performance you expect.

Q11:
Should our Scheduler class implement Runnable? Should we be creating new threads?

A:
No. You will not be explicitly creating any threads for this project. (However, see also Q18). The Disk class has one thread in it. It spends most of its time sleeping, venturing outside only to call Kernel.interrupt when an I/O operation has completed. The Shell calls Kernel.exec to create new threads to run application programs specified on command lines to the Shell. Thus each application program runs in its own thread. When an application program calls Library.readDiskBlock, this thread enters the kernel and executes various kernel methods. The kernel does not have any threads of its own. Any instruction in the kernel (or your scheduler) is executed either by the disk thread or one of these application threads.

Let's look at two common paths through the code, assuming that there is no BufferPool yet.

  1. An application program calls Library.readDiskBlock. This method will call Kernel.interrupt(INTERRUPT_USER, ...), which ultimately calls Elevator.read. It creates and enqueues a new Request r. Finding the disk idle, it calls Disk.beginRead. Then it calls r.await(), which calls wait(). Note that the thread that is blocked belongs to the application program that made the original readDiskBlock call. Other threads can call kernel methods while it is waiting. Not also that the call to r.await() should not be inside a synchronized method of Elevator (see Q??). If you look at the source code for Disk, you will see that beginRead simply sets some private fields of Disk and calls notify, thus waking up the Disk thread and telling it to start copying the data to disk.
  2. The disk thread finishes an I/O operation and calls Kernel.interrupt. In the original version of the kernel, it eventually reaches the code
        case INTERRUPT_DISK:
        break;
    
    Code you added to this case will call Elevator.endIO, which calls notifyAll. The application program thread that started the operation, wakes up, notices that its operation has completed, and returns from Elevator.read, Kernel.interrupt, and Library.readDiskBlock. If there are more requests waiting, endIO will select one and call disk.beginRead or disk.beginWrite to get the disk started on another operation. Note that the thread that is calling beginRead in this case is the disk thread, so in a sense, the disk is calling itself! However, beginRead is non-blocking, so there is no problem with circular waiting, which could lead to deadlock.

    See also Q18.


Q12:
I'm still confused about the relationship between application programs, the Library, the Kernel, the Disk, and the Elevator. How should these be structured in our Java code? What calls what?

A:
The Library is just a bunch of static methods. Application programs such as DiskTester should never directly access any other part of the system except by calling methods of class Library. Like the Library, the Kernel contains nothing but static methods and fields. The only public method is interrupt, which does all its work by calling private static methods of Kernel. The kernel has a pointer to one instance of class Disk, which is passed in as a parameter to the POWER_ON interrupt from Boot.main. The code you were given doesn't do anything with the disk.

You could do most of this project by adding new code to Kernel.java, but it is cleaner (and easier to debug) to put most of your code in new classes Elevator and BufferPool. You will create one instance of each of these classes in Kernel.doPowerOn and store them in static fields of Kernel. The Elevator needs to be able to access the Disk, so pass a pointer to it to the Elevator constructor:

    scheduler = new Elevator(disk);
Similarly, you can pass various information to the BufferPool constructor.

When a read request comes from an application program (via a call to Kernel.interrupt(INTERRUPT_USER, ...)), your doDiskRead method in the kernel will call Elevator.read. This call is blocking - that is, it will not return until the data has been copied into the program's buffer.

See also Q13.


Q13:
How does BufferPool fit into all of this?

A:
When you write BufferPool, you will insert it between the Kernel and the Elevator by replacing the calls to Elevator.read and Elevator.write with calls to BufferPool.read and BufferPool.write. From the kernel's point of view, the BufferPool looks just like the Elevator. Its read and write methods are blocking just like the read and write methods of Elevator, but they will usually be much faster because they only need to copy data into or out of a memory buffer rather than waiting for the disk.

Q14:
What about race conditions in Q13? What about two requests to read (or write) the same block at about the same time?

A:
The kernel should make the disk look like sequentially consistent memory. From the point of view of applications programs (such as DiskTester), it should appear as if kernel calls are completed one at a time, with each operation completing before the next one starts. Thus if two application programs try to write the same disk block at about the same time, the net result should be that the block contains the data written by whichever operation happened to occur second. Similarly, if a read and a write to the same block occur simultaneously, the reader will either see the result of the write or the data that was in that block previously, depending on which operation "wins".

Each buffer in the BufferPool should contain an indication of the block number of the disk block it currently holds (if any). Each read or write call for block b must use the the cached copy of b if there is one. This is important not only for for performance, but also for correctness. For example, suppose one application writes block 17 and another reads block 17 twice. If the second read "doesn't notice" that the block is in the cache (perhaps because of some race condition) and reads the copy from disk instead, it will look to the application like time went backwards: The first read will see the new data written by the other application, but the second read will see the old data. Thus adding or removing a block from the cache must be an atomic action; each search for a block in the cache must come entirely before or after the change. Proper use of synchronized methods can guarantee this property.


Q15:
What should the return values be for Library.readDiskBlock and Library.writeDiskBlock?

A:
Both methods return 0 on success and a negative value on errors. You can use ERROR_IO.

Q16:
I have more questions about DiskTester. In Q10, you showed us how to write a test pattern to a block and check it for correctness, but when do these methods get called? Should the tester write a block and immediately read it back to see if it's correct? Why is checkPattern failing in some of my "random" tests? For the localized distribution, should the blocks be chosen randomly from a small spread of blocks, or is it OK to loop through the small spread of blocks in succession?

A:
It is important to remember that the purpose of DiskTester is to see whether your implementation is correct. For this project, there are two kinds of correctness. First and foremost, the implementation should have the property that each read of a disk block sees the data written by the last write preceding it. A simple test for minimal correctness is to write a test pattern to a block and then read it back to see if it's what you expect. The methods setPattern and checkPattern have a blockNumber parameter. The idea is that you write a different pattern to each block, so that you can be sure you're reading and writing the correct block in each case. A fancier test might mix reads and writes in more complex patterns, and a still tougher test might run several copies of DiskTester in parallel to check for race conditions. One problem you might encounter with random mixtures of reads and writes is that your tester may read a given block before the first write to that block. The Disk documentation states that the simulated disk starts out with random junk in all of its blocks except for block 0, which starts out filled with null bytes. To avoid this problem, you might want your first test to be a version of DiskTester runs through all the blocks, writing a known pattern to them.

The other kind of "correctness" you need to test is that the Elevator algorithm and the BufferPool are doing their jobs. If they are working correctly, certain kinds of workloads should see striking improvements in performance. For example, suppose one DiskTester program accesses blocks 1, 2, 3, 4, ..., while another copy, running in parallel with it, accesses blocks 1001, 1002, 1003, 1004, ..... If the disk accesses are interleaved, the disk will spend most of its time jumping back and forth between the two regions, but if they are queued properly by the Elevator algorithm, the disk might satisfy request 1, 2, 3, 4, ... first, and then seek to block 1001 and satisfy 1002, 1003, 1004. The overall throughput should be a lot better. Note that you can only see this improvement if you have more than one job running concurrently (see Q5).

Similarly, the cache should substantially improve the performance of workloads with a high degree of locality (most references are to a small set of blocks, which fit in the cache) but not workloads that randomly hit all the blocks on disk (unless your cache is as big as the whole disk!)

In summary, you should think carefully about what your implementation is trying to accomplish, and design DiskTester to prove that it is doing the job.


Q17:
After we added BufferPool, we decided to turn Elevator on and off to make sure it it was still working. We find that with BufferPool installed, Elevator makes no difference no matter how many DiskTester processes we have an no matter how we write DiskTester. What are we doing wrong?

A:
Perhaps your methods BufferPool.read and BufferPool.write are synchronized. Or perhaps your Elevator class calls Request.await() from a synchronized method. Remember the advice from Project 2: A synchronized method in one object should not call a synchronized method in another object. This rule is not absolute (there are some circumstances in which you can safely violate it), but in general, it's good advice. In Project 2, violating it can lead to deadlock. In this case, you won't get deadlock because the BufferPool methods call Elevator methods, which call Request methods. The hierarchical ordering of the three classes protect you from deadlock. You avoid deadlock, but you still have a performance problem. Consider what happens when BufferPool.read fails to find the block it wants in the cache. It allocates a buffer and calls Elevator.read to get the block from disk. Suppose the disk is idle. Inside Elevator, the current thread calls Disk.beginRead and then Request.await which calls wait. The call to wait releases the monitor lock on the current object (the Request), but not any monitor locks the thead may be holding on the Elevator or BufferPool objects. That means than any call to readDiskBlock or writeDiskBlock from another DiskTester will block trying to get the BufferPool or Elevator lock. The net result is that there will never be more than one call to Elevator.read or Elevator.write in progress at any one time, and Elevator will never have multiple requests to choose among.

The fix is the same here as it is was in Project2. Make BufferPool.read and BufferPool.write non-synchronized, but carefully wrap the portions of them that manipulate fields of BufferPool in synchronized (private) methods of BufferPool. Similarly, make sure the methods Elevator.read and Elevator.write are not synchronized. The key point is that calls to Elevator.read and Elevator.write are not in (or called by) synchronized methods of BufferPool.


Q18:
When the LRU search in BufferPool finds a dirty buffer, should it clean it immediately or skip over it, looking for a clean buffer?

A:
The way the project instructions are written, you clean it immediately. The problem with this approach is that Elevator.write is blocking. The current process, which only wants to read or write some other block, is blocked until this write request has completed, which may take quite a long time. It would be better if Elevator provided a non-blocking version of write. The problem with this approach, however, is how to let the Elevator tell the BufferPool that the cleaning request has completed, so that the buffer is now available to be allocated for other blocks. If you simply add a synchronized method BufferPool.cleaningDone, you can easily get a deadlock.

The simplest solution I can think of is to add a separate "cleaner" thread. When the LRU buffer allocator finds a dirty buffer, it marks it as "needs cleaning" and then looks for another buffer. It only blocks in the unlikely case that all buffers are dirty. The cleaner thread spends its whole life looking for a buffer labeled "needs cleaning" and a cleaning it by calling Elevator.write. With this solution, you should see a measurable improvement in performance in the case that there are many DiskTester processes writing blocks scattered around the disk. You may even find that it helps to have multiple cleaner threads.

This project is already pretty challenging for the time allotted, so you are not required to implement a cleaning thread. If you finish early and everything seems to be working perfectly, you might want to try out this idea and see how well it works.

See also Q11.


Q19:
Q6 says Disk.busy and Disk.currentBlock are private, but actually they're protected. Can I use Disk.busy in Elevator?

A:
Java will let you access this field because any field given protected access also has "package" access (also known as "phriendly" or "default" access; what you get if you omit the words public, protected, or private). However, it probably won't do you any good. Suppose code in Elevator checks Disk.busy and takes some action if it is true, for example
    while (Disk.busy) { wait(); }
After the access to Disk.busy, the Disk process may complete its operation and call Kernel.interrupt, which calls Elevator.endIO, which calls notifyAll. If all this happens before the first process calls wait, the notifyAll may see no waiting threads and hence have no effect. The application process will stay blocked forwever in wait -- deadlock!

The solution is to have a separate variable Elevator.diskIsBusy. Its meaning is slightly different than Disk.busy. Whereas Disk.busy == true means that the disk is currenly working on a request, Elevator.diskIsBusy == true means that the Elevator has told the Disk to start on an operation but has not yet been informed of its completion. Because diskIsBusy is a field of class Elevator, it can be accessed in a synchronized method of Elevator, allowing you to inspect the variable and take an action based on its value, all as part of a single atomic operation.


Q20:
Do we really need a flush() method in class Elevator?

A:
Perhaps not. If you run a command such as
    % java Boot 10 Disk 100 Shell
the Kernel doesn't shut down until the Shell terminates. Suppose you type some commands (for example, various runs of DiskTester) and then type exit. The commands can only access the disk by calling readDiskBlock() and writeDiskBlock(), which are blocking, so they will not terminate until all their I/O operations have completed. Thus, the Elevator queue should be empty when you type exit.

If you type a command such as such as DiskTester & exit, the exit could take effect while the DiskTester was in the middle of a disk operation. However, the Shell implements exit command by calling System.exit(0), which bypasses the normal Kernel shutdown and would prevent your flush method from being called anyhow. You might say that this is a bug in the Shell, and you would be right. However, it's our bug, not yours, so you don't have to deal with it. You may assume exit is never combined with any other command. In short, Elevator.flush() is not necessary.

However, BufferPool.flush() is definitely necessary. Otherwise, the only reason anything ever gets written to disk is that a "dirty" buffer in the buffer pool is found by the LRU replacement algorithm. Note that to test this feature, you will need to make two runs. First remove the file DISK if it exists. Then run a test that writes known data to one or more blocks and shut down the system completely (that is, exit from the Shell). Finally, run another test that checks those blocks and varifies that they contain the correct data. See also Q2.