Orthogonality

When creating an interface, you generally want it to be simple and complete. It should be easy to understand (simple), yet do everything that you need it to do (complete). These two things tend to be opposing goals, since it's hard to have many features and still keep it simple.

In addition, interfaces should be combinable. We should be able to combine multiple features into one interface without any of them conflicting. This also means that we should be able to change or remove any arbitrary feature in an interface and still have the other features in the interface work. This idea of seperate, combinable features is known as orthoganality.

File Descriptors

File descriptors are used as an interface for both streams (keyboards, network pipes, etc.) and random-access devices (disks, flash, etc.). Both types of devices use the same API, which makes file descriptor access extremely simple!

    char buf[1024];
    
    int fd = open("abc", O_RDONLY);         //Opens any stream or random-access device
    if (fd < 0) 
        error("open failed");
    if (lseek(fd, 500000000, 0))            //Seek a position in any random-access device (but not streams?!?!)
        error("lseek error");
    ssizet s = read (fd, buf, sizeof(buf)); //Read a number of bytes from any stream or random-access device
    ...					
    if (close(fd) !=0)                      //Close any stream or random-access device
        error("Close failed");
    

How can a call to close fail?

Linux and Solaris store "last update time" of a file in cache instead of in disk, and this must be written to disk when the file is finally closed. When we close, we may encounter an I/O error if the disk fails as we are writing to it. Thus, close() mostly only fails when the physical hardware fails.

If you take a look though, lseek does not work for streams. For instance, how could lseek possibly work on a keyboard?

Due to this dilemna, lseek() simply doesn't return anything for streams. Instead, it returns an error during runtime.

lseek works for random-access devices but not streams

This means that file descriptors are not orthogonal! Lets try reorganizing this API so that streams cannot do lseek(). This means that we can catch lseek() errors at compile time instead of at runtime. Let's call this new API the FD-SD Approach.

FD-SD Approach

The API will be split into two interfaces--one for random-access devices and one for streams.

fd (file descriptor) works as before

    open(...)    //Opens a random-access device
    read(...)    //Reads a random-access device
    close(...)   //Closes a random-access device
    lseek(...)   //Seeks a position in a random-access device

and now sd (stream descriptor) is used for streams

    stream_open(...)    //Opens a stream
    stream_read(...)    //Reads a stream
    stream_close(...)   //Closes a stream
    stream_lseek(...)   //Doesn't exist! You can't seek in a stream.

Now we are more orthogonal, but less simple! Pushing orthogonality means increasing the complexity (we have to deal with two interfaces instead of one), and there is now a lot of code overlap. For the people who originally created file descriptors, this trade-off was not worth it. Thus, they chose the approach of having only one interface, despite having a runtime error instead of a compile-time error.

Virtualizable Processor and OS

A virtualizable processor and OS let you support the idea of a process.

What is a process:

The point of having processes is to get as much orthogonality as possible. This grants us safety & simplicity. Safety, because different processes won't be able to interfere with each other. Simplicity, because it's easier to think of processes as isolated.

It is possible to code non-isolated processes! Check out Lamport's paper on concurrent programming!

Modeling OS Resources

Keeping processes isolated requires the OS to manage memory and CPU resources and allocate them to user programs. There are two approaches to modeling these OS resources:
1) Opaque identifiers

When a user program requests a resource, the OS gives the program an opaque identifier. To do anything with the resource, the user program must request the OS to manipulate the resource. This is the way file descriptors work; the file descriptor is simply a number that represents a certain file.

    int fd = open(...);
    read(fd, ...);

2) Direct access

When a user program requests a resource, the OS gives the program direct access. User program can manipulate memory directly with this resource.

    struct file *f = file_open(...);
    f->offset = 500;//Change file properties directly
Advantages of opaque identifiers vs. direct access
Opaque Identifiers Direct Access
+ Safer+ Faster
      Arbitrates race conditions+ Good in small embedded systems written by one developer
      Checks bounds
+ More organized
+ Abstraction (allows us to change OS without changing apps)

POSIX syscalls dealing with processes

    pid_t fork(); //"Look Ma! No args!" no need for them
    // returns > 0 if in parent
    //        = 0 if in child
    //        < -1 if failed

There's no need for arguments because the two processes are identical except they have different:

Parent and child share file descriptions but have their own file descriptors. So a child can close a file without affecting the parent's descriptor, but if the child reads anything, the read affects the parent as well. More specifically, close affects descriptors and read affects descriptions. Programs like Apache uses this to its advantage!

int execvp(char const* file, char* const* argv);

This function will quit the current process completely and invoke a the new process pointed to by "file". If the call succeeds, the function will never return, since the process which calls it no longer exists. Instead, the new process has the same pid as the old process (As well as ppid, accumulated execution times, etc. Essentially, everything that fork() gives to the child, the new process keeps).

pid_t waitpid(pid_t pid, int* status, int options);

This function allows a parent process to wait for a child process to finish executing before continuing to run. The function will return the child's exit status.

    posix_spawnvp(...)

This function combines fork() and execvp() and contains about two pages worth of arguments. (Fork-exec pairs are described in the next section). Most of the arguments in this function deal with typical twiddling that is done between the fork and exec, like:

This function is mostly here for efficiency, for when running an OS where fork_exec is slow.

    void _exit(int);
    void abort(void);

These two system calls are low-level immediate exits for the process. _exit() is called when the program ends nicely, and abort() is called when a the program crashes. Note that _exit() is different from exit(), which is a higher-level function which handles a few things before calling _exit().

Fork-exec pairs

The functions fork(), execvp() and waitpid() can be used in conjunction to create a fork-exec pair. The parent process forks a child process and the child process uses execvp() to start a new process. The parent can then wait upon the new process and continue running once it has completed.

    void printdate(void) {
        pid_t p = fork();
        if(p < 0)
            error();
        if(p == 0) {
            char* args[2] = {"date", NULL};
            execvp("/usr/bin/date", args);
            error();
        }
        //If we reach this point in the code we are the parent
        int status;
        if(waitpid(p, &status, 0) < 0)
            error();
        if(!WIFEXITED(status) || WEXITSTATUS(status) != 0)
            error();
    }

We can use fork(), waitpid() and execvp() to implement shell commands like "cat a | dog b". Implementing the pipe will require two forks, two execvps and a little bit of extra resource handling within the child process.

Problems with these syscalls?

Race condition in waitpid()

If the child process exits before the parent calls waitpid(), then the parent will be waiting on a process which no longer exists. The creation of ZOMBIE processes fixes this race condition by keeping the child process half-alive until the parent calls on it.

Fork Bombs

    for(;;)
        fork();
    

This code is a fork bomb, which leads to infinitely self-replicating processes which eventually fill up all process slots and take up all system resources. This can be fixed by setting process limits.

Race conditions in open()

open("/tmp/foo", O_RDWR | CREAT, 0600);

What happens if two processes ran this command at the same time? There would be a race condition--the first process to run the function would succeed in making the file and the second process would fail. So what about this possible solution?

    while(access("/tmp/foo",0)==0) continue; //Loop until the file is available
    fd = open("/tmp/foo", O_RDWR | CREAT, 0600);

Now the second process will keep looping until the first process is done with the file. But there's still a problem with this if we have more than two processes trying to run this same code. So what can we do?

We'll discuss this next lecture