Skip to content
Shell & TerminalDeep Dive Published Updated 6 min readViews unavailable

Job Control: How Shells Manage Foreground and Background Processes

Ctrl-Z, bg, fg, and a trailing & all touch one mechanism: process groups and Unix signals deciding which process can read your terminal.

Job control is the shell feature behind Ctrl-Z, bg, fg, and background execution with & — a coherent system for managing which of potentially several running processes gets to read from your terminal at any given moment, built on process groups and specific Unix signals.

What a “job” actually is

A job is a unit the shell tracks — typically one process, or a pipeline of several processes launched together — that can be running in the foreground (actively connected to your terminal’s input), running in the background (executing but not reading terminal input), or stopped (suspended, not currently running at all).

Starting a background job

long-running-command &

The trailing & starts a command in the background immediately, returning control of the terminal to you right away rather than waiting for the command to finish — the shell prints a job number you can reference in subsequent job-control commands.

Suspending and resuming the foreground job

Ctrl-Z            # suspend the current foreground job
fg                # resume the most recently suspended job, in the foreground
bg                # resume the most recently suspended job, in the background

Ctrl-Z doesn’t stop a process permanently — it sends SIGTSTP, a signal requesting the process suspend itself, which the shell traps and uses to mark that job as stopped rather than terminated.

Listing and referencing specific jobs

jobs
fg %2
kill %1

jobs lists every job the current shell session is tracking, each with a job number; %N syntax lets you reference a specific job by that number in fg, bg, or kill, rather than only ever operating on “the most recent” job.

The actual mechanism underneath: process groups

Every job’s processes are placed into a shared process group by the shell — the kernel tracks which process group currently has control of the terminal (the “foreground process group”), and signals like the one generated by Ctrl-C (SIGINT) are delivered to every process in that foreground group at once, not to any single process individually.

Why background jobs don’t receive your Ctrl-C

Because Ctrl-C sends SIGINT specifically to the terminal’s current foreground process group, and a backgrounded job isn’t part of that foreground group, pressing Ctrl-C while a job runs in the background has no effect on it at all — this is a direct, logical consequence of the process-group mechanism, not a separate special case job control has to account for.

What happens to background jobs when you close the terminal

By default, closing a terminal sends SIGHUP (“hangup”) to jobs still attached to that session, which typically terminates them — this is exactly the problem tools like nohup, disown, and terminal multiplexers like tmux solve, each in a different way, by detaching a job’s fate from the specific terminal session that originally launched it.

Why understanding process groups clarifies job control entirely

Job control can feel like a grab-bag of unrelated commands (bg, fg, jobs, Ctrl-Z) until you see they’re all just different ways of manipulating one underlying mechanism — which process group currently owns foreground terminal access, and which processes a given signal actually gets delivered to as a result.

Where job control actually came from

Job control did not ship with the earliest Unix shells. Jim Kulp, working at the International Institute for Applied Systems Analysis, implemented it against the C shell, building on new kernel support for process groups and stop signals that shipped in 4.1BSD in June 1981. Only after that kernel work existed could a shell meaningfully suspend and resume jobs — job control is as much a kernel feature (process groups, a terminal driver aware of a “foreground” group, signals like SIGTSTP) as it is a shell one, which is why it took a specific kernel release before any shell could offer it at all.

The signals a background process runs into on its own

A background job that unexpectedly tries to read from the terminal receives SIGTTIN; one that tries to write to it — depending on the terminal’s tostop setting — can receive SIGTTOU. Both default to stopping the process rather than killing it outright, which is exactly why an interactive program accidentally started with & often appears to hang: it isn’t stuck, it’s stopped, waiting on terminal access it will never get while it stays in the background. jobs or ps shows it as stopped, and bringing it to the foreground with fg typically lets it resume immediately.

Waiting on a specific background job

long-task &
pid=$!
wait "$pid"
echo "exited with status $?"

$! holds the process ID of the most recently started background job, and wait given that PID blocks until specifically that job finishes and returns its exit status — distinct from a bare wait, which blocks until every background job the shell knows about has finished. Bash 4.3 added wait -n, which waits only for the next background job to finish, useful for processing results from several parallel jobs as each one completes rather than waiting for the slowest one to decide when anything is ready.

Detaching cleanly: disown vs. nohup

long-task &
disown -h            # keep the job running, but stop the shell sending it SIGHUP on exit
nohup long-task &     # start in a way that never wires up SIGHUP delivery to begin with

disown -h and nohup solve the same underlying problem — surviving the shell’s own exit — from opposite directions. nohup starts a process already configured to ignore SIGHUP, decided before the process ever launches. disown -h instead tells the shell not to send SIGHUP to an already-running job when the shell exits, without changing anything about the job’s own signal disposition. Either is sufficient alone; combining them is redundant, not more effective.

Job control in scripts is off by default, on purpose

Non-interactive scripts run with job control disabled unless a script explicitly enables it with set -mbg, fg, and jobs remain available for background-process bookkeeping, but a script does not get the terminal-focused foreground/background switching an interactive session has, since there is normally no terminal driving keystrokes into it in the first place. This is a deliberate default, not a limitation to work around: most of what job control provides — suspend-on-Ctrl-Z, terminal-focus switching — has no meaning in a context with no interactive terminal attached.

Related:

Sources:

Comments