Consider the following command:
$ cat /dev/zero > /dev/null
This will run forever until user presses ^C in the terminal. The terminal driver infroms the OS that ^C was pressed. The OS then sends the signal SIGINT to all process with this terminal as the controlling terminal.
This motivation for signals comes from having to deal with uncooperative and broken processes.
Use signals to handle a mess, and only when all other means of communication have failed. Killing a process with a pipe is unreliable and harder to work with. In practice, processes treat signals as more important, thus using signals is a more definitive way to kill a process.
Signals change how our abstract machine works. When a signal arrives to the process, an asynchronous function call is invoked in order to handle the signal, this funciton call is often called the handler.
#include <signal.h>
signal(SIGINT, handle_int);
// handle_int is a function pointer that points to the handler
void handle_int(int sig)
{
printf("ouch! \n");
exit(1);
}
Internally, printf (which is a part of standard I/O):
The problem: suppose some ordinary code contains:
printf("yay!");
Then printf will:
------Signal arrives!----------------
Problem!
printf calls malloc --> re-entering malloc!
program corrupts heap --> crashes!
void handle_int( int sig ){
write(STDERR_FILENO, "ouch!\n", 6);
_exit(1);
}
Note that exit(1) causes problems the same reason printf does, so we use _exit(1) instead, which is a syscall
Limit ourselves to async-signal-safe functions!
Suppose heap is implemented simply as follows:
char h[1000000];
char *hp = h;
| in use | free | h hp hMalloc:
inline void *malloc( size_t s){
char *r = hp;
hp = hp + s;
return r;
}
Some Function:
somefn( ){
r = hp;
hp = hp + s;
}
- function modifies some global data structures
Problem! we want signal handlers to be able to see these structures
somefn( ){ sigset_t 0; sigprocmask(SIG_BLOCK, sigset_t s, &0); -| r = hp; | hp = hp + s; |----> Critical Section sigprocmask(SIG_SETMASK, &0, 0); _| }
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
int how
SIG_BLOCK: adds set to the set of blocked signals
SIG_UNBLOCK: remove set from the set of blocked signals
SIG_SETMASK: set is the set of blocked signals
const sigset_t *set is the signals you want to change
sigset_t *oldest is the previous value of signal mask
Limitations: - sigprocmask is a system call --> slows you down - many applications get signals wrong:
program might create /tmp/foo, gets SIGINT but has no handler
Problem! /tmp/foo is leftover trash
program establishes a SIGINT handler signal handler does unlink("/tmp/foo") BUT program: - create /tmp/foo - computes - unlink("/tmp/foo") ------Signal arrives!------- Problem! unlink("/tmp/foo") again - exit Solution: - unlink("/tmp/foo") - sigprocmask to block SIGINT - exit - sigprocmask to unblock
Threads can be considered lightweight processes that perform tasks in parallel with other threads. Each thread has its own instruction pointer, registers, and stack which allow for multi-tasking. However unlike processes, threads running inside a process share memory, file descriptors, and other things such as the UID. Because of this lack of isolation, using threads creates a high risk for race conditions. If threads are implemented carefully, they have several advantages over multi-processing: threads are created more quickly than processes because utilize the current process's address space, switching between threads is quicker for similar reasons, and communicating between threads is quick because of their shared memory.
The syntax to create a new thread is:
int pthread_create(pthread_t *threadID, const pthread_attr_t *attr, void *(*start_routine)(void *), void* arg);
This creates a thread with attributes attr and runs the function start_routine
with arguments arg. Upon completion, pthread_create() stores the thread ID in *thread.
The function pthread_join() is used to prevent race conditions by suspending execution until the thread designated by threadID terminates via pthread_exit().
int pthread_join(pthread_t threadID, void *retVal); void pthread_exit(void *retStatus);
On a sucessful join, the return value retVal is set to the value of retStatus.
If a thread needs to wait for a device to be ready, a standard way is to insert a while loop that checks the status of the device.
while(device_is_busy) continue;
This example represents busy waiting (polling). The drawback polling is that the waiting thread hogs CPU that could be used for other threads. To fix this issue, threads can call the yield() function instead. This function gives up control CPU and allows another thread with high priority to run.
while(device is busy) yield();
Cooperative threading is a technique that allows threads to decide when to give up its CPU for other threads. This model can simplify scheduling and reduce the amount of race conditions that occur. To implement proper cooperative threading, threads need to voluntarily give up control of the CPU every once in a while by yielding or blocking. A disadvantage to cooperative threading is that one thread can be uncooperative and keep control of the CPU if it is designed incorrectly.
Preemptive threading uses a timer interrupt at the hardware level and acts like an interrupt instruction. About every 10 ms, the CPU traps and inserts a yield() into the program automatically. The kernel saves and restores contexts, including threads. This approach is much simpler than cooperative but has a more costly overhead. Since the yield() function is now automatically inserted, it can cause unpredictability in the code. Some task that we expect to run next can randomly yield() and run much later, which may result in race conditions.
Notice that cooperative threading is more structured for single-core computers because it is faster and simpler. Preemptive threading is more structured for multi-core processors because of the processor's need for more parallelism and locking.
Threads and processes must deal with scheduling to produce the correct results. Different policies and mechanisms are used by the scheduler to help coordinate the different processes and threads.
Scheduling policies are algorithms that are implemented to coordinate threads or processes to execute tasks correctly and efficiently. The scheduler needs to be quick in order to create the illusion of seamless executions and to maximize the efficiency of the CPU. The scheduler should also limit its use of RAM in order to keep itself invisible to the user. Because the typical user prefers speed over efficiency, the contrainst on RAM usage is larger, which limits the capabilites of the scheduler to more simple algorithms.
Scheduling metrics can be applied to processes in order to compare the performances of different scheduling policies.
Arrival Time: The time when a task arrives into the system.By averaging these times, we can find the strengths and weaknesses of different scheduling policies. The goal is to find a policy that minimizes the average turnaround time. A low turnaround time represents good throughput, the amount of work a computer can do in a given period of time, and utilization, the ratio of the actual amount of CPU being used over the total CPU that can be used. Average wait time can be used to measure the fairness of the scheduler. A good scheduler will have a low average wait time.
Source: Masaki Moritani (Fall 2010)