OS Processes
So far in 3410, we have been operating under the ridiculous notion that a computer only runs one program at a time. A given program gets to own the computer’s entire memory, there is only a single program counter (PC) keeping track of a single stream of instructions to execute.
You know from your everyday computing life that this is not how “real” computers work. They can simultaneously run multiple programs with their own instructions, heap, and stack. The operating system (OS) is, among other responsibilities, the thing that makes it possible to run multiple programs on the same hardware at the same time. The next part of the course will focus on this mechanism: how the OS and hardware work together to work on multiple things concurrently.
Executable vs. Process
When you compile the C code you have written this semester, you produce an executable file. This is a file that contains the instructions and data for your program. An executable is inert: it’s not doing anything; it’s just sitting there on your disk. You can copy an executable, rename it, attach it to an email, print it out, put it on a floppy disk and send it through the US mail—anything you can do with any other file.
When you run an executable, that creates a process. A process is a currently running instance of a program. You can run the same executable multiple times and get multiple, concurrently executing processes of the same program. The different processes will share the same instructions and constant data, but they will have different heaps and different stacks (so different values of all their variables, potentially). It’s not just a file—you can’t print out a process or burn it to a CD. A process is something that occurs on a specific computer at a specific time.
Part of an operating system’s job is to provide processes with the illusion that they own the entire computer. That means that a process gets to use all of the machine’s registers without worrying about other processes using them at the same time. The OS manages the CPU’s program counter so it appears, to each process, to proceed normally through a given program’s instructions—without jumping willy-nilly to other programs’ instructions. Through a mechanism called virtual memory, every process gets the illusion of owning the entire \(2^{64}\)-byte memory address space. (Virtual memory will be the topic of the next lecture.)
The Process Lifecycle
What happens when you type ./myprog
in your shell to launch an executable?
(Assume you already compiled an executable, myprog
.)
The OS must create a new process with the instructions and data from myprog
.
The OS keeps track of the list of running processes.
Each process gets an entry in this list called a process control block (PCB).
The PCB includes metadata like the process id (pid),
information about the user who owns the process,
a current state of the process (running, waiting, ready, etc.),
and so on.
To create a new myprog
process, the OS allocates a new PCB and adds it to its list of running processes.
Next, the OS sets up the memory for the process. Recall that programs expect to have access to regions of memory for their stack, heap, global data, and instructions. So at the very least, the OS needs to take the instructions from the executable and put them into memory. We’ll cover more about how to set up the memory address space for a process when we talk about virtual memory.
Finally, it’s time to run the process. The OS can transfer control to the program’s first instruction by setting the program counter to that instruction’s address.
It can be helpful to think about a process’s state (as tracked by its PCB) as a state machine.
Process states include initializing, runnable, running, waiting, and finished.
While setting up the PCB and the process’s memory, the OS places a new process in the initializing state.
Eventually, when this is all set up, the process becomes runnable.
Then, when the OS decides to finally start a process, it sets the PCB’s state to running.
The OS uses the waiting state for processes that are waiting for the OS to complete some task on its behalf (such as I/O).
Finally, after main
eventually returns, the process enters the finished state.
Context Switching
Many processes may be active at the same time, i.e., they may all have PCBs that are all runnable. Only one process can actually be running at a time. The OS dedicates the CPU one process for a short span of time, and then it pauses that process and lets another process run for some time. While the length of these time windows varies by OS and according to how busy the computer is, you can think of them happening every 1–5 ms if it helps contextualize the idea. The OS aims to give a “fair” amount of time to each process.
The act of changing from running one process to running another is called a context switch. Here’s what the OS needs to do to perform a context switch:
- Save the current process state. That means recording the current CPU registers (including the program counter) somewhere in memory.
- Update the current process’s PCB (to exit the running state).
- Select another process. (Picking which one to run is an interesting problem, and it’s the responsibility of the OS scheduler.)
- Update that PCB to indicate that the process is now in the running state.
- Restore that process’s state: read the previously-saved register values back from memory.
- Resume execution by jumping to the new process’s current instruction.
Context switches are not cheap. Again as a very rough estimate, you can imagine them taking about a microsecond, or something like a thousand clock cycles. The OS tries to minimize the total number of context switches while still achieving a “fair” division of time between processes.
Kernel Space & User Space
The kernel is a special piece of software that forms the central part of the operating system. You can think of it as being sort of like a process, except that it is the first one to run when the computer boots and it has the special privilege of managing all the actual processes. The kernel has its own instructions, stack, and heap.
Systems hackers will often refer to a separation between kernel space and user space.
OS stuff happens in kernel space: maintaining the PCBs, choosing which processes to run, and so on.
All the stuff that the processes do (every single line of code in myprog
above, for instance) happen in user space.
This is a cute way to refer to the separation of code and responsibilities between the two kinds of code.
However, there is also an important difference in privileges:
kernel-space code has unrestricted access to all of the computer’s memory and to I/O peripherals.
It can read and write the memory of any process.
User-space code, because of kernel-space machinations, gets that aforementioned illusion of running in a sandbox where it does not have to worry about other processes.
In user space, each process receives a limited number of privileges from the kernel and must ask the kernel nicely to perform things like I/O or to communicate with other processes.
Processor ISAs provide mechanisms to enforce this distinction in privileges. For example, RISC-V has a special set of privileged instructions and registers that only kernel-space code is allowed to use. The CPU starts in a state where these instructions are allowed; when the OS starts a user-space process, it instructs the CPU to take away access to these instructions. When control eventually transfers back into kernel space, the CPU re-enables access to these privileged instructions.
System Calls
On their own, the only things that processes can do are run computational instructions and access memory. They do not have a direct way to manage other processes, print text to the screen, read input from the keyboard, or access files on the file system. These are privileged operations that can only happen in kernel space. This privilege restriction is important because it puts the kernel in charge of deciding when these actions should be allowed. For example, the OS can enforce access control on files so an untrusted user can’t read every other user’s passwords.
Processes can ask the OS to perform privileged actions on their behalf using system calls. We’ll cover the ISA-level mechanisms for how system calls work soon. For now, however, you can think of a system call as a special C function that calls into kernel space instead of user space. (Calling a “normal” function always invokes code within the process, i.e., either code you wrote yourself or code you imported from a library.)
Each OS defines a set of system calls that it offers to user space. This set of system calls constitutes the abstraction layer between the kernel and user code. (For this reason, OSes typically try to keep this set reasonably small: a simpler OS abstraction is more feasible to implement and to keep secure.)
In this class, we’re using a standardized OS abstraction called POSIX. Many operating systems, including Linux and macOS, implement the POSIX set of system calls. (We’ll colloquially refer to it as “Unix,” but POSIX is the actual name of the standard.)
For a list of all the things your POSIX OS can do for you, see the contents of the unistd.h
header.
That’s a collection of C functions that wrap the actual underlying system calls.
For example, consider the write
function.
write
is a low-level primitive for writing strings to files.
You have probably never called write
directly, but you have used printf
and fputc
, both of which eventually must use the write
system call to produce their final output.
Process Management
There are system calls that let processes create and manage other processes. These the big ones we’ll cover here:
exit
terminates the current process.fork
clones the current process. So after youfork
, there are two nearly identical processes (e.g., with nearly identical heaps and stacks) running that can then diverge and start doing two different things.exec
replaces the current process with a new executable. So after youexec
a new program, you “morph” into an instance of that program.exec
does not create or destroy processes—the kernel’s list of PCBs does not grow or shrink. Instead, the current process transforms in place to run a different program.waitpid
just waits until some other process terminates.
fork
The trickiest in the bunch is probably fork
.
When a process calls fork()
, it creates a new child process that looks almost identical to the current one:
it has the same register values,
the same program counter (i.e., the same currently-executing line of code),
and the same memory contents (heap and stack).
A reasonable question you might ask is:
do the two processes (parent and child) therefore inevitably continue doing exactly the same thing as each other?
What good is fork()
if it can only create redundant copies of processes?
Fortunately, fork()
provides a way for the new processes to detect which universe they are living in:
i.e., to check whether they are the parent or the child.
Check out the manual page for fork
.
The return value is a pid_t
, i.e., a process ID (an integer).
According to the manual:
On success, the PID of the child process is returned in the parent, and 0 is returned in the child.
This is why I kept saying the two copies are almost identical—the difference is here.
The child gets 0 returned from the fork()
call,
and the parent gets a nonzero pid instead.
This means that all reasonable uses of fork()
look essentially like this:
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t pid = fork();
if (pid == 0) { // Child.
printf("Hello from the child process!\n");
} else if (pid > 0) { // Parent.
printf("Hello from the parent process!\n");
} else {
perror("fork");
}
return 0;
}
In other words, after your program calls fork()
, it should immediately check which universe it is living in:
are we now in the child process or the parent process?
Otherwise, the processes have the same variable values, memory contents, and everything else—so they’ll behave exactly the same way, aside from this check.
Another way of putting this strange property of fork()
is this:
most functions return once.
fork
returns twice!
exec
The exec
function call “morphs” the current process, which is currently executing program A, so that it instead starts executing program B.
You can think of it swapping out the contents of memory to contain the instructions and data from executable file B and then jumping to the first instruction in B’s main
.
There are many variations on the exec
function; check out the manual page to see them all.
Let’s look at a fairly simple one, execl
.
Here’s the function signature, copied from the manual:
int execl(const char *path, const char *arg, ...);
You need to provide the executable you want to run (a path on the filesystem) and a list of command-line arguments (which will be passed as argc
in the target program’s main
).
Let’s run a program! Try something like this:
#include <stdio.h>
#include <unistd.h>
int main() {
if (execl("/bin/ls", "ls", "-l", NULL) == -1) {
perror("error in exec call");
}
return 0;
}
That transforms the current process into an execution of ls -l
.
There’s one tricky thing in the argument list:
by convention, the first argument is always the name of the executable.
(This is also true when you look at argc[0]
in your own main
function.)
So the first argument to the execl
call here is the path to the ls
executable file, and the second argument to execl
is the first argument to pass to the executable, which is the name ls
.
We also terminate the variadic argument list with NULL
.
fork
+ exec
= spawn a new command
The fork
and exec
functions seem kind of weird by themselves.
Who wants an identical copy of a process, or to completely erase and overwrite the current execution with a new program?
In practice, fork
and exec
are almost always used together.
If you pair them up, you can do something much more useful:
spawn a new child process that runs a new command.
You first fork
the parent process, and then you exec
in the child (and only the child) to transform that process to execute a new program.
The recipe looks like this:
fork()
- Check if you’re the child. If so,
exec
the new program. - Otherwise, you’re the parent. Wait for the child to exit (see below).
Here that is in code:
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork();
if (pid == 0) { // Child.
if (execl("/bin/ls", "ls", "-l", NULL) == -1) {
perror("error in exec call");
}
} else if (pid > 0) { // Parent.
printf("Hello from the parent!");
waitpid(pid, NULL, 0);
} else {
perror("error in fork call");
}
return 0;
}
This code spawns a new execution of ls -l
in a child process.
This is a useful pattern for programs that want to delegate some work to some other command.
(Don’t worry about the waitpid
call; we’ll cover that next.)
waitpid
Finally, when you write code that creates new processes, you will also want to wait for them to finish.
The waitpid
function does this.
You supply it with a pid of the process you want to wait for (and, optionally, an out-parameter for some status information about it and some options),
and the call blocks until the process somehow finishes.
It’s usually important to waitpid
all the child processes you fork
.
Try deleting the waitpid
call from the example above, and then compile and run it.
What happens?
Can you explain what went wrong when you didn’t wait for the child process to finish?
Signals
Whereas system calls provide a way for processes to communicate with the kernel, signals are the mechanism for the kernel to communicate with processes.
The basic idea is that there are a small list of signal values, each with its own meaning: a thing that the kernel (or another process) wants to tell your process. Each process can register a function to run when it receives a given signal. Then, when the kernel sends a signal to that process, the process interrupts the normal flow of execution and runs the registered function. Some signals also instruct the kernel to take specific actions, such as terminating the program.
There are also system calls that let processes send signals to other processes. (In reality, that means that process A asks the kernel to send the signal to process B.) This way, signals act as an inter-process communication/coordination mechanism.
Here are the functions you need to send signals:
kill(pid, sig)
: Sendsig
to processpid
.raise(sig)
: Sendsig
to myself.
To receive signals, you set up a signal handler function with the signal
function.
The arguments are the signal you want to handle and a function pointer to the code that will handle the signal.
Here’s an example of a program that handles the SIGINT
signal:
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
void handle_signal(int sig) {
printf("Caught signal %d\n", sig);
exit(1);
}
int main() {
signal(SIGINT, handle_signal); // Set up the signal handler for SIGINT.
while (1) {
printf("Running. Press Ctrl+C to stop.\n");
sleep(1);
}
return 0;
}
The important bit is this line:
signal(SIGINT, handle_signal);
This line asks the kernel to register a function we’ve written so that it will run in response to the SIGINT
signal.