pete > courses > CS 315 Fall 26 > lecture 13: signals
Lecture 13: signals
Goals
- describe the purpose of signals
- handle signals (correctly)
- send signals from both program and shell
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:
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:
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)
(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:
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?)