Detailed Behaviors of Unix Signal

Dyx

When Unix is mentioned in this document it means macOS or Linux as they are the mainly used Unix at this moment. When shell is mentioned it means Bash or Zsh. Most demos are written in C for macOS with Apple libc and Linux with glibc.

signal is a function defined in libc. sigaction is a function defined by POSIX. sigaction is able to specify many detailed behaviors of a signal while signal can’t. As a libc function, signal behaves differently across systems thus don’t use it if the portability is required and sigaction is available.

This document narrows the environment down to macOS with Apple libc and Linux with glibc. The behavior of signal is determined then. Thus signal is used to demonstrate for conciseness.

Table of Contents

Pending and Blocking

A process has two bitmap — pending and blocked. Sending a signal to a process is just setting the corresponding bit in pending to 1. Blocking a signal is setting the corresponding bit in blocked to 1.

The process checks pending & ~blocked to find which signals are ready to be delivered. Making a signal delivered is resetting the corresponding bit in pending to 0 and:

The loop keeps running even it’s in process of running a handler.

The above text describes most behaviors of signals installed by the signal function in modern Unix. Some of them can be modified by calling sigaction instead of signal. For example, sigaction can specify which extra signals to block while handling it by the sa_mask variable and whether to restart automatically an interrupted system call by the SA_RESTART flag. See the manual page of sigaction for detail1 2.

If a signal with the same signal number are sent to a process twice within an enough short duration that the second signal arrives before the first is delivered, the second arrival will be ignored because the corresponding bit is already 1. If the second signal arrives while handling the first, it will be delivered after the first one is delivered.

A handler will be interrupted by handling another unblocked signal because the loop keeps running while handling a signal. After the new handler returns, the old one resumes.

Ignoring

Signals can be ignored by a process — e.g. signal(SIGALRM, SIG_IGN) ignores SIGALRM. What does ignoring mean exactly? Is it setting an empty function as the handler or it is a special flag? This varies across systems.

On macOS, ignoring is more like a special flag. It clears the corresponding bit in the current pending bitmap and prevents subsequent arrivals of the signal to update pending3. Thus if a signal arrives at a process blocks and ignores it, the arrival will not be handled even later the process unblocks and installs a handler for it.

On Linux, ignoring a signal is more like setting an empty function as the handler. It also clears the corresponding bit in the current pending bitmap but not prevents subsequent arrivals to update pending. The subsequent arrivals are handled normally by doing nothing4. So if the signal is blocked and ignored, the arrival will be handled after the process unblocks and installs a handler.

The following C program verifies the different behaviors.

/* Print `14 delivered` on Linux
 * Print nothing on macOS
 */
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <sys/types.h>

void handler(int sig)
{
	printf("%d delivered\n", sig);
}

int main(void)
{
	sigset_t sigalrm;
	sigemptyset(&sigalrm);
	sigaddset(&sigalrm, SIGALRM);

	sigprocmask(SIG_BLOCK, &sigalrm, NULL);
	signal(SIGALRM, SIG_IGN);

	alarm(1);
	sleep(3);

	signal(SIGALRM, handler);
	sigprocmask(SIG_UNBLOCK, &sigalrm, NULL);
	sleep(3);

	return 0;
}

It prints 14 delivered on Ubuntu 18.04 but prints nothing on macOS .

System Call Interruption

Some system calls may be interrupted and terminated with the error EINTR by invoking a signal handler. Setting the SA_RESTART flag on the handler with sigaction makes the system call automatically restarts.

Handlers installed with signal has the SA_RESTART flag set by default. The following C code verifies the behaviors.

#include <stdio.h>
#include <unistd.h>
#include <signal.h>

void nop(int sig);

int main(void)
{
	signal(SIGALRM, nop);
	alarm(1);

	char buf[0xff];
	if (read(0, buf, 1) < 0) {
		perror("read");
		return 1;
	}

	puts("DONE");
	return 0;
}

void nop(int sig) { }

On both macOS and Linux the program waits until a character is entered.

On Linux, there are some system calls can’t be restarted even the SA_RESTART flag is set. See signal(7)5.

A notable fact is that the document of glibc version 2.326 conflicts with the man-page5. It states that handlers installed by signal of glibc doesn’t restart system calls. The experiment above shows that the man-page is correct. Maybe there are some misunderstanding here so the paragraph of the glibc document is quoted below.

WARNING The following quote doesn’t state the fact if there’s no misunderstanding.

When you don’t specify with sigaction or siginterrupt what a particular handler should do, it uses a default choice. The default choice in the GNU C Library is to make primitives fail with EINTR.

Fork and Exec

A forked process inherits the signal disposition from the parent process but pending is discarded.

If a process calls an exec* function, signal handlers are reset to SIG_DFL except the ignored. The ignored signals keep ignored. The blocked mask is also kept.

sa_mask and sa_flags are kept after reloading on macOS but not on Linux. However, keeping sa_mask and sa_flags or not is meaningless. Because the signal is set to either SIG_DFN or SIG_IGN after reloading, there’s no place for sa_mask or sa_flags to play in. If a handler is later installed in the reloaded process, sa_mask and sa_flags will be overwritten.

The following C code demonstrates the behaviors.

#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <sys/errno.h>
#include <sys/wait.h>

/* Print the error message set by errno and exit */
void unixerr(char *msg);

/* Return "yes" if n is non-zero else "no" */
char *yesno(int n);

/* Print the signal state of SIGALRM */
void psigst(void);

/* Do nothing */
void nop();

int main(int argc, char **argv)
{
	if (argc > 1) {
		/* reloaded with exec */
		printf("RELOADED\n------\n");
		psigst();
		return 0;
	}

	sigset_t sigalrm, sigterm;
	sigemptyset(&sigalrm);
	sigemptyset(&sigterm);
	sigaddset(&sigalrm, SIGALRM);
	sigaddset(&sigterm, SIGTERM);
	sigprocmask(SIG_BLOCK, &sigalrm, NULL);

	struct sigaction sa = {
		.sa_handler = nop,
		.sa_mask = sigterm,
		.sa_flags = SA_NODEFER
	};

	sigaction(SIGALRM, &sa, NULL);

	alarm(1);
	sleep(2);

	printf("PARENT\n------\n");
	psigst();
	printf("\n");

	pid_t cpid = fork();
	if (cpid == -1)
		unixerr("fail to fork");

	if (!cpid) {
		printf("FORKED\n------\n");
		psigst();
		printf("\n");
		fflush(stdout);

		execl(argv[0], argv[0], "exec", NULL);
		unixerr("fail to reload");
	}
	else {
		if (wait(NULL) == -1)
			unixerr("wait failed");
		return 0;
	}
}

void unixerr(char *msg)
{
	perror(msg);
	exit(1);
}

char *yesno(int n)
{
	return n ? "yes" : "no";
}

void psigst(void)
{
	sigset_t pending, blocked;
	struct sigaction sa;

	sigpending(&pending);
	sigprocmask(0, NULL, &blocked);
	sigaction(SIGALRM, NULL, &sa);

	printf("SIGALRM pending: %s\n", yesno(sigismember(&pending, SIGALRM)));
	printf("SIGALRM blocked: %s\n", yesno(sigismember(&blocked, SIGALRM)));
	printf(
		"SIGALRM sa_mask: %s\n",
		sigismember(&sa.sa_mask, SIGTERM) ? "SIGTERM" : "{}"
	);
	printf("SIGALRM sa_flags: %d\n", sa.sa_flags);
}

void nop() { }

Output on macOS

PARENT
------
SIGALRM pending: yes
SIGALRM blocked: yes
SIGALRM sa_mask: SIGTERM
SIGALRM sa_flags: 16

FORKED
------
SIGALRM pending: no
SIGALRM blocked: yes
SIGALRM sa_mask: SIGTERM
SIGALRM sa_flags: 16

RELOADED
------
SIGALRM pending: no
SIGALRM blocked: yes
SIGALRM sa_mask: SIGTERM
SIGALRM sa_flags: 16

Output on Linux:

PARENT
------
SIGALRM pending: yes
SIGALRM blocked: yes
SIGALRM sa_mask: SIGTERM
SIGALRM sa_flags: 1140850688

FORKED
------
SIGALRM pending: no
SIGALRM blocked: yes
SIGALRM sa_mask: SIGTERM
SIGALRM sa_flags: 1140850688

RELOADED
------
SIGALRM pending: no
SIGALRM blocked: yes
SIGALRM sa_mask: {}
SIGALRM sa_flags: 0

Resuming the Stopped

A process can’t ignore or reset a handler for SIGSTOP. When SIGSTOP is sent to a process, the process will be stopped (suspended). Pressing CTRL + Z in terminal sends SIGTSTP to the foreground processes. The default behavior of SIGTSTP is also suspending the process but it can be reset by the process.

A stopped process resumes after SIGCONT is sent to it. The behavior of sending a signal other than SIGCONT to a stopped process differs across systems.

On macOS, any signal resumes a stopped process even the signal is blocked.

On Linux, (except SIGCONT) SIGTERM resumes a stopped process (even blocked) but other signals don’t.

On both systems, SIGKILL terminates a process no matter it’s stopped or not.

SIGCHLD

While a child process is terminated or suspended or resumed, SIGCHLD will be sent to the parent process. If a handler of SIGCHLD is installed in the parent process with the SA_NOCLDSTOP flag, the signal will be sent only when a child process is terminated.

Normally, a process becomes a zombie and waits for the parent to reap it. However, if the parent installs a handler of SIGCHLD with the SA_NOCLDWAIT flag, children won’t become zombies anymore and they’re reaped automatically.

If the parent explicitly specifies SIG_IGN as the action for the signal SIGCHLD, zombies will not be created even the SA_NOCLDWAIT flag is removed from sa_flags 3 2. The following C code verifies the behavior.

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <errno.h>

int main(void)
{
	sigset_t empty;
	sigemptyset(&empty);
	sigaction(SIGCHLD, &(struct sigaction) {
		.sa_handler=SIG_IGN,
		.sa_mask=empty,
		.sa_flags=SA_RESTART /* No SA_NOCLDWAIT here */
	}, NULL);

	pid_t cpid = fork();

	if (cpid < 0) {
		perror("fork");
		return 1;
	}

	if (cpid) {
		sleep(3);
		if (kill(cpid, 0) == -1 && errno == ESRCH)
			puts("the child is reaped");
		else
			puts("the child is NOT reaped");
	}
	else
		return 0;

	return 0;
}

The program prints both in macOS and Linux:

the child is reaped

SIGINT

When CTRL + C is pressed, the shell sends SIGINT to all processes in the foreground process group.

However, if background processes are launched in a non-interactive shell, SIGINT and SIGQUIT are by default ignored for the background processes7. Moreover, if a background process is a shell, the POSIX standard forbids it to restore the handler of SIGINT and SIGQUIT8. zsh doesn’t respect the weird requirement but bash does.

Consider the following 3 shell programs. parent.sh launches a.sh and b.sh as background processes and waits for their terminations.

# parent.sh

./a.sh &
./b.sh &

wait
# a.sh

while true
do
	echo message from a
	sleep 2
done
# b.sh

while true
do
	echo message from b
	sleep 2
done

If CTRL + C is pressed while parent.sh is running, parent.sh will be terminated but a.sh and b.sh keep running. Restoring SIGINT handlers in a.sh and b.sh could make them terminated in zsh but not in bash.

A solution for most simple cases is to send a signal other than SIGINT and SIGQUIT — e.g. SIGTERM — to child processes while the parent process exits.

#!/bin/bash
# parent.sh

function onexit() {
	kill -- `pgrep -P $$`
}

trap onexit EXIT

./a.sh &
./b.sh &

wait

SIGHUP

When the terminal disconnects — e.g. close Terminal.app on macOS — SIGHUP will be sent to the session leader which is usually a shell9. SIGHUP is also sent to the foreground process group if the session leader terminates.

After SIGHUP is delivered to the shell, the shell may exit immediately or send SIGHUP to background processes before it exits. In any case SIGHUP will be sent to the foreground process group.

Bash has an option called huponexit. It could be checked by shopt | grep huponexit. If huponexit is on and it’s in an interactive login shell, SIGHUP will be sent to background processes10.

On both macOS and Linux, being interactive means it can’t be running a script file.

On Linux, being a login shell means an SSH shell or a true TTY. A shell open by the local terminal emulator is not a login shell11.

On macOS, a shell open by Terminal.app does also be a login shell12. However if a subshell is entered, the subshell is a non-login shell.

In Zsh if the job control is enable which is by default in an interactive shell and the nohup option is off which is by default in most systems, SIGHUP will be sent to background processes. Run setopt in zsh; If the option is on it would appear in the output13. Zsh provides an extension syntax command &! to run command in background and disown from SIGHUP14.

PID 1

There were barely chances to write an init program until Docker becomes popular. Docker is Linux-based so this section discusses Linux only and demos are for Docker.

The only signals that can be sent to process ID 1, the init process, are those for which init has explicitly installed signal handlers15. Thus even SIGKILL can’t be sent to PID 1.

The following init.sh and Dockerfile build a container whose init program is init.sh.

#!/bin/bash
# init.sh

while true
do
	sleep 1
done
# Dockerfile

FROM ubuntu:18.04
COPY init.sh /
ENTRYPOINT ["/init.sh"]

Running docker build -t init . to build the image and running docker run --name init --rm -it init to start a container and attach it to the terminal. Open another terminal and run docker exec -it init bash to attach to the container in the new terminal and make bash be the shell.

In the new terminal run ps -ax and it will print the following.

root@784455b692bd:/# ps -ax
  PID TTY      STAT   TIME COMMAND
    1 pts/0    Ss+    0:00 /bin/bash /init.sh
   10 pts/1    Ss     0:00 bash
   21 pts/0    S+     0:00 sleep 1
   22 pts/1    R+     0:00 ps -ax

It confirms that init.sh is run with PID 1.

Execute kill -TERM 1 then ps -ax. The result shows that init.sh is still there. SIGTERM is ignored though init.sh never sets it. The result is the same if kill -TERM 1 is replaced with kill -KILL 1.

Also it can’t be terminated by press CTRL + C in the previous terminal because SIGINT is also ignored.

Modify init.sh to the following.

#!/bin/bash
# init.sh

trap exit SIGTERM
trap exit SIGINT

while true
do
	sleep 1
done

CTRL + C and kill -TERM 1 work properly now.

References