CS 537
Programming Assignment 4
Frequently Asked Questions

Last modified Mon Apr 16 05:57:33 CDT 2007

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 DiskQueue 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 DiskQueue.read or DiskQueue.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 DiskQueue.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 DiskQueue.endIO calls request.finish(), which calls notify. DiskQueue.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. ArrayList) 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 DiskQueue algorithm actually improves performance.

After you get this part of the project working, write DiskCache and put it “between” the Kernel and the DiskQueue by making the kernel call DiskCache.read or DiskCache.write, instead of DiskQueue.read or DiskQueue.write. When a request arrives, DiskCache first tries to satisfy it from the cache. If the requested block isn't in the cache, it chooses a buffer, calling DiskQueue.write to clean any dirty buffers it encounters, and calls DiskQueue.read to re-fill it with the requested block if the original call was DiskCache.read. Also write a method DiskCache.shutdown() 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 DiskCache (see Q1), each writeDiskBlock request will immediately call DiskQueue.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 DiskQueue.endIO chooses it. When you add DiskCache, a block will get written to disk only when the clock algorithm notices that is is dirty, or in DiskCache.shutdown().

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 DiskQueue scheduling and the DiskCache should each give a significant improvement in speed.

See also Q10 and Q16.


Q4:
Should Library.readDiskBlock and Library.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 DiskQueue 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 DiskQueue.read or DiskQueue.write is called, it will always find the disk idle, start it up, and wait for it to finish. DiskQueue.endIO will find that there is only that one request waiting, with no others in the queue, so DiskQueue is no different from FIFO. For DiskQueue 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 DiskQueue.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 Q4, 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 DiskCache, 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 DiskCache, the array will be one of the buffers in the cache allocated in the DiskCache 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 Q5) 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 -v 123 & DiskTester -v 456
    Shell> exit
    %
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. You can also modify your Makefile to change the line following “run:” to read
   java -ea Boot 10 Disk 100 Shell 'command1 & command2 & command3'
and simply type “make” to re-compile and run your program.

Warning: This line must start with a TAB character, not spaces.


Q10:
Where does the DiskTester program get the data to write to disk? How does it decide which block to read or write?

A:
It writes a simple test pattern in the first 12 bytes of each block it modifies and clears the rest of the block to zeros. The first four bytes are the id from the command that invoked the DiskTester, the next four are the block number, and the next four are a serial number (the number of writes issued by the DiskTester). If you specify -w -n 1000, it will write 1000 times. Each time, it will choose a random block, choosing any block on disk with equal probability, so some block may be written more than once. If you specify -r -n 1000 it will read instead of writing (verifying that the headers contain the right block numbers), and if you specify -n 1000 with neither -w nor -r, it will flip a coin before each operation, choosing to read or write with equal probability. If you also specify -i the tester will make a pass through all the blocks on disk writing headers to them before any random reads or writes.

If you specify -c the reads and writes will be clustered rather than uniform: 90% of the accesses will be to 10% of the blocks. The particular set of blocks chosen as the “hot” blocks depends on the last digit of the id specified on the command line.


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 DiskCache yet.

  1. An application program calls Library.readDiskBlock. This method will call Kernel.interrupt(INTERRUPT_USER, ...), which ultimately calls DiskQueue.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. Note also that the call to r.await() should not be inside a synchronized method of DiskQueue (see Q17). 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 from disk to the buffer.
  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 DiskQueue.endIO, which calls request.finish. The application program thread that started the operation wakes up, notices that its operation has completed, and returns from DiskQueue.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 Q12, Q18 and Q21.


Q12:
I'm still confused about the relationship between application programs, the Library, the Kernel, the Disk, and the DiskQueue. 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 DiskQueue and DiskCache. You will create one instance of each of these classes in Kernel.doPowerOn and store them in static fields of Kernel. The DiskQueue needs to be able to access the Disk, so pass a pointer to it to the DiskQueue constructor:

    scheduler = new DiskQueue(disk);
Similarly, you can pass various information to the DiskCache 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 DiskQueue.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, Q14, Q18, and Q21.


Q13:
How does DiskCache fit into all of this?

A:
When you write DiskCache, you will insert it between the Kernel and the DiskQueue by replacing the calls to DiskQueue.read and DiskQueue.write with calls to DiskCache.read and DiskCache.write. From the kernel's point of view, the DiskCache looks just like the DiskQueue. Its read and write methods are blocking just like the read and write methods of DiskQueue, 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 DiskCache 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.
  1. Why is DiskTester throwing a RuntimeException with the message call failed?
  2. What do the messages bad disk size, bad block size, and bad data in block mean?
  3. If DiskTester is not throwing any exceptions or printing any of these messages, does that mean our program is correct?

A:
  1. One of the Library calls writeDiskBlock or readDiskBlock is returning a non-zero exit code. The problem could be in your changes to Library.java or Kernel.java, or it could be in your new DiskCache or DiskQueue classes.
  2. The first two messages indicate that Library.getDiskBlockCount() or Library.getDiskBlockSize() is returning a negative value. The third message indicates you specified -V and some block read returned data that did not have the right block number in the header. Did you remember to call DiskTester with the -i flag first?
  3. Not necessarily. Only the most flagrant of errors will be uncovered by a simple run of DiskTester. Once you have DiskTester running cleanly with the -n option specified, try running it again with -v. This will create a good deal of debugging output. It will print a message each time it reads or writes a disk block. Carefully check the output and make sure each read gets back the data more recently written to the same block. You may want to use a small disk size for this test to make sure the same block gets hit many times. The size of the disk (in blocks) is the third parameter to Boot, between “Disk” and “Shell”. Once all this is working correctly, run several copies of DiskTester concurrently, with a command such as DiskTester -V 1 & DiskTester -V 2 & DiskTester -V 3 & DiskTester -V 4. Try running with and without the DiskCache to see if it makes a difference in performance (see also Q3, Q5, Q17, and Q18). Finally, you will want to use the -c option to generate workloads with a lot of locality and make sure the DiskCache makes a difference in performance.

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


Q17:
After we added DiskCache, we decided to turn DiskQueue on and off to make sure it it was still working. We find that with DiskCache installed, DiskQueue 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 DiskCache.read and DiskCache.write are synchronized. Or perhaps your DiskQueue 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 DiskCache methods call DiskQueue 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 DiskCache.read fails to find the block it wants in the cache. It allocates a buffer and calls DiskQueue.read to get the block from disk. Suppose the disk is idle. Inside DiskQueue, 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 thread may be holding on the DiskQueue or DiskCache objects. That means than any call to readDiskBlock or writeDiskBlock from another DiskTester will block trying to get the DiskCache or DiskQueue lock. The net result is that there will never be more than one call to DiskQueue.read or DiskQueue.write in progress at any one time, and DiskQueue will never have multiple requests to choose among.

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


Q18:
When the Clock search in DiskCache 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 DiskQueue.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. I can think of two ways to improve concurrency. One way would be to add a non-blocking method DiskQueue.beginWrite(). The problem with this approach is that it requires some way for DiskQueue to tell the DiskCache that the write request has completed. If you simply add a synchronized method DiskCache.writeDone, you can easily get a deadlock.

Another approach is to add a separate “cleaner” thread. When the Clock 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 DiskQueue.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 either of these two suggested improvements. 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 DiskQueue?

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 DiskQueue checks Disk.busy and takes some action if the disk is idle, for example telling it to start serving a new request. If disk completes its current operation after you test Disk.busy but before you add the new request, the disk will see nothing to do, but the code that adds the new request will fail to restart the disk. The requesting process will stay blocked forever with the disk idle -- deadlock!

A solution is to have a separate variable DiskQueue.diskIsBusy. Its meaning is slightly different than Disk.busy. Whereas Disk.busy == true means that the disk is currently working on a request, DiskQueue.diskIsBusy == true means that the DiskQueue 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 DiskQueue, it can be accessed in a synchronized method of DiskQueue, 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 shutdown() method in class DiskQueue?

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 DiskQueue 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 shutdown 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, DiskQueue.shutdown() is not necessary.

However, DiskCache.shutdown() 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 Clock 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 verifies that they contain the correct data. See also Q2.


Q21:
What happens if two processes want to read the same block at about the same time and it isn't in the DiskCache?

A:
You have to be careful about this case. Suppose two processes want to read block 20 at about the same time, and it isn't in the cache. P1 scans the cache, finds that 20 is not present, allocates a buffer, and starts a read from DiskQueue. Before it waits for the read, it must release the mutex on DiskCache so that other processes can access unrelated buffers. But now P2 scans the cache, finds 20 missing, and tries to read it from disk. Regardless whether P1 and P2 selected the same or different buffers for the read, the second read is at best redundant, and at worst could cause a conflict.

One possible solution to this problem is for P1 to lock the buffer while it is waiting for the read to complete. In somewhat more detail, when P1 finds block 20 is not in the cache and allocates a buffer b to hold it, it should immediately mark b as “contains block 20 but is not yet ready”. When P2 looks for block 20, it will find buffer b and wait until the “not ready” flag is turned off. When P1's read request to the DiskQueue completes, it clears the “not ready” status and does a notifyAll() on b, allowing P2 to continue. Note that there should be a single synchronized method of DiskCache that looks for a buffer containing block 20 and if it doesn't find it, allocates a buffer and marks it as “contains 20 but not ready”. However the call to DiskQueue.read() needs to be outside this method, because it does not want to block other processes from using the DiskCache while it is waiting for the read to complete.

There is a small difference if the buffer b chosen by P1 happens to be dirty. P1 immediately marks b as containing block 20. That's a bit of a lie, since b still contains some other block, but P1 also locks b (marks it as unready). It then writes the dirty block back to disk and reads in block 20 before unlocking b. Meanwhile, process P2, looking for block 20, thinks it's in b, so it so it doesn't try a redundant read of block 20 from disk, but also sees that b is locked, so it does try to copy data out of b until it really does contain block b.