Lecture 9: Signals
Note: Reading these lecture notes is not a substitute for watching the lecture. I frequently go off script, and you are responsible for understanding everything I talk about in lecture unless I specify otherwise.
Pipes
Sending data between processes: what if the amount of data is variable?
Last lecture, we showed an example of the parent sending a fixed number of
bytes to the child. The parent called write
with exactly 6 bytes, and the
child called read
with exactly 6 bytes.
In practice, we often need to send data where we don’t know in advance how big
the message will be. To do this, it is very common for the receiving process to
read
in a while (true)
loop until all of the data has been read. We rely on
two important properties of read
:
- If you try
read
ing from an empty pipe,read
will block (i.e. take you off the CPU) until at least one byte is available in the pipe. - If the pipe is empty and all write-oriented file descriptors into the pipe
have been closed, then it is impossible for any process to add more data into
the pipe. If you were reading from an actual file on disk, this would be the
same as having read all the bytes from the file, and now being at the end of
file (EOF). In this situation,
read
returns 0 without blocking to indicate “hey, I didn’t read any bytes, and I also didn’t wait for any new bytes to come in because you’ve reached the end of the ‘file’.”
With this in mind, we can repeatedly call read
until we see it return 0, then
break out of the loop (because there must not be any more input coming).
Closing file descriptors
Previously, we explained how close()
is similar to free()
in that it frees
system resources (file descriptors occupy kernel memory), and you should avoid
leaking file descriptors as much as possible. However, leaking file descriptors
can be much worse than leaking memory, because if any write ends of a pipe
accidentally remain open, that will cause deadlock (i.e. the program will hang,
waiting forever).
This program is exactly the same as above, but with a close()
call commented
out such that the child process fails to close its write end of the pipe. Even
though the parent calls close()
, the child does not, so from the operating
system’s point of view, it’s theoretically still possible for more data to go
into the pipe in the future. Therefore, read()
blocks, waiting for input, and
does not return 0. The child process gets stuck on the read()
call waiting
for more data, and the parent process gets stuck on the waitpid()
call
waiting for the child process to exit. You can try running the program to see
what the file descriptor tables look like.
Implementing subprocess
Last time, we implemented a library function called system
that launches an
executable in a child process and waits for it to complete. What if we want to
run an executable, but we want to interact with it while it is running (e.g.
feed it input, process its output, etc)?
Similar to system
, subprocess
launches an executable in a child process.
However, instead of waiting for the child process to exit, it returns
immediately, so that the parent can communicate with the child while it runs.
It returns the PID of the child, as well as a file descriptor; if the parent
writes to this file descriptor, the child will be able to read that data.
We can write some code that is almost exactly the same as system
, but without
the waitpid
call, and with the addition of a pipe
:
But we have a problem: what file descriptor is the executable (run in the child
process) supposed to read from? In our test program, we end up with file
descriptor 3 as the read end of the pipe, but if the sample test program calls
open()
for some unrelated file before calling subprocess()
, then we will
end up as file descriptor 4 as the read end of the pipe. We have no guarantees
on what the pipe file descriptors will end up being, and since the executable
is going to run the same code every time, we need some deterministic file
descriptor to read from.
This is what “standard in,” “standard out,” and “standard error” are. Stdin is a hardcoded file descriptor (0) that a process reads input from, as standard practice. Stdout is a hardcoded file descriptor (1) that a process writes output to, as standard practice. Usually, all three of these file descriptors point to the terminal, but in situations like these, we can make them point elsewhere (e.g. stdin can point to the read end of the pipe, so that when the executable reads from file descriptor 0 as it’s hardcoded to do, that comes from the pipe).
dup2
The dup2
syscall rewires a file descriptor to point somewhere else:
int dup2(int source, int destination);
You can think of the parameter names like this:
int dup2(
int fd_pointing_to_the_thing_we_want_to_point_to,
int fd_we_want_to_change_to_point_to_the_first_argument);
Updating subprocess
We can update subprocess so that the child process’s stdin points to the read end of the pipe:
subprocess_t subprocess(const char *command) {
int fds[2];
pipe(fds);
subprocess_t process = { fork(), fds[1] };
if (process.pid == 0) {
close(fds[1]); // The child isn't writing to the pipe, so we can close
// the write end
dup2(fds[0], STDIN_FILENO); // Rewires fd 0 to point to the read end of
// the pipe. The read end of the pipe now has 2 file
// descriptors pointing to it.
close(fds[0]); // Now that STDIN_FILENO points to the read end of the
// pipe, we can close this extra file descriptor.
// Start the target exectuable:
char *argv[] = {"/bin/sh", "-c", (char *) command, NULL};
execvp(argv[0], argv);
}
// The parent isn't reading from the pipe, so we can close the read end:
close(fds[0]);
return process;
}
Finished and working version on (cplayground).
Recap
System calls:
execvp
runs an executable file, but takes over the process that calls it. (You aren’t allowed to have two executables running in the same process.)pipe
creates an in-memory file that can be used to establish inter-process communicationdup2
allows you to rewrite file descriptors (usually stdin/out/err) to point to different places
Library functions:
system
callsexecvp
in a child process and waits for it to finish- You may see this same function as
subprocess.run()
in Python, orchild_process.execFile()
in Javascript
- You may see this same function as
subprocess
callsexecvp
in a child process but returns immediately, giving you some way to communicate with the child while it’s running- You may see this same function as
subprocess.Popen
in Python, orchild_process.spawn
in Javascript
- You may see this same function as
If you’re curious, I taught another lecture in a different class about common
pitfalls in multiprocessing, and why I think you should use higher-level
functions like system
and subprocess
instead of calling fork whenever
possible: video,
slides
Signals
A signal is a way to tell a process that something has happened (e.g. the user has pressed CTRL+C, a segfault has happened, a new file was created on disk, etc.). Signals are very primitive in that there is no associated data attached to a signal, and you can’t even tell who sent the signal – you can only tell that the thing has happened.
When a signal comes in, that runs a signal handler, which is just a function to react to the thing that happened.
Common signals
- You’ve seen
SIGSTOP
andSIGCONT
already in the context of job control, which stop/resume a process SIGINT
(“sig interrupt”) is sent whenever the user presses ctrl+c on the keyboardSIGTSTP
(“sig temporary stop”) is sent whenever the user presses ctrl+z on the keyboard. The default behavior is to stop the process, but unlikeSIGSTOP
, a program can change this behavior, even ignoring the signal if it wants to.SIGCHLD
(“sig child”) is sent whenever a process’s child process has a job control state change (i.e. stops, continues, or terminates).SIGABRT
(“sig abort”) is usually sent by a process to itself when something has gone terribly wrong, and it wants to exit (maybe also save some debugging information to indicate what happened). If you throw an exception in C++, you usually terminate with SIGABRT.SIGSEGV
is sent by the operating system to a process when it makes an illegal memory access. This is what causes the “segmentation fault, core dumped” message to appear on the screen!
Signal handlers
Signal handlers are ordinary functions that we ask the operating system to run
whenever a signal comes in. The signal
function registers a signal handler,
asking the OS to run our function for a specified signal. Signal handlers take
a single argument, which is used to indicate which signal it is handling.
For example:
This is incredibly cool and useful, as it gives us the power to do multiple things concurrently within a single process. We can be in the middle of doing something, then have a signal come in, hop over and take care of whatever happened, then go back to what we were doing. Imagine working on your homework, and then someone calls you, so you hop over to take the call, then go back to what you were doing: that sort of thing is enabled by signal handlers such as these.
Perils of traditional signal handling
Unfortunately, signal handlers are extremely easy to misuse, and the consequences can be severe. Many regard signals to be one of the worst parts of Unix’s design; for interesting reading, the Ghosts of Unix Past series is an excellent read. You can also check out a lecture video I gave for a different class.
The main challenge of using signal handlers is preventing them from touching variables that are currently being used by the main body of execution.
To give you an analogy, once, I was cutting my hair with electric hair clippers when my little brother knocked on the door and wanted to see what I was doing. I showed him how the clippers and detachable guards worked, then went back to cutting my hair. But when I was talking to him, I removed the guard, and I went back to cutting my hair without remembering that, so I cut off all my hair…
Something similar can happen with signal handlers:
vector<string> stringsToProcess;
void sigintHandler(int sig) {
// Clear out stringsToProcess so we can shut down
stringsToProcess.clear();
}
int main() {
stringsToProcess = ...
signal(SIGINT, sigintHandler); // call sigintHandler when SIGINT is received
for (string &s : stringsToProcess) {
// DANGER: what if sigintHandler were to run in the middle of this
// loop? We would end up trying to access deleted memory, which is
// undefined behavior
processString(s);
}
}
Signal handlers are especially dangerous because you can’t clearly see where all these conflicts are. This code is spectacularly unsafe, and could theoretically cause anything to happen (from a segfault to deletion of all files on disk) if you get unlucky:
void sigintHandler(int sig) {
printf("Got SIGINT!\n");
}
int main() {
signal(SIGINT, sigintHandler);
while (true) {
printf("Sleeping...\n");
sleep(1);
}
}
Here’s a screen recording of an extended version of this program going terribly wrong.
You can’t tell that anything is wrong just from looking at this, but if you
know how printf
is implemented, then you know that it:
- first adds the thing you want to print to a global buffer in memory
- if there is a newline in the buffer,
write
s the buffer to stdout
Expanded, the above code looks more like this:
char outputBuffer[OUT_BUF_SIZE];
void sigintHandler(int sig) {
append "Got SIGINT!\n" to output buffer
if newline in buffer:
flush to stdout
}
int main() {
signal(SIGINT, sigintHandler);
while (true) {
append "Sleeping...\n" to output buffer
if newline in buffer:
flush to stdout
sleep(1);
}
}
This has a similar problem to the previous stringsToProcess
example, where
the signal handler clashes with the main body of execution if the signal
handler ends up running at an unfortunate time.
There are tools to control when signal handlers execute in order to avoid
these “race conditions,” but these functions are tricky to use, filled with
nuance, and they are completely ineffective when using external libraries.
You cannot call printf
, malloc
, or free
from a signal handler, and that
means you also can’t use anything from the C++ standard library such as
std::string
or std::vector
. The things you can safely do in a signal
handler function are so limited that I don’t think it’s worth writing them at
all.
Synchronous signal handling
To safely handle signals, I strongly feel the best option is to avoid the concurrency you get when using signal handling functions, and instead focus on one thing at a time. Keep one logical train of thought without jumping around due to interruptions. Write code like this:
main:
do my homework
wait for my partner to finish the problems
check answers
Or like this:
while true:
do some work
handle any email notifications that came in
We can accomplish this in three steps.
- First, we create a set of signals that we are interested in handling:
// Create a set of signals: sigset_t signals; // Initialize it to the empty set. This is IMPORTANT, otherwise the set will // contain random signals based on whatever garbage stack memory happened to // be at &signals before sigemptyset(&signals); // Add the signals of interest: sigaddset(&signals, SIGINT); sigaddset(&signals, SIGTSTP);
- Next, we tell the operating system to hold off on normal delivery of these
signals. We don’t want the default behavior of the signals (e.g. by default,
SIGINT, will terminate the process, and we don’t want that), and we don’t
want to run any signal handling functions… We just want the operating
system to hang onto these when they come in so that we can deal with them
later.
sigprocmask(SIG_BLOCK, &signals, NULL);
- Finally, we can wait for a signal to come in by using
sigwait
. This waits for any signal in our set to be received, and it returns the received signal via the second parameter:int received; sigwait(&signals, &received); printf("Received signal %d\n", received);
You can try out this simple demo, clicking the terminal and pressing ctrl+c and ctrl+z to send SIGINT and SIGTSTP.