CS 111 Winter 16 Lec 5 Scribe Notes

Orthogonality, Processes, and Races

By Cameron Ito, Steven Xu, and Andrew Park

Here are a list of features you want to achieve when creating an operating system. Note that neither this list nor its examples are all-encompassing:

Modularity and Abstraction

From a high-level, perspective, modularity is the degree to which a system's components can be separated and recombined. In other words, in a system that has good modularity, there would exist numerous small parts, or modules, that each perform one and only one specific task. Each of these individual modules could then be combined and grouped to create larger modules until you are finally left with the system that you originally started off with.

In an operating system with modularity, you want the operating system to be decomposed into a bunch of smaller programs that work independently of one another so that the functionalities of the operating system don't have to depend on each other.

Abstraction is the idea of establishing a level of complexity on which a user or person interacts with a system without the user needing to understand the more complicated details underneath the current level. In how it relates to operating systems and modularity, abstractions are, in a sense, the modular "boundaires" that provide the interaction between individual modules (programs) and the environment that they reside in (operating systems). One side goal of these abstractions in an operating system is such that if an individual modular program breaks and is unable to provide the functionality it was supposed to, it will not stop other programs from working as intended.

Illustration of Linux OS having 2 levels of hardware abstraction Illustration of x86 architecture having 4 levels of hardware abstraction
Figure 1: The lines in the both diagrams illustrate the abstraction involved with dealing with the hardware of computers. Specifically for the diagram on the left, only the kernel is shown to have access to "privileged" instructions that access the hardware whereas the applications do not.

Interestingly, the Linux OS supports 2 levels of hardware abstraction whereas the x86 architecture does not. The reasoning behind each design decision is as follows: the Linux designers placed more emphasis on performance whereas the x86 designers placed more emphasis on simplicity and flexibility.
Depiction of a microkernel shown in class
Figure 2: Microkernels support even more levels of abstraction.

Fork and Execvp

The pid_t fork(void) system call clones a process except for:

  1. the process ID
    Use the system call, pid_t getpid(void), to see this
  2. the parent process ID
    Use the system call, pid_t getppid(void), to see this
  3. File Descriptor Tables
    Parent process with its file descriptor table Child process with its file descriptor table
    Figure 3: An illustration of a file descriptor table is shown above. Each running process has a file descriptor table which contains pointers to all open i/o streams. Whenever a process starts, three entries are always created in the first 3 cells of the table: entry 0 points to standard input, entry 1 points to standard output, and entry 2 points to standard error. New entries are always added to the table whenever a file or i/o stream is opened.

    /dev/urandom is a pseudo random number generator. This allows it is able to similate more entropy.

  4. CPU time information
    Use a bash command to find this information:
    $ time programName
  5. Pending Signals
  6. File Locks
  7. And a few more, but the ones above are some of the more important ones. So fork() doesn't actually just "clone" a process. It is a little more complicated than that as you can see from the list above. However, the overall amount of data copied is greater than the amount of data that is different.

The int execvp(char const* file, char *const argv[]) system call destroys or reuses a process except. Everything about the process (local variables, programs, registers, instruction pointer(%rip) etc.) is lost except for...

Here is some sample code that uses fork and execvp to print the date:

int printdate(char const* Outfile) {
pidt p = fork();
switch(p) {
case -1: // fork failed
error(); // handle via our error function whose details are hidden
case 0: { // child process
int fd = open(Outfile, O_WRONLY);
if (fd < 0)
error();
if (dup2(fd, 1) < 0)
error();
if (fd != 1)
close(fd);
static char const date[] = "/bin/date"; // the static keyword stores the variable in read-only memory, as opposed to being
stored in the memory reserved for local variables
execvp(date, (char const*) {date, NULL});
error(); // The code should never reach here because child process should've been destroyed by execvp. If it is continuing, that means that something failed. Handle this with our error function.
}
default: { // parent process
int status;
if (waitpid(p, &status, 0) != p)
error(); // waitpid failed, handle via our error function
return WIFEXITED(status) && WEXITSTATUS(status) == 0;
}
}
}

If we wanted to tweak fork we could do it manually:

// RandomProgram.c

... // code before this

if (fork() == 0) {
op() // arbitrary operations
//housekeeping code (glue of the fork, etc.)
op() // arbitrary operations
nice(10) // where we modify the code; in this example, we add the nice() system call, which changes the process's priority
op() // arbitrary operations
execvp(. . .);
}

... // code after this

Or we can use the following system call to accomplish the same thing:

int posix_spawnvp(pid_t *restrict pid,
const char *restrict file,
const posix_spawn_file_actions_t *file_actions,
const posix_spawnattr_t *restrict attrp,
char *const argv[restrict],
char *const envp[restrict]);

This system call doesn't need to copy as much data as fork() does when it creates a new process, only the specified data in its parameters. So, the benefits of this system call is that it's cheaper than fork(). Overall, it's more efficient and faster and is the method used by Windows... but it's a pain to use because the parameters of this system call are so complicated and does not support orthogonality....

So the first method, changing fork() manually is more popular route to take because even though it is more complicated, it is more customizable and more importantly, supports orthogonality.

N.B. the restrict keyword ensures that pointers do not point to overlapping pieces of memory.

Orthogonality

When thinking of orthogonality, one way to think about it is in a mathematical representation as shown in the figure below.
Mathematical Representation of Orthogonality
Figure 4: In a traditional 3-Dimensional Cartesian coordinate system, each of the axises, x, y, and z are orthogonal to each other. For this example above, additional axises, represented by dotted lines, are added to indicate that the number of variables orthogonal to each other are not limited by a set number.

Say you have an object located in the figure above. If you change that objects x-value, it will not change that object's value for any other axises. If you change that object's niceness value, it also not change the object's value for any other axises, which illustrates a larger point: that the choice of 1 attritute in a system will not affect the other attributes.

The main idea of orthogonality is the indepedence of variables. In other words, you don't want your choice of 1 attribute to affect all the other attributes. Another way to think about orthogonality is that the more orthogonal the design, the fewer exceptions it will have.

An example of orthogonality in an operating system is illustrated in nice() system call used by the Linux OS. We want to use the nice() system call whenever we want to change only a process's nice value. We don't want other system calls to change a process's nice value.

Network Flash
Mouse vs. Disk
Keyboard
_________________________ _________________________
Spontaneous Data Generation Request and get a Response
Stream Devices Random Acesss
Not much Memory Has Memory (stable)
Infinite Data (in theory) Finite Data
User Controlled Kernel Controlled
Figure 5: A table illustrating the differences between two categories of I/O devices.

A big idea in UNIX is that everything is considered to be a file. That includes files, processes, and interestingly enough, I/O devices.

With this idea in mind, here are some system call designed to model everything as a file.

*However there are some special exceptions. While the lseek system call is effective for random access I/O devices. it is not able to do the same reposition read/write file offset for stream I/O devices. For example, if you use the following call:

lseek(fd, 27000, SEEK_SET)

for a stream device, lseek returns -1 and sets the integer variable errno.

Question to think about: What are some ways to achieve orthogonality?

Pipe

Interprocess communication, or IPC for short, is the communication of multiple processes with one another. In an operating system, this refers to the mechanisms an operating system provides to allow processes to share data.

One such mechanism is a named pipe, also known as FIFO for its behavior, which is different from a traditional pipe in the sense that its lifespan is longer than the process that creates it. Other than that, a named pipe is essentially the same as a pipe.

Pipes are bounded buffers. This buffer is a ring buffer. Processes write to the buffer (located in Kernel memory), and other processes read from it. This allows a process to send data to another process. Let's say Process A is writing to a pipe of size 4 KiB, and Process B is reading from it. If Process A has written 4 KiB of data to the pipe's buffer, the pipe is now full. We would not want Process A to continue writing and overwrite data that Process B has not yet read. This is why Process A will be suspended by the Kernel. Once Process B reads from the buffer, Process A can resume. If Process B reads from the buffer until it is empty, it will be suspended until Process A writes more data. Process B will continue to wait on the pipe to read until Process A closes its end. This is how Process B can tell that no more data will be written to the pipe.

alternative text
Figure 6: Here is a visual example of how a ring buffer would work. This ring buffer has 12 bytes. (Image credits to Muhannad Ajjan from Wikipedia)

Pipes can also have multiple writers and multiple readers.

(A & B) | (C & D & E)

alternative text
Figure 7: In this example above, A and B will write to C, D, and E. There is no way for Process A to write to a specific process like Process C. If it sends a message, whichever process happens to read it will be the one to get it. If Process A wrote "Hello", Process C could read in "He", Process D could read in "o", and Process E could read in "ll". This will be unlikely as "hello" is a really short string so in reality only one process, for instance C, would read the entire "hello". But, Process A would probably write much more. For instance if it wrote 10 KiB, it would be highly likely for this message to be split amoung Process C, Process D, & Process E. This example is a race condition, where the behavior of the program depends on the scheduling done by the Kernel. Depending on which part of Process A's message Process C receives, its actions will be different.

Race Conditions

Race conditions occur when a device or system attempts to perform two or more operations at the same time, but because of the nature of the device or system, the operations must be scheduled in a proper sequence in order to demonstrate the correct behavior. In an operating system, race conditions occur inside critical sections, sections of code that are executed by multiple threads and where the sequence of execution for the threads makes a difference in the result of that code.

Here is an example of race condition in the following sort.c program

In the program, we have to:

We could create the temp file in sort.c by:

int create_temp_file(void) //returns a file descriptor to a temp file.
{
return open("tmp/sorttmp", (O_RDWR | O_CREAT | 0x600 | O_TRUNC));
}

This approach will not work. This ia due to the race condition raised by the file name. If two processes call this function, they will be writing to the same tempoary file. Instead we need to create a unique name. We can use this code instead:

int create_temp_file(void) //returns a file descriptor to a temp file.
{
char name[1000];
sprintf(name, "/tmp/sort.%d", getpid());
return open(name, (O_RDWR | O_CREAT | 0x600 | O_TRUNC));
}

This approach works in an ideal system. However, this is not the case all the time. It would not work on the Seasnet servers because they assume all sorts will be nice and follow the same convention. We can fix this by adding the O_EXCL flag to our open function call:

return open(name, (O_RDWR | O_CREAT | 0x600 | O_TRUNC | O_EXCL));

The O_EXCL flag causes open to fail if the file we are trying to create already exists. Thus our current method might not work again. This is because if process with id 4 runs sort it will create file "/tmp/sort4" and finish sort. Then if process 4 is given another sort command to run, it will fail because "/tmp/sort4" would already be created. This is a race condition. To fix this, we can pass in a random number via random() instead of getpid() in sprintf(). We just have to keep running open() with new names from random() until it succeeds. This will help avoid the race condition. To implement this, we could use a for loop like:

for(;;) {
sprintf(name, "/tmp/sort.%d", random());
if (open(name, O_RDONLY) == 0) { //We can't open file to read. It must not exist.
return open(name, (O_RDWR | O_CREAT | 0x600 | O_TRUNC | O_EXCL));
}
}

It's important to note that the code above violates orthogonality, so this goes to show that orthogonality is not necessarily always a good thing. You always have to consider tradeoffs that comes up with implementing certain design principles. Orthogonality in the case example above introduced race conditions, and as a result, there was a decrease in reliability of the system. Sometimes, there are arguments against orthogonality.