OS Lab 4 - Processes. Signals

De la WikiLabs
Jump to navigationJump to search

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:

  1. create a new process with fork(2), and tell the parent from the child by the value it returns;
  2. wait for a child with waitpid(2), and read its exit status;
  3. run another program with the exec family of functions, and explain what the process keeps and what it loses;
  4. send signals with kill(2), and handle them with sigaction(2);
  5. 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
Note: A child that has ended, but has not been waited for yet, is a zombie: the kernel has freed its memory and files, and keeps only its PID and its status, for when the parent waits for it. 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
How a shell runs a command: it forks a copy of itself, the copy prepares itself and calls exec to become the command, and the shell waits for it to end.

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.

Note: Ctrl+C sends 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)
A signal handler is the reverse of a system call: the kernel calls into the program. After the handler, 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, like count above.
  • It should only call async-signal-safe functions, such as write(2), kill(2) and _exit(2). printf(3) and malloc(3) are not safe: the handler may have interrupted the program in the middle of one of them.
Note: If a signal arrives while the process is blocked in a system call, such as 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:

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
  1. 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.
  2. Run the command: fork(2), call execvp(3) in the child, and waitpid(2) for it in the parent. If execvp(3) fails, print NAME: command not found on standard error, and end the child with status 127.
  3. 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].
  4. Add two built-in commands, which the shell runs itself, without fork(2): exit, and cd DIR, which changes the current working directory with chdir(2), or to $HOME (getenv("HOME")) if there is no DIR.

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_t global 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 for EINTR around waitpid(2).
  • The handler is installed before fork(2). What happens to it in the child, after exec? Why would SIG_IGN not 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.