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:
Protection
- from maliciously misuse by other applications.
- so each shared resource, whether it's memory or CPU time, is used in accordance with the system policies defined by the system administrators or designers.
- from any possible damages from errant programs.
Robustness
- so that it can handle unforseen circumstances that was not accounted for by the system designer.
- when dealing with unexpected behavior.
Utilization
- by making efficient use of all of its resources. If a process stops to wait for another process, we want our OS to take the resources that process was using, and let another process to use them while it waits.
Performance
- in aspects like memory, speed, time, etc.
Flexibility*
- so that the OS can work with a wide variety of programs and applications.
Simplicity*
- for the sake of ease of use, debugging, and maintanence.
-
* For today's lecture, we are going to talk about how to achieve flexibility and simplicity. The way we do that is through the use of both modularity and abstraction
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.
Fork and Execvp
The pid_t fork(void) system call clones a process except for:
- the process ID
Use the system call, pid_t getpid(void), to see this - the parent process ID
Use the system call, pid_t getppid(void), to see this - File Descriptor Tables
- CPU time information
Use a bash command to find this information:
$ time programName - Pending Signals
- File Locks
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...
- all the data that not copied over from the fork() system call. So in a sense, the fork() and execvp() system calls do the opposite things.
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.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 |
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.
- open
- close
- read
- write
- dup2
- lseek
*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.
Pipes can also have multiple writers and multiple readers.
(A & B) | (C & D & E)
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:
- store an arbitrary amount of data.
- copy data into a temporary file.
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));
}
}