Table of Contents

Lecture 6 Scribe Notes

Topics Covered
1. Signals
2. Threads
3. Scheduling

Signals

Why do we need signals?
Signals are often used in operating systems to notify processes of asynchronous events. For example, pressing Control-C in the command line sends a signal to the shell to terminate a process. There are about 32 signals supported by Unix. These signals are broken down into subcategories. Below are examples of some signals used by Unix.

Processor Fault Interrupts:
SIGILL - illegal instruction (dangerous instructions)
SIGFPE - floating point error (e.g., dividing by 0)
SIGSEGV - segmentation violation (process tries to access
kernel memory, non-existing memory, etc.)
Timer Interrupt:
SIGALRM - alarm signal (alarm (sec)) (sec- delay in seconds
between calls to alarm)
I/O Error:
SIGPIPE - process is trying to write to a closed pipe
User Signals:
SIGKILL - kills process
SIGUSR1 - user-defined signal1)
SIGUSR2 - user-defined signal2)

Default Action:
There is a default action for all signals, which is normally to kill the process it interrupts. The default action is incurred whenever there is no signal handler provided to catch the signal.
signal(signo, SIG_DFL) SIG_DFL - default signal handler

Using Child Signals

Example of signal handler:

/*this is the signal handler that is executed whenever our process is interrupted by a child process*/
void childsig(int signalno) {
    printf( "My child got signal: %d\n", signalno );
  }
 
int main(int argc, char *argv[]) {
    int status, pid, ret;
    signal( SIGCHLD , childsig );   //here is where we assign childsig as the signal handler for SIGCHLD
    pid = fork();
 
    if (pid == 0) {
        sleep(5); // sleep for 5 seconds
        printf( "exiting 1\n", ret );
        exit(0);
    }
 
    pid = fork();
 
    if (pid == 0) {
        sleep(10); // sleep for 10 seconds
        printf( "exiting 2\n", ret );
        exit(0);
    }
 
    sleep(100);
    printf( "Done sleeping", ret );
}

So what does the program above do?

Wait 5 seconds, then prints:
  Exiting 1
  My child got signal: 20
Terminates and gives the shell prompt
5 seconds later, we see:
  Exiting 2


Notice that the program above did not complete execution of sleep(100). So what happened to it exactly? Whenever a signal is delivered to a process, the process(if it was sleeping) wakes up prematurely. In this case, our main function stopped sleeping when the child process finished execution and sent its signal to the process.



A simple way to fix this problem is to add a while loop around the call to sleep. However, when does our program know when to stop sleeping? The solution is to use a process counter in our program. The process counter increments whenever a child process is created and decrements only when the signal handler receives SIGCHLD. The signal handler can tell our program to exit() when there are no more processes left.

int processes = 0;
 
void childsig(int signalno) {
    printf( "My child %d has gotten a signal: %d\n", signalno );
    processes--;
 
    if (processes == 0) {
        exit(0);
    }
}
 
int main(int argc, char *argv[]) {
    int status, pid, ret;
    signal( SIGCHLD , childsig );   
    processes++;
    pid = fork();
    if (pid == 0) {
        sleep(5); // sleep for 5 seconds
        printf( "exiting 1\n", ret );
        exit(0);
    }
 
    processes++;
    pid = fork();
 
    if (pid == 0) {
        sleep(10); // sleep for 10 seconds
        printf( "exiting 2\n", ret );
        exit(0);
    }
 
    While (1) {
        sleep(100);
        printf( "Still sleeping…\n", ret );
    }
}


Output for above code:

After 5 seconds
  exiting 1
  My child has gotten a signal: 20
  Still Sleeping
After 10 seconds
  exiting 2
  My child has gotten a signal: 20

There is still one more problem with this code!
The problem is that we never free the memory that we allocated for our child process. This means that when our child process becomes a zombie, it doesn't get killed. It continues to consume our precious memory resources! The way to kill child processes is to call waitpid(). waitpid not only kills our zombie processes, but it also tells us which process it ended.
Example of using waitpid:

int processes = 0;
 
void childsig(int signalno) {
    int status;
    int process;
 
    do {
        process = waitpid(0, &status, WNOHANG); //the first value we pass into waitpid tells it that it is waiting on a child process
                                                //status is used with macros, it can be used to tell us the exit status of the process
                                                //WNOHANG means to return immediately if no child has exited
        if (process > 0) {
            printf( "My child %d has gotten a signal: %d\n", process, signalno );
            processes--;
        }
    } while (process > 0);
 
    if (processes == 0) {
        exit(0);
    }
}
 
int main(int argc, char *argv[]) {
 
    int status, pid, ret;
    signal( SIGCHLD , childsig );  
    processes++;
    pid = fork();
 
    if (pid == 0) {
        sleep(5); // sleep for 5 seconds
        printf( "exiting 1\n", ret );
        exit(0);
    }
    processes++;
    pid = fork();
 
    if (pid == 0) {
        sleep(10); // sleep for 10 seconds
        printf( "exiting 2\n", ret );
        exit(0);
    }
 
     while (1) {
        sleep(100);
        printf( "Still sleeping…\n", ret );
    }
}

The program above outputs the same thing as the program above it with the exception that we also output the process ID of the child after we call waitpid on it. waitpid also kills any child processes that have exited, so that we are not wasting any memory. This is an example of both blocking and polling. Our loop waits for any subprocesses to end (polling), however it does not give up control of our process entirely, which means our process can work on another process if the wait takes too long (blocking).

Scheduling


There are two main types of scheduling: cooperative scheduling (aka cooperative multitasking) and preemptive scheduling (aka preemptive multitasking).
Cooperative scheduling: the running process decides when to yield its resources. This could potentially cause issues. For example, if the the running process decides to enter an infinite loop, the entire computer will be stopped. However, in most implementations, the process does not only yield its resources on a “exit” or “yield” call. In fact, most system calls in a kernel using cooperative scheduling will stop the running process.
Preemptive scheduling: the kernel can stop a potentially bad process and force it to give up control of its resources. Preemptive scheduling is more resilient to errors (more robust) but more complex to implement. Preemptive scheduling is used in most modern operating systems for user-level applications; however, cooperative scheduling is still often used by the kernel when it manages its own internal tasks.

Scheduling Algorithms


There are many different types of scheduling algorithms. The simplest of them all is probably First-in-first-out (FIFO) scheduling. In FIFO scheduling, the process that has been waiting the longest to start (first entered the scheduling queue) will be the first to be allocated resources.
Scheduling has several important attributes that should be optimized. These included the throughput, latency, priority scheme, expected value, worst case, wait-time, and context switch.


First In First Out Scheduling executes the processes in the order they are received.


For instance, we may have four processes that need to be executed. In this example, each process carries two pieces of information: the amount of time the process waits to run and the amount of time the process will take to run. When calculating the run time of a particular process, we must add an additional time factor known as a context switch, represented as χ. The context switch represents the amount of time the processor takes to switch from one process to another. Although the time taken for a context switch is relatively short, this time can be significant if many context switches are made in a short period of time. Context time is the overhead that occurs when processes yield control to the kernel. The kernel then must schedule the next task to be performed. In the example below, the context switch time is added incrementally as each process is completed.

To calculate process times and wait times, as shown in the previous chart, the following equations can be used:
To calculate wait time for process n:

Wait Time = Previous process’ turnaround time + Start Time of Current Process + Context Switch
To calculate turnaround time for process n:

Turnaround Time = Wait Time + Run Time
To calculate utilization for n processes:

Utilization:

Average Wait Time for n processes:

Threads

Using threads is the concept of using multiple virtual processors. It is important to note that threads do not create another set of memory or file descriptors. A given process can have multiple threads. Threads represent a single sequence of instructions that are executed in parallel with other threads. Threads can be executed in parallel either through time slices or multiprocessing. Adding threads, like adding another processor, increases utilization, but it complicates process synchronization. The goal for threads is to break down BIG processes into manageable sub-processes.
Threads generated within a process share the process descriptor, address space, and file descriptor table. This allows threads to communicate faster with each other.

Example of Thread Utility

Suppose there is a database that contains a queue of requests, where each requests’ complexity varies. 3). The scheduling of the requests can be done via First-in-first-out scheduling; however, this method has a less than optimal wait time. Optimal wait time can be achieved by using the Smallest-First scheduling method, but this often leads to a scenario known as starvation. Starvation occurs when a certain process is always passed over in favor of another and is often never executed. In this situation, as smaller requests to the database keep coming in, they are chosen to execute first (due to the Smallest-First scheduling); therefore, the large processes never get executed.

It is also possible to break a big request into smaller pieces, except it is extremely difficult. In this scenario, it is better to use threads. When a large request is encountered, the process can simply start a new thread and continue to execute the remaining requests.

Thread Interfaces

int pthread_create(pthread_t *threadid_out, const pthread_attr_t *attr, 
                    void *(*start_function)(void *), void *start_function_arg);
int pthread_join (pthread_t tid, void* *exit_status);
void pthread_exit (void *exit_status);


pthread_create() is used to create a new thread, with attributes specified by *attr.
pthread_join() suspends execution of the calling thread until the target thread terminates.
pthread_exit() terminates the thread and passes exit_status to any successful join.

Synchronization


The goal of synchronization is to allow multiple processes to run simultaneously and coherently. This means that processes should be allowed to communicate with each other through a narrow channel, but their input and output should remain distinct. Threads that are not synchronized have poor process isolation, which means that new threads can easily overwrite old ones leading to bad robustness for the entire system. The way to synchronize process requests is as follows:

struct Rentry{
  void* request;
  struct Reentry* next;
};
Reentry* queue;
Lock* queuelock;
 
void* get_next_request(){
retry:
  if(queue){
    acquire(queuelock);
    struct Reentry* ret = queue->next;
    void* rv = queue->request;
    free(queue);
    queue = ret;
    release(queuelock);
    return rv;
  }
 sleep(100);
 goto retry
}


The program above uses locks to synchronize processes. The way the above program works is, we have a queue that contains multiple process requests. First we acquire the queuelock, which prevents other threads from getting the same process request. Then we return the next request, and finally release the queuelock. Without locks there would not be process isolation in the above code. Once the queue is locked, any modifications can be made before lock is released upon exit.

Summary


In Lecture 6 we three main concepts: signals, scheduling, and threads. For signals we went over how to implement signals and use signal handlers. We saw how to use waitpid() to kill child zombie processes, in order to free up memory allocated to child processes. We also learned how to use blocking and polling within our signal handlers.
For scheduling, we looked at two different kinds of scheduling: cooperative and preemptive. We gave an example of one type of scheduling known as FIFO , or First In First Out, scheduling. Also shown is the method for calculating the wait time, turnaround time, and total utilization of our FIFO scheduler.
Lastly, we took a look at threads. We saw some of the useful features of threads, as well as some of the function interfaces used to implement them. We also described a major problem in thread robustness, which was synchronization. Finally, we saw one method for overcoming synchronization problems by acquiring and releasing locks.

1) , 2) it is possible to define your own signals and use them to communicate between processes. If you want another process to invoke a function in your process, make sure you set your function as the signal handler
3) As a side note: the latency of a given process is proportional to its complexity