OS Lab 4 - Processes. Signals
In the last lab, we used system calls to work with files. So far, each of our programs has been a single process. In this lab, we will create new processes with fork(2), run other programs in them with exec, wait for them to end with waitpid(2), and send and handle signals. As an application, we will write a small shell.
Learning objectives
By the end of this lab you should be able to:
- create a new process with
fork(2), and tell the parent from the child by the value it returns; - wait for a child with
waitpid(2), and read its exit status; - run another program with the
execfamily of functions, and explain what the process keeps and what it loses; - send signals with
kill(2), and handle them withsigaction(2); - put all of these together into a simple shell.
Processes
The kernel runs tasks, and a process is a group of tasks which share their memory, and usually their open files and more. In this lab, each process will have a single task, so we will only use the word process from now on.
Every process is identified by a number, its PID (process ID), and has a parent: the process that created it. A process can find its own PID using getpid(2), and the PID of its parent using getppid(2).
// In: <unistd.h>
pid_t getpid(void); // The PID of the process itself
pid_t getppid(void); // The PID of its parent
fork(2)
A new process is created with:
// In: <unistd.h>
pid_t fork(void);
fork(2) creates a copy of the calling process, the child. Both processes continue from the same point, the return from fork(2), so fork(2) returns twice, once in each process:
| In the | fork(2) returns
|
|---|---|
| Parent | The PID of the child |
| Child | 0 |
| Parent, on error (no child was created) | -1, and sets errno
|
fork(2) returns twice: the PID of the child in the parent, and 0 in the child. From there on, the two processes run independently, each with its own copy of the memory.The child gets a copy of everything the parent has:
- its memory: the code, the global variables, the heap and the stack. The copy is made with copy-on-write (see the course), so it is fast, but after
fork(2)a change made by one process is not seen by the other; - its file descriptor table: the same numbers point to the same open files, so the two processes share the file offsets;
- its current working directory, its signal handlers (see below) and more.
The following program creates a child, and both processes print their PIDs and a variable:
1#include <errno.h>
2#include <stdio.h>
3#include <stdlib.h>
4#include <string.h>
5#include <sys/wait.h>
6#include <unistd.h>
7
8static void print_error(const char *what)
9{
10 char msg[256];
11 snprintf(msg, sizeof msg, "%s: %s\n", what, strerror(errno));
12 write(STDERR_FILENO, msg, strlen(msg));
13}
14
15int main(void)
16{
17 int x = 1;
18
19 pid_t pid = fork();
20 if (pid == -1) {
21 print_error("fork");
22 return EXIT_FAILURE;
23 }
24
25 char msg[256];
26 if (pid == 0) {
27 // The child: its own copy of x.
28 x = 2;
29 snprintf(msg, sizeof msg, "child: PID %d, parent %d, x = %d\n", getpid(), getppid(), x);
30 write(STDOUT_FILENO, msg, strlen(msg));
31 return EXIT_SUCCESS;
32 }
33
34 // The parent: pid is the PID of the child.
35 waitpid(pid, nullptr, 0);
36 snprintf(msg, sizeof msg, "parent: PID %d, child %d, x = %d\n", getpid(), pid, x);
37 write(STDOUT_FILENO, msg, strlen(msg));
38 return EXIT_SUCCESS;
39}
$ ./build/debug/app
child: PID 2741, parent 2740, x = 2
parent: PID 2740, child 2741, x = 1
The parent calls waitpid(2), which we will look at next, to wait for the child to end before printing, so the child's line always comes first. Without it, the two lines could come in either order: after fork(2), the kernel runs the two processes independently.
waitpid(2)
A parent waits for a child to end with:
// In: <sys/wait.h>
pid_t waitpid(pid_t pid, int *wstatus, int options);
| Parameter | Description |
|---|---|
pid |
The PID of the child to wait for, or -1 for any child
|
wstatus |
Where to store the status of the child, or nullptr if we do not need it
|
options |
0 to block until the child ends. WNOHANG: do not block, return 0 if the child has not ended yet
|
waitpid(2) returns the PID of the child that ended, or -1 on error (for example, ECHILD if there is no such child). The status is an int which packs several pieces of information. It is read with the following macros:
| Macro | Description |
|---|---|
WIFEXITED(status) |
True if the child ended normally, by returning from main() or calling exit(3)
|
WEXITSTATUS(status) |
The exit status, from 0 to 255. Only valid if WIFEXITED(status)
|
WIFSIGNALED(status) |
True if the child was killed by a signal |
WTERMSIG(status) |
The number of the signal. Only valid if WIFSIGNALED(status)
|
1#include <errno.h>
2#include <stdio.h>
3#include <stdlib.h>
4#include <string.h>
5#include <sys/wait.h>
6#include <unistd.h>
7
8static void print_error(const char *what)
9{
10 char msg[256];
11 snprintf(msg, sizeof msg, "%s: %s\n", what, strerror(errno));
12 write(STDERR_FILENO, msg, strlen(msg));
13}
14
15int main(void)
16{
17 pid_t pid = fork();
18 if (pid == -1) {
19 print_error("fork");
20 return EXIT_FAILURE;
21 }
22 if (pid == 0) {
23 exit(3);
24 }
25
26 int status;
27 if (waitpid(pid, &status, 0) == -1) {
28 print_error("waitpid");
29 return EXIT_FAILURE;
30 }
31
32 char msg[256];
33 if (WIFEXITED(status)) {
34 snprintf(msg, sizeof msg, "child %d exited with status %d\n", pid, WEXITSTATUS(status));
35 } else if (WIFSIGNALED(status)) {
36 snprintf(msg, sizeof msg, "child %d was killed by signal %d\n", pid, WTERMSIG(status));
37 }
38 write(STDOUT_FILENO, msg, strlen(msg));
39 return EXIT_SUCCESS;
40}
$ ./build/debug/app
child 2803 exited with status 3
ps shows it as <defunct>. A parent should always wait for its children: leaving zombies around for a long time is bad practice. If the parent ends first, the child is adopted by process 1, which waits for it.exec
fork(2) can only make copies of the running program. To run a different program, a process calls execve(2):
// In: <unistd.h>
int execve(const char *path, char *const argv[], char *const envp[]);
The kernel loads the executable file at path into the process, in place of the program that is running, and starts it with the given arguments and environment. Both argv and envp are arrays of strings ending with nullptr.
On success, execve(2) does not return: the program that called it is gone. If it returns, it failed, and it returns -1 and sets errno (for example, ENOENT if there is no such file).
The process is the same: it keeps its PID, its parent, its open file descriptors and its current working directory. Only its memory, and the program in it, are replaced.
glibc provides several functions on top of execve(2), which differ in how they take the arguments and how they find the program:
| Function | Arguments | The program is | Environment |
|---|---|---|---|
execl(path, arg0, arg1, ..., nullptr) |
A list | path |
Inherited |
execlp(file, arg0, arg1, ..., nullptr) |
A list | Searched for in PATH |
Inherited |
execv(path, argv) |
An array | path |
Inherited |
execvp(file, argv) |
An array | Searched for in PATH |
Inherited |
execve(path, argv, envp) |
An array | path |
envp
|
A common pattern when launching new programs is calling exec in the child, right after fork(2). The following program runs ls -l / and waits for it:
1#include <errno.h>
2#include <stdio.h>
3#include <stdlib.h>
4#include <string.h>
5#include <sys/wait.h>
6#include <unistd.h>
7
8static void print_error(const char *what)
9{
10 char msg[256];
11 snprintf(msg, sizeof msg, "%s: %s\n", what, strerror(errno));
12 write(STDERR_FILENO, msg, strlen(msg));
13}
14
15int main(void)
16{
17 pid_t pid = fork();
18 if (pid == -1) {
19 print_error("fork");
20 return EXIT_FAILURE;
21 }
22
23 if (pid == 0) {
24 char *argv[] = {"ls", "-l", "/", nullptr};
25 execvp(argv[0], argv);
26 // execvp() only returns if it failed.
27 print_error(argv[0]);
28 _exit(127);
29 }
30
31 int status;
32 waitpid(pid, &status, 0);
33 char msg[256];
34 snprintf(msg, sizeof msg, "ls exited with status %d\n", WEXITSTATUS(status));
35 write(STDOUT_FILENO, msg, strlen(msg));
36 return EXIT_SUCCESS;
37}
$ ./build/debug/app
total 64
lrwxrwxrwx 1 root root 7 Apr 22 2024 bin -> usr/bin
drwxr-xr-x 4 root root 4096 Sep 3 15:07 boot
...
drwxr-xr-x 12 root root 4096 Sep 3 21:06 var
ls exited with status 0
Signals
A signal is a small number that the kernel sends to a process, to tell it that something happened, right away, whatever the process is doing. Signals come from the kernel itself (an invalid memory access, a child that ended), from the terminal (Ctrl+C), or from other processes. Each signal has a name, a number and a default action, which is what happens to a process that does nothing about it:
| Signal | Number | Default action | Sent when |
|---|---|---|---|
SIGINT |
2 | End the process | Ctrl+C is pressed in the terminal |
SIGKILL |
9 | End the process. Cannot be caught or ignored | kill -KILL, kill -9
|
SIGSEGV |
11 | End the process | An invalid memory access |
SIGPIPE |
13 | End the process | Writing to a pipe that nobody reads |
SIGTERM |
15 | End the process | kill: a polite request to end
|
SIGCHLD |
17 | Ignore | A child ended or stopped |
SIGCONT |
18 | Continue a stopped process | kill -CONT
|
SIGSTOP |
19 | Stop (pause) the process. Cannot be caught or ignored | kill -STOP
|
SIGTSTP |
20 | Stop the process | Ctrl+Z is pressed in the terminal |
kill -l lists all of them.
Sending signals: kill(2)
// In: <signal.h>
int kill(pid_t pid, int sig);
Despite its name, kill(2) sends any signal, not only those which end the process. A process may only send signals to processes of the same user, unless it runs as the superuser (root).
The following program starts a child which waits forever, and ends it with SIGTERM:
1#include <errno.h>
2#include <signal.h>
3#include <stdio.h>
4#include <stdlib.h>
5#include <string.h>
6#include <sys/wait.h>
7#include <unistd.h>
8
9static void print_error(const char *what)
10{
11 char msg[256];
12 snprintf(msg, sizeof msg, "%s: %s\n", what, strerror(errno));
13 write(STDERR_FILENO, msg, strlen(msg));
14}
15
16int main(void)
17{
18 pid_t pid = fork();
19 if (pid == -1) {
20 print_error("fork");
21 return EXIT_FAILURE;
22 }
23 if (pid == 0) {
24 pause(); // Wait forever, or until a signal ends the process.
25 _exit(0);
26 }
27
28 sleep(1);
29 if (kill(pid, SIGTERM) == -1) {
30 print_error("kill");
31 return EXIT_FAILURE;
32 }
33
34 int status;
35 waitpid(pid, &status, 0);
36 if (WIFSIGNALED(status)) {
37 char msg[256];
38 snprintf(msg, sizeof msg, "child %d: %s\n", pid, strsignal(WTERMSIG(status)));
39 write(STDOUT_FILENO, msg, strlen(msg));
40 }
41 return EXIT_SUCCESS;
42}
$ ./build/debug/app
child 2911: Terminated
strsignal(3) (from <string.h>) returns the description of a signal, the way strerror(3) does for errno.
SIGINT to all the processes running in the foreground of the terminal: a program, and the children it creates, all receive it.Handling signals: sigaction(2)
A process can choose what happens when it receives a signal, with:
// In: <signal.h> (simplified)
struct sigaction {
void (*sa_handler)(int); // A function, SIG_DFL or SIG_IGN
sigset_t sa_mask; // Signals to block while the handler runs
int sa_flags; // SA_RESTART, ...
};
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
sa_handler |
When the signal arrives |
|---|---|
A function void handler(int sig) |
The kernel interrupts the program and calls the function, the signal handler, with the number of the signal. When the handler returns, the program continues where it was |
SIG_IGN |
The signal is ignored |
SIG_DFL |
The default action (see the table above) |
sigreturn(2) takes the program back to where it was interrupted.The following program counts Ctrl+C presses, and ends after the third one:
1#include <errno.h>
2#include <signal.h>
3#include <stdio.h>
4#include <stdlib.h>
5#include <string.h>
6#include <unistd.h>
7
8static volatile sig_atomic_t count = 0;
9
10static void on_sigint(int sig)
11{
12 (void) sig;
13 count++;
14}
15
16int main(void)
17{
18 struct sigaction sa = {.sa_handler = on_sigint};
19 sigemptyset(&sa.sa_mask);
20 if (sigaction(SIGINT, &sa, nullptr) == -1) {
21 char msg[256];
22 snprintf(msg, sizeof msg, "sigaction: %s\n", strerror(errno));
23 write(STDERR_FILENO, msg, strlen(msg));
24 return EXIT_FAILURE;
25 }
26
27 while (count < 3) {
28 pause(); // Sleep until a signal arrives.
29 const char msg[] = " Caught SIGINT\n";
30 write(STDOUT_FILENO, msg, strlen(msg));
31 }
32 return EXIT_SUCCESS;
33}
$ ./build/debug/app
^C Caught SIGINT
^C Caught SIGINT
^C Caught SIGINT
A signal handler can run between any two instructions of the program, so it must be careful:
- It should only change global variables of type
volatile sig_atomic_t, which the program reads after it returns, likecountabove. - It should only call async-signal-safe functions, such as
write(2),kill(2)and_exit(2).printf(3)andmalloc(3)are not safe: the handler may have interrupted the program in the middle of one of them.
read(2) or waitpid(2), the system call is interrupted: after the handler returns, it fails with errno set to EINTR. With SA_RESTART in sa_flags, most system calls are restarted instead, as if nothing happened.Blocking signals: sigprocmask(2)
A process can also block signals for a while, for example during a part of the program that should not be interrupted. A blocked signal is not lost: it stays pending, and is delivered as soon as it is unblocked.
// In: <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigprocmask(SIG_BLOCK, &set, nullptr); // Ctrl+C waits from here...
// ...
sigprocmask(SIG_UNBLOCK, &set, nullptr); // ...until here
Signals, fork(2) and exec
After fork(2), in the child |
After exec
| |
|---|---|---|
| Signals with a handler | Same handler | Back to the default action: the handler was part of the old program |
Ignored signals (SIG_IGN) |
Still ignored | Still ignored |
| Blocked signals | Still blocked | Still blocked |
Reading lines: readline
The project template already uses the GNU readline library, which reads a line from the terminal and lets the user edit it and recall earlier lines with the arrow keys:
// In: <readline/readline.h> and <readline/history.h>
char *readline(const char *prompt);
void add_history(const char *line);
readline(3) prints the prompt and returns the line, without the newline, in memory allocated with malloc(3), which the program must free(3). At the end of the input (Ctrl+D on an empty line), it returns nullptr. add_history() saves a line for the arrow keys.
By default, readline(3) installs signal handlers of its own while it waits for a line, to put the terminal back in order before the program is interrupted. A program which handles signals itself turns this off, before anything else, with:
rl_catch_signals = 0;
Exercises
Write the program in a new copy of the project template. As a reminder from the first lab:
mkdir -p ~/oslab && cd ~/oslab
curl -OL https://wiki.dcae.pub.ro/images/9/93/Os_lab_skeleton.zip
unzip -d lab04 Os_lab_skeleton.zip
For this lab:
- Use the glibc wrappers for the system calls (
fork(2),execvp(3),waitpid(2),kill(2), ...). - As in the last lab, do not use the standard I/O library for output: build the text with
snprintf(3)andwrite(2)it. Usereadline(3)for input.
A simple shell
Write a shell: a program which reads commands with readline(3), runs them, and waits for them to end, until the user types exit or presses Ctrl+D.
$ ./build/debug/app
$ echo hello world
hello world
$ ls /nonexistent
ls: cannot access '/nonexistent': No such file or directory
[exit status 2]
$ nosuchcmd
nosuchcmd: command not found
[exit status 127]
$ cd /usr
$ pwd
/usr
$ exit
- Read a line, and split it into words, at spaces and tabs, into an array of strings ending with
nullptr.strtok_r(3)can be used. - Run the command:
fork(2), callexecvp(3)in the child, andwaitpid(2)for it in the parent. Ifexecvp(3)fails, printNAME: command not foundon standard error, and end the child with status 127. - After the command ends, print its exit status on standard error if it is not 0, as
[exit status N], or the signal that killed it, as[killed by signal N: NAME]. - Add two built-in commands, which the shell runs itself, without
fork(2):exit, andcd DIR, which changes the current working directory withchdir(2), or to$HOME(getenv("HOME")) if there is noDIR.
Bonus: Set rl_catch_signals = 0 as the first thing in main(), and install a handler for SIGINT which passes the signal on to the running command, with kill(2).
$ sleep 10
^C[killed by signal 2: Interrupt]
$ echo still here
still here
- Keep the PID of the running command in a
volatile sig_atomic_tglobal variable, and set it back to 0 when the command ends. At the prompt, there is no command to pass the signal to. - Use
SA_RESTART, or check forEINTRaroundwaitpid(2). - The handler is installed before
fork(2). What happens to it in the child, afterexec? Why wouldSIG_IGNnot work instead of a handler?
Extra: Support output redirection: ls -l / > listing.txt. In the child, before execvp(3), open the file, and make the standard output point to it with dup2(2): dup2(fd, STDOUT_FILENO) makes file descriptor 1 point to the same open file as fd, which can then be closed. Remove > and the file name from the arguments.