Developer Tools · Command-Line Craft
Process Groups, Sessions, and the Mechanics of Job Control
Why Ctrl-C reaches a whole pipeline at once, what a background job actually is, and which kernel structures the shell manipulates to make fg and bg work.
A terminal runs many processes and yet Ctrl-C affects only some of them. The selection is not made by the shell, and it is not made by the process that receives the signal. It is made by the terminal driver, using two identifiers that every process carries and that most programs never inspect: the process group and the session.
Three identifiers attached to every process
Every process has a process id. It also has a process group id, which is inherited from its parent unless it is changed, and a session id, which is inherited in the same way. A process group is a set of processes that can be signalled as a unit; a session is a set of process groups associated with one controlling terminal.
A process becomes a group leader when its pid equals its pgid, which happens when it calls setpgid on itself or is placed in a new group by its parent. A session leader is a process whose pid equals its sid, created by setsid, which also detaches the caller from any controlling terminal. When a session leader subsequently opens a terminal device and does not pass O_NOCTTY, that terminal becomes the session’s controlling terminal.
The shell exploits all three levels. It places each job in its own process group so that the job can be signalled without touching the shell, and it keeps itself in a group of its own so that a signal aimed at a job cannot reach the interpreter that is waiting for it.
/* parent side: put the child in a new group, then hand it the terminal */
pid_t shell_pgid = getpgrp();
int status;
int ttyfd = STDIN_FILENO; /* the controlling terminal */
pid_t pid = fork();
if (pid == 0) {
setpgid(0, 0); /* new group, leader is the child */
/* ... dup2 the pipe ends, exec the command ... */
}
setpgid(pid, pid); /* also from the parent, to close the race */
tcsetpgrp(ttyfd, pid); /* child's group becomes foreground */
waitpid(pid, &status, WUNTRACED);
tcsetpgrp(ttyfd, shell_pgid); /* take the terminal back */
Both processes call setpgid because the order in which the parent and child run is not fixed: if the parent is preempted before its call, the child’s own call has already done the work, and vice versa.
Foreground is an attribute of the terminal
The terminal device records which process group is currently in the foreground. The value is set with tcsetpgrp and read with tcgetpgrp. Everything the driver does with keyboard-generated signals is addressed to that group.
When the driver’s line discipline sees the interrupt character, Ctrl-C by default and configured through the VINTR control character, it sends SIGINT to every process in the foreground group. It does not send it to the shell, and it does not send it to background jobs. The same rule applies to SIGQUIT from VQUIT and to SIGTSTP from VSUSP. This is why interrupting a | b | c stops all three stages: they are all members of one foreground group, and the driver signalled the group rather than a process.
The design explains a behaviour that otherwise looks arbitrary. A pipeline whose stages were each placed in separate groups would leave orphans running after an interrupt, and a pipeline whose stages stayed in the shell’s group would deliver the interrupt to the shell as well. One group per job, one foreground group per terminal, is the arrangement that satisfies both constraints.
Stop, continue, and the signals nobody types
SIGTSTP is not a kill. Its default action is to stop the process, which the kernel records as a state rather than a termination, and the parent can observe it with waitpid and the WUNTRACED flag. The shell does exactly that: when the foreground job stops, the wait returns, the shell records the job’s state and its pgid, takes the terminal back with tcsetpgrp, and prints a prompt.
Resuming reverses the sequence. fg puts the job’s group back in the foreground, sends SIGCONT to the group, and waits again. bg sends SIGCONT without changing the foreground group, so the job runs while the shell keeps the terminal.
Two further signals are generated by the driver rather than by a key. A background process that tries to read from its controlling terminal receives SIGTTIN, whose default action is to stop it, because allowing the read would let two jobs compete for the same input. A background process that writes receives SIGTTOU only if the terminal’s TOSTOP flag is set, which is off by default; the asymmetry exists because interleaved output is usually tolerable while interleaved input is not. Both flags live in the same structure that governs canonical mode and echo, and the shell itself sets TOSTOP in some configurations so that a background job cannot scribble over the prompt.
What the ampersand changes
Running cmd & makes the shell fork, place the child in a new process group, and return to the prompt without waiting. The child’s standard input still refers to the terminal, so a background job that reads will stop itself with SIGTTIN at the first read. This is the most common confusion about background jobs: the job did not fail, it is stopped, and jobs will show it in that state.
The shell also records the job in an internal table with a job number. %1 and kill %1 are shell syntax resolved against that table, not kernel objects; a job number means nothing outside the shell that created it. A negative pid in kill, as in kill -TERM -12345, addresses a process group directly and does not depend on the table.
Job control itself is a shell mode. An interactive shell enables it by default; a non-interactive shell does not, so a script’s background children remain in the script’s own process group. An interrupt delivered to that group therefore reaches the background children as well, which is often the opposite of what the script’s author intended. set -m enables job control in a script, at the cost of each job running in its own process group, so a signal aimed at the script no longer reaches the jobs it started.
Orphaned groups and the hangup
A process group becomes orphaned when no member has a parent in a different group within the same session. POSIX specifies special treatment for an orphaned group that contains stopped processes: the kernel sends SIGHUP followed by SIGCONT to the group. The hangup is not a courtesy; without it, a stopped process whose parent has exited would remain stopped forever, because nothing in the session is left to send it SIGCONT.
The terminal adds a second hangup path. When the terminal device is closed, because the session leader exited or the connection dropped, the driver sends SIGHUP to the foreground process group. A shell exiting sends SIGHUP to its jobs for the same reason, and nohup works by setting SIGHUP to be ignored before executing the command, which survives exec and is inherited by everything the command spawns. disown achieves a related result differently: it removes the job from the shell’s table so the shell does not signal it on exit, without changing the child’s dispositions.
Where the model leaks
The arrangement assumes one controlling terminal per session, and it assumes the terminal is the source of job control signals. Neither holds for a process started by a supervisor, a CI runner, or a container runtime. Such a process may have no controlling terminal at all, so tcsetpgrp fails and any code that assumes job control works has to fall back.
The other leak is descriptor sharing. A background job inherits the terminal’s descriptors, and if it holds them open after the foreground job finishes, the shell’s attempt to read the next command competes with whatever the background job does with the same descriptor. Redirecting a background job’s standard input from /dev/null is the standard mitigation, and it addresses the descriptor rather than the signal, which is where the actual conflict lives.
None of this machinery is visible in the command line, which is why it is worth knowing before writing a supervisor or a wrapper that spawns children. The same structures reappear whenever a program has to decide which processes to signal together, and the choice of group determines whether a termination request reaches a pipeline as a unit or picks off its stages one at a time, a concern that continues in signal handling in scripts.
References
- setpgid(2) — Linux manual page — Linux man-pages project
- termios(3) — Linux manual page — Linux man-pages project
- Advanced Programming in the UNIX Environment — W. Richard Stevens and Stephen A. Rago, Addison-Wesley