pete > courses > CS 315 Fall 26 > lecture 13: signals


Lecture 13: signals

Goals


we’ve now discussed processes in detail: how they’re created (fork(2)) and how we can make them do what we want (exec(2))

we’ve also looked at the UNIX mechanisms of users and permissions and how these provide some degree of security

the notion of permissions, however, applies to files in the filesystem, and not to other processes

this begs the question: what can one process do to and/or with another process?

we’ve really only seen two examples of processes interacting: - a process forks to create a child - a child exits and the parent can wait for its exit value

you might wonder, then, what other ways can they interact?

since, as I’ve said, a child gets the exact same memory as the parent, might there be some sharing going on?

that is, if the parent changes some value in memory, would the child see the result?


we can test this by putting some values in various places in memory, forking to create a child, having the parent change the values, and then see if the child’s values are different

here is a program that does just that: fork-test.c

it creates a global variable and assigns it the value zero

it creates a local (stack) variable and also assigns it the value zero

it allocates memory in the heap and puts the value zero there

it allocates two regions of memory using mmap(2), one with MAP_PRIVATE and the other with MAP_SHARED, and stores zero in both

then it forks

after forking, the parent modifies all those values

whereas the child sleeps for one second (to allow the parent to finish its modifications) and then prints out the values of each

if the child reports the new values, that means we’ve got a method for the parent to communicate to its child

if the child reports all zeroes, we do not have such a method

here we go:

$ ./fork-test
I'm the parent and I've modified all the values
global_var is       0
local_var is        0
heap_var is         0
mmap_private_var is 0
mmap_shared_var is  60

we see that the only memory shared between parent and child is the region of memory mmaped using MAP_SHARED (the name perhaps gives it away)

this means that, to create a child process, fork(2) makes a copy of the parent’s globals, stack, heap, and private memory mappings

and any modifications either process makes to its own copy of those regions is unseen by the other process


even so, this shared memory still requires a parent/child relationship

it doesn’t allow unrelated processes (even if they’re owned by the same user) to communicate to each other

the question thus arises: can we do better?

the answer is, unsurprisingly, yes!

the mechanisms for processes to communicate with each other are collectively referred to as interprocess communication, abbreviated IPC

(and "shared memory" is one such mechanism, but not our focus today)

the main topic for today is one of the more rudimentary methods of IPC: signals

(warning: signals are going to look super primitive, and they are, because they’re used more for system management rather than the kinds of eloquent communication we might want—we’ll get to the more expressive methods in a couple weeks)


the POSIX standard defines a collection of signals, each identified by a name and a number, which may be sent from one process to another

(signal names and numbers can be seen in the signal(7) manpage)

when a process receives a signal, it executes code to "handle" it

the code that runs upon receipt of a signal is called a "signal handler"

the idea is that a process will tell the system which function to run when a particular signal is received

when that signal is received, the current code running in that process is suspended, the signal-handler function is called, when that function finishes, the system causes the process to resume its original execution context


here is a test program:

signal-test.c

it uses the sigaction(2) syscall to register a signal handler

this requires filling in an instance of struct sigaction

we specify the signal-handler function in the sa_handler member of that struct

and then, in the call to sigaction(2), we associate this handler with the SIGUSR1 signal (which has number 10)

next, it prints out its process id and then calls read(2) (and, if we type nothing at it, the process will just sit there)

if we compile and run this program, we should expect it to do nothing unless and until we send is the signal SIGUSR1

but how do we do that?


I’m so glad you asked

signals are, unsurprisingly, sent using a syscall, and that particular syscall is kill(2)

(indeed weirdly named; the reason will become clear soon)

as we can see in the manpage, kill(2) needs a process id and a signal number

we can therefore get away with a very simple program here:

mykill.c

it takes two command-line parameters: the pid and the signal number, which are just turned around and passed to kill(2) itself


we’ll need two terminal windows to test it

but first, compile:

$ make signal-test mykill
gcc -g -Wall -pedantic -o signal-test signal-test.c
gcc -g -Wall -pedantic -o mykill mykill.c

in the first terminal window:

$ ./signal-test
My pid is 821295
waiting for a signal

as claimed, it just sits there

but we use that pid in our second terminal window (recall that SIGUSR1 has number 10):

$ ./mykill 821295 10

back in the first window, we see new stuff:

I received signal 10
The time is 13:12:00 on 28 Oct 2019.
and we're back

the signal handler function ran (it printed the first two lines of output there)

and then it resumed with the final call to write(2) in main

excellent


OR IS IT

I said earlier that, when the signal handler function finishes, the process resumes where it left off

shouldn’t it have resumed in the middle of read(2) ?

shouldn’t it still be waiting for me to type at it?

why did it proceed to the final call to write(2) ?

answer: if a signal is received while a system call is in progress (and the system call is therefore interrupted to run the signal handler), the system call bails

let’s modify our previous test program to give us a little more information about the success of read(2)

(and, at the same time, be responsible programmers by checking the result of functions we call)

signal-test-syscall.c

(I’ve simplified the handler; the important part here is what happens with read(2))


compile:

$ make signal-test-syscall
gcc -g -Wall -pedantic -o signal-test-syscall signal-test-syscall.c

in terminal one:

$ ./signal-test-syscall
My pid is 821403
waiting for a signal

in terminal two:

$ ./mykill 821403 10

and back in terminal one we see:

received signal 10
read: Interrupted system call

so the signal was received, but read(2) failed

it returned an error—specifically, it returned the "interrupted system call" error, which is quite appropriate


one reaction to this behavior might be that having the process exit in this circumstance is unreasonable

instead of exiting, we might want it to try the read(2) again

but what if we get another signal in our second try?

presumably, we want to keep on trying until it works

but maybe we only want to keep on trying if we were interrupted by a signal

for whatever reason, we decide that "normal" failures of read(2) should cause the program to exit

therefore, we get this program, which demonstrates a common code pattern for handling function calls that we want to resume even if signals arrive:

signal-test-syscall-correct.c

this may seem like overkill for such a seemingly simple operation as read(2), and for many/most simple programs (and possibly even many complex ones), it quite likely is

but if we’re writing mission-critical software, we have to consider the possibility that a signal may arrive at any time, in which case we would use code like that shown above


a few loose ends need tying up here

first, I said that signals are often used for system management stuff, but the one we’ve looked at (SIGUSR1) doesn’t have an apparent system-management purpose

second, the mykill program does indeed take a signal number on the command-line, but it defaults to SIGTERM (number 2)

third, if we’re writing important software and want to handle signals responsibly, we ought to know what kind of signals a process might receive

let’s start from the last loose end and work our way back up


some signals are automatically sent when a process does something naughty

such as:

SIGSEGV (11) when it commits a segfault

SIGFPE (8) when it attempts to perform a floating-point operation that is disallowed (eg, divide by zero)

SIGILL (4) when it issues a malformed CPU instruction


other signals can be used to tell the process to do something

such as:

SIGSTOP (20) will pause the process

SIGCONT (18) will unpause the process

SIGTERM (15) will terminate the process

SIGINT (2) will interrupt the process

SIGHUP (1) will "hang-up" the process, which traditionally has the effect of causing the process to re-read its configuration (the name is super old, from the days when terminals were physical boxes attached to the computer via a phone line that could hang up)


yes, this means we can cause a program to crash and report segfault even though a segfault never really happened

in terminal one:

$ ./signal-test
My pid is 821552
waiting for a signal

in terminal two:

$ ./mykill 821552 11

and back in terminal one we see:

Segmentation fault (core dumped)

yes, you can set up a signal handler for SIGSEGV, but the process won’t resume after the handler is done executing: this one is fatal


another interesting one is SIGINT, the interrupt signal

this one is used to tell a process that it should wrap up its business and exit

in fact, this precisely the signal that is sent when you press ^C to terminate your process


in fact, all signals are fatal unless a signal handler is set up

in terminal one:

$ ./signal-test
My pid is 821574
waiting for a signal

now, in terminal two, we send it SIGUSR2 (number 12), which sounds innocuous and not at all fatality-inducing like SIGTERM:

$ ./mykill 821574 12

back in terminal one, it dies without executing that final write(2):

User defined signal 2

this begs the question: can we write a program that declines to exit when you press ^C?

yes, yes we can: sigint-ignorer.c

I compile it, run it, and press ^C at it, and all it does it laugh at me:

./sigint-ignorer
My pid is 821654
I'm going to sleep now.
^CYou pressed Ctrl-C and I'm not going to terminate!
<nelson>Ha ha</nelson>
I'm going to sleep now.
^CYou pressed Ctrl-C and I'm not going to terminate!
<nelson>Ha ha</nelson>
I'm going to sleep now.
^CYou pressed Ctrl-C and I'm not going to terminate!
<nelson>Ha ha</nelson>
I'm going to sleep now.
^CYou pressed Ctrl-C and I'm not going to terminate!
<nelson>Ha ha</nelson>
I'm going to sleep now.
^CYou pressed Ctrl-C and I'm not going to terminate!
<nelson>Ha ha</nelson>
I'm going to sleep now.
^CYou pressed Ctrl-C and I'm not going to terminate!
<nelson>Ha ha</nelson>
I'm going to sleep now.
I'm going to sleep now.
I'm going to sleep now.

so how do we make it go away?

we send it SIGTERM (number 15), which a process cannot handle, and causes it to exit immediately

$ ./mykill 821654 15

and, finally, the last two loose ends:

the reason mykill defaults to sending SIGTERM is because that’s what the real kill(1) program defaults to

because it is most often used without specifying a signal to send, because we want to end the target process, hence it is named somewhat gruesomely

(but we’ve already talked about reaping zombie orphan children, so…. Happy Halloween?)

Last modified: