Learning Objectives

Processes

The point of an operating system is to efficiently execute instructions safely and securely. The operating system compartmentalizes sections of executing code through the use of a process. In other words, a process is what a program looks like when it's being ran on the CPU. However, the process is not always running on the CPU. In the suspended state, the process is fully stored in memory, which is stored in a data structure called the process control block (PCB). This data structure must store all information needed for the process to be restored on the CPU so that it can resume running.

Why do we need a process to be fully represented in memory? Remember that the OS provides virtualization, where all processes get a share of the CPU. However, there is only one functional unit in a unicore CPU where electrons are flowing to represent the various 0s and 1s needed to run instructions. If I put another process on the CPU, the contents of the registers wouldn't match what is needed. The stack pointer would point somewhere else and all of the variable I want for my process would contain some other process' data. Therefore, we have to store the entire context of a process in memory. Essentially, when we take the process off the CPU, we have to store all of the information of the process at that given time. Then, when we resume the process, we reset all of the registers, including the stack pointer, to their original values. When the process resumes, it proceeds as if nothing happened.

Process States

The operating system has to know what the process is doing at any time. When the process is on the CPU, it has full control over the CPU. In fact, if the operating system turns off external interrupts, the only way to return control back to the operating system from the process is for the process to call ecall. Rememeber, the CPU doesn't care. It just wants a memory address where it can go and start executing instructions.

Even though we want a process to run as fast as possible, there are some reasons why a process can't continue to execute. The number one reason is that it has to wait for some service to be completed. For example, when you call the fopen() function, the operating system will query the file system, which in turn queries the device driver, which in turn queries the secondary storage drive to find the given path. While this "querying" is happening, the process can't continue. We know from programming in C++ that when fopen returns, we either get a NULL value or a pointer to a FILE structure. However, notice that fopen doesn't return control to the process while it is operating. This means that the process is blocked, that is, it cannot continue until some condition is satisfied.

There are four different states that a process can be as shown in the table below.

StateDescription
RUNNINGThe process IS ABLE to be ran on the CPU during the next cycle. This does not mean that it IS running on the CPU (even though it may be), but that it can run on the CPU.
SLEEPINGThe process cannot run because the condition that must be met is some timeout value. Typically, the process stores a timer value that the central clock must hit before it is awoken.
WAITINGThe process cannot run because the condition that must be met is some indefinite value. Typically, the process is asking for a service that has not yet completed, such as I/O.
DEADThis isn't a real state, but it is used for fixed-sized process lists to denote an empty process. Any newly created process can occupy a "dead" process.

Every operating system has their own take on the states of a process; however, they typically follow the "run, sleep, wait" model as shown above.

Context Switches

Remember that a process has two faces: (1) on the CPU and (2) in memory and off the CPU. A context switch describes the following steps.

  1. Pull current process off of the CPU.
  2. Store all CPU registers in the old process' PCB.
  3. Choose next process to run (this is called scheduling).
  4. Reset all CPU registers from the PCB of the next process.
  5. Jump to the memory address of the next process' program counter (stored in the PCB).

Think of how heavily involved a context switch is. We have to run the steps above every single time we need to make a context switch. A context switch is invoked through the use of a hardware timer. This timer interrupts the CPU at a given frequency, which can be programmed by the OS. Linux allows us to configure the hardware context switch timer from 250 Hz, 500 Hz, or even 1000 Hz, which means that the timer interrupts the cpu 1000 times per second! That means the steps above occur every \(\frac{1}{1000}\)th of a second! The amount a time the process gets to be on the CPU before being interrupted is called its quantum.

Reducing the overhead of a context switch is hard to do. Some higher-meaning processes, those that are more important than others, are given a higher quantum multiplier. Recall that a quantum is the amount of time a process remains on the CPU. The quantum multipler increases this time. A high quantum multiplier has a tradeoff: (1) You execute many more instructions for a single process, but (2) all other processes have to wait for \(Q_{period}\times Q_\text{multiplier}\) amount of time to elapse.

The Process Control Block (PCB)

The process control block, or PCB, is the process' state in memory. Remember that a process that is not currently on the CPU must be preserved in memory. The place in memory we store a process' context is in the PCB. Here's the context portion of the PCB in our COSC361 operating system.


struct Context
{
    u64 regs[32];
    u64 fpregs[32];
};

The actual process' structure is as follows.


struct Process
{
    // Which CPU this process is running on.
    i32 on_cpu;
    // The previous CPU this process ran on.
    i32 p_cpu;
    // The process ID (index + 1)
    u32 pid;
    // The privilege mode this process runs in
    // (see cpu.h)
    u32 mode;
    // The number of context switches this process
    // has received
    u64 switches;
    // The total CPU time this process has received.
    u64 runtime;
    // The value of the timer when this process is switched
    // to.
    u64 switch_time;
    // The program counter
    // (address of the currently executing instruction)
    u64 pc;
    // If this process is in the SLEEPING state, this is
    // the time that the process needs to be awoken.
    u64 sleep_until;
    // The quantum multiplier. The coefficient of the
    // time quantum given to this process. Higher numbers
    // means longer time before it is preempted.
    u16 qm;
    // The process' priority for ML and MLF scheduling.
    u16 priority;
    // The memory address of the page table for this process.
    // only valid for USER mode processes.
    MmuTable *page_map;
    // The top of the stack given to this process. The stack pointer
    // points to the bottom of this, but we store the head so
    // we can free it later.
    char *stack_top;
    // The process' name.
    char name[32];
    // The state of the process (RUNNING,SLEEPING,DEAD,etc)
    ProcessState state;
    // The registers (GP and FP) are stored here.
    Context context;
    // Private data
    PrivateData data;
};

The RISC-V architecture has 32 general purpose registers and 32 floating point registers, which are all members in the Context structure above. Furthermore, we have to store the instruction that needs to be executed next, which is why we store the pc for program counter. Recall that this is a special register that stores the memory address of the next instruction. Therefore, when we put the process back on the CPU, we jump directly to the program counter and resume where the process left off.

Other operating systems will store more information inside of the PCB, such as links to open files, sockets, and other such data types. UNIX uses the "everything-is-a-file" concept, where a socket is a file descriptor, an open file is a file descriptor, and even a device itself (from the /dev/ directory) is a file descriptor.

Process List

Since the operating system needs to store and remove processes at a relatively quick speed, choosing the data structure of the process list is important. For example, consider running a simple ls command. The shell must fork a new process, find the ls command, and exec() on that command's file. The fork() system call causes the OS to copy the parent process into a new PCB for the child. As you can see, there is a lot of information being stored in the PCB. So, answer this. Why does the child start at the exact same location as the parent? In other words, why doesn't the child start the program all the way from the beginning. The reason is because the program counter (pc) is stored in the PCB. Since we copy the parent's PCB into a new one for the child, the pc gets copied as well.

Process List Data Structures

Typically, an operating system uses one of the following data structures to store the process list.

Static Process Structure Array

A static process structure array is the simplest and the fastest way to implement a process list. However, it cannot grow or shrink, and it takes a set amount of memory regardless of the number of processes. In the case of the COSC361 operating system, we have a state called DEAD, which signifies that no process is operating on that structure.

Array of Pointers to Dynamically Allocated Structures

Dynamic memory is a slow process. We have to find a "spot", mark that spot so that nobody else takes it, map it into the virtual address space (map the MMU), which may require another table. Therefore, calling malloc/free or new/delete is not a very good way to store a list of processes being added and removed from the list.

Creating a Process

A process is created when the PCB is initialized to a set of CPU instructions. Remember that C++ has the entry point int main(). Well, that has a memory address where the instructions for that function begin. If we load the entire program into memory and jump to that memroy address, we've essentially started the process!

Typically, when a process is created, it is not immediately sent to the CPU. Instead, it goes into the process list and awaits scheduling. Depending on the scheduling algorithm and other factors, the process may not see the CPU for an extended length of time (relatively, it's within a blink of an eye to us, but in terms of the CPU, it can be quite a long wait).

Fork Model

Most UNIX operating systems require that a process is created as a copy of a parent. In Linux, we use the system call clone (which fork and vfork use), to copy a parent into a new child process. This allocates a new PCB and process identifier (PID) for the child process. Remember, again, depending on the scheduling algorithm, the parent might get the CPU first, or the child might get the CPU first. This is called a race condition because the child and parent are racing to see who gets the CPU first.

However, the fork model causes some issues. We copied the entire PCB of the parent into a new one for the child, but what if we want to execute a brand new command? In this case, we call one of the exec suite of functions. This causes the operating system to read the program, store the relevant information in the PCB, and set the state to RUNNING. Notice that this will overwrite the copied PCB. Linux calls this the "process' image" because it is what the process looks like in memory.

Independent Creation Model

Windows and your COSC361 operating system use an independent creation model. Not every process is required to have a parent and processes are created by a simple kernel function. In Windows, we use CreateProcess and in the COSC361 operating system, we use the following function.


for (i = 0; i < MAX_PROCESSES; i++)
{
    if (_processes[i].state == DEAD)
    {
        char *stack = nullptr;
        if (stack_size > 0) {
            stack = new char[stack_size];
            if (stack == nullptr) {
                // Memory cannot be created for this process
                return nullptr;
            }
        }
        ret = _processes + i;
        memzero(&ret->data, sizeof(ret->data));
        ret->runtime = 0;
        ret->switches = 0;
        ret->state = set_running ? RUNNING : WAITING;
        ret->pid = i + 1;
        ret->sleep_until = 0;
        ret->qm = qm;
        ret->mode = mode;
        ret->priority = prio;
        ret->page_map = nullptr;
        strncpy(ret->name, name, 31);
        ret->stack_top = stack;
        ret->context.regs[GpA0] = reg0;
        ret->context.regs[GpSp] = (unsigned long)(ret->stack_top + stack_size);
        ret->context.regs[GpRa] = (unsigned long)recover_process;
        ret->pc = (unsigned long)func;
        ret->on_cpu = -1;
        break;
    }
}

As you can see above, we search for a dead process control block in the static list of processes. Then, we set the relevant context and structures for the process to actually run. Once again, this is just setting the structure to the RUNNING state, meaning when the scheduler searches for a process to run, this one can be a candidate!

Resource Control Block

The resource control block (RCB) is much like the process control block, except that the RCB stores all of the resources the kernel links to the given process. This usually includes the environment (such as the PATH, CWD, UID, GID), all open file descriptors, all memory allocations, and so forth.

There is not much more to be said for the RCB, except that when a process is destroyed (when it exits), its state is set to DEAD and the RCB is freed. This is why you can exit from a process and all of the dynamic memory you've malloc'd is freed, all files are closed, and so forth.

Threads

Threads are executing portions of a process. Processes can have one or more threads, which are independently scheduled instances of execution. For example, a music player must have threads that write the audio data to the sound card as well as update the graphical user interface with the timecode, and another that reads the audio data from a file.

Linux implements user threads as processes, however this isn't always the case for other operating systems. There are two levels where a thread can be executed--kernel and user. Kernel level threads (KLTs) are executed in privilege mode and user level threads (ULTs) are executed in unprivileged mode.

Threads are different from processes in that they usually share some content from the process control block (PCB). All threads of the same process share same PCB. However, threads have their own data structure, called the thread control block (TCB). This data structure is much smaller and only contains the information necessary to separate each thread from the process.

Threads are typically grouped in what is known as a thread group and is given a thread group identifier (TGID). One of the threads is the "parent" thread and is called the leader. The leader is controlled by the PCB's context and is responsible for handling the SIGCHLD signal when child threads exit.