Skip to content
academia.sh

Lesson 02 / 22

Foreground and Background Jobs

When a session closes, all 13 of the shell's `&`-backgrounded jobs die, 9 of the disowned ones die, while nohup and a separate session keep all 13 alive; `jobs` shows 13 lines while the number of jobs actually progressing is 8.

Contents

The previous lesson looked at processes from outside the machine: which exist, which are heavy. Processes also have a bond with the operator. When a script is started from a terminal, more than a single process is born: that process belongs to a process group, the group to a session, the session to a controlling terminal. When the terminal closes, events travel along this entire chain.

The Shell Programming course established job control at the level of usage: &, wait, the exit code. This lesson does not repeat that side. What it measures is a single question: which processes survive when the session closes, and how well jobs output predicts this in advance.

Session, Group, and Controlling Terminal

The commands in a pipeline are placed into a single process group; the shell sends signals per group, not to individual processes. A shell opened from a terminal and all its children share the same session; the shell stands at the head of the session, and that terminal is the session’s controlling terminal. These three identities can be listed:

$ ps -eo pid,ppid,pgid,sid,tty,stat,comm
    PID    PPID    PGID     SID TTY      STAT COMMAND
   1000     998    1000    1000 pts/4    Ss   bash
   1014    1000    1014    1000 pts/4    R    report-generator
   1021    1000    1014    1000 pts/4    R    metric-collector
   1043    1000    1043    1000 pts/4    T    backup-job
   1092       1    1092    1092 ?        Ss   queue-worker

This transcript is illustrative and has not been run. There are three things to read here. The first two processes carry the same PGID value: the two ends of a pipeline. The last process’s SID value equals its own process number, and its TTY column is empty — that process is in its own separate session and is attached to no terminal. The third process’s state is T: stopped.

When the terminal closes, two separate events travel, and either one can kill. The first is the hangup signal: the kernel delivers it to the head of the session, and the shell propagates it to its own jobs. The second is quieter — because the terminal no longer exists, a process trying to write to it gets an input/output error, and most programs stop on this error. The second event leaves no signal record at all.

  • PM8. Part of the synthetic server’s 24 processes has been started from the operator’s shell; the rest belong to other sessions.
  • PM9. Every process has two extra properties: whether its output still goes to the terminal (writes) and whether it waits for input from the terminal (reads).
  • PM10. Five detachment methods are measured: foreground, background (&), disown, nohup, and separate session.
  • PM11. A method determines three things: whether the process is in the session, whether the shell sends it the hangup signal, whether the process ignores that signal. Methods that redirect also turn off the writes property.
  • PM12. The survival rule: no event reaches processes outside the session; those inside it are eliminated first by the signal, then by the write error.
  • PM13. The common expectation is this: the foreground job dies, and all four of the other methods keep the job alive. The false diagnosis is the number of processes where this expectation and reality diverge.
  • PM14. The second seed is 20260219.
# --- common definition, in the form used in the first lesson: synthetic server and oracle
SEED, SECOND_SEED = 20260218, 20260219
USER = ("root", "app", "backup", "monitor")
COMMAND = ("data-receiver", "report-generator", "backup-job", "metric-collector",
           "queue-worker", "cache-cleaner")


def generator(seed):
    d = seed

    def next_value(n):
        nonlocal d
        d = (d * 1103515245 + 12345) % 2147483648
        return d % n
    return next_value


def processes(seed=SEED, count=24):
    r = generator(seed)
    result = []
    for i in range(count):
        kind = r(10)
        pattern = "flat" if kind < 5 else ("spike" if kind < 8 else "heavy")
        result.append({"pid": 1000 + i * 7 + r(5),
                       "user": USER[r(len(USER))],
                       "command": COMMAND[r(len(COMMAND))], "pattern": pattern,
                       "priority": r(11) - 5, "memory": 20 + r(400),
                       "ignores_signal": r(10) < 2})
    return result


# --- this lesson's layer: the session bond
METHOD = ("foreground", "background", "disown", "nohup", "separate session")


def distribute_sessions(processes_, seed=SEED):
    """The shell's own jobs are separated from the rest of the machine.
    `writes`: its output still goes to the terminal. `reads`: it waits for input from the terminal."""
    r = generator(seed)
    for s in processes_:
        s["shell_job"] = r(10) < 5
        s["writes"] = r(10) < 6
        s["reads"] = r(10) < 3
    return processes_


def apply(process, method):
    """A method determines three properties of the process. The rest comes from the process itself."""
    in_session = method != "separate session"
    shell_sends = method in ("foreground", "background")
    protected = method in ("nohup", "separate session")
    return {"in_session": in_session, "shell_sends": shell_sends,
            "ignores": protected or process["ignores_signal"],
            "writes": process["writes"] and not protected}


def is_alive(d):
    """The terminal closing produces TWO separate events, and either one can kill."""
    if not d["in_session"]:
        return True                          # outside the session: neither event reaches it
    if d["shell_sends"] and not d["ignores"]:
        return False                         # first event: the hangup signal
    if d["writes"]:
        return False                         # second event: write error once the terminal is gone
    return True


S = distribute_sessions(processes())
SHELL_JOBS = [s for s in S if s["shell_job"]]
print("total processes:", len(S), "| shell's own jobs:", len(SHELL_JOBS),
      "| belong to another session:", len(S) - len(SHELL_JOBS))
print("shell jobs: ignore the signal", sum(1 for s in SHELL_JOBS if s["ignores_signal"]),
      "| write to the terminal", sum(1 for s in SHELL_JOBS if s["writes"]),
      "| read from the terminal", sum(1 for s in SHELL_JOBS if s["reads"]))
total processes: 24 | shell's own jobs: 13 | belong to another session: 11
shell jobs: ignore the signal 4 | write to the terminal 9 | read from the terminal 5

Backgrounding a Job

Job control’s commands are the shell’s own commands, and they do not appear in the process table. & starts a job in the background, Ctrl+Z stops the foreground job, bg resumes a stopped job in the background, fg brings it back to the foreground. jobs lists the shell’s job table:

$ jobs -l
[1]-  1014 Running                 ./report-generator --day 7 &
[2]+  1043 Stopped                 ./backup-job --full
[3]   1021 Running                 ./metric-collector | ./summarize &

This transcript, too, is illustrative and has not been run. Jobs are referred to with markers like %1, %2; fg %2 brings the second job to the foreground. The number in square brackets is not a process number — it is the shell’s own counter and has no meaning in another shell.

The jobs table has two limits, and both produce exactly what this lesson measures. First, the table carries only this shell’s jobs: no process started from another session, or by the service manager, appears here. Second, the table shows the bond as it is now, not the future outcome.

The practical consequence of these two limits is this: jobs is not the way to verify that a job has genuinely detached from the session. Verification is done by opening a second session and searching for the process there; in ps output, an empty TTY column and a PPID of 1 are the proof of detachment. jobs output can never answer this question, because what is being asked lies exactly outside jobs output’s scope.

The exit code contract established in the Shell Programming course holds here too: wait %1 waits for the job to end and returns its exit code. When a job is terminated by a signal, the code becomes 128 plus the signal number; whether a background job died silently is read from this code. If wait is not called and the code read, the information is lost — the job table goes away when the shell closes.

There are three ways to detach a process from the session. disown removes the job from the shell’s table; the shell no longer sends it the hangup signal. The nohup command starts the process so that it ignores the hangup signal, and redirects its output to a file. setsid places the process in a new session; that session has no controlling terminal, so there is no terminal to close either. The three methods do not do the same job, and the difference can be measured.

Five Methods, One Hangup

# On top of the previous block: SHELL_JOBS, METHOD, apply, is_alive come from there.
# Common expectation: the foreground job dies, all the others survive.
EXPECTATION = {"foreground": False, "background": True, "disown": True,
               "nohup": True, "separate session": True}

print("method        applied  expected  actual  died  false diagnosis")
for y in METHOD:
    actual = {s["pid"] for s in SHELL_JOBS if is_alive(apply(s, y))}
    expected = {s["pid"] for s in SHELL_JOBS} if EXPECTATION[y] else set()
    print(f"  {y:16s} {len(SHELL_JOBS):7d}  {len(expected):8d}  {len(actual):6d}"
          f"  {len(SHELL_JOBS) - len(actual):4d}  {len(expected ^ actual):15d}")
print("oracle: the `actual` column on every row, since we generated the fiction")
method        applied  expected  actual  died  false diagnosis
  foreground            13         0       0    13                0
  background            13        13       0    13               13
  disown                13        13       4     9                9
  nohup                 13        13      13     0                0
  separate session      13        13      13     0                0
oracle: the `actual` column on every row, since we generated the fiction

The second row is this lesson’s main result. & keeps nothing alive: all 13 of the 13 backgrounded jobs die, false diagnosis 13. The ampersand puts a job in the background; it does not take it out of the session. When the shell propagates the hangup signal to its own jobs, it includes the backgrounded ones too. The sentence “I put & after it, so it survives logout” is wrong for every job in the fiction.

The third row gives a finer result. disown genuinely blocks the hangup signal — but of 13 jobs, only 4 survive, 9 die. The ones that die are victims of the second event: their output was still going to the terminal, and once the terminal vanished, they stopped on their first write attempt. disown cuts the signal, not the stream. This is the cost of trying to meet two separate events with a single precaution.

In the last two rows, the false diagnosis is zero. nohup meets both events at once: it makes the signal ignored and redirects the output to a file. A separate session removes the problem entirely; there is no bond left to close. This is the right arrangement for lasting jobs — either a separate session, or not starting the job from the shell at all and turning it into a service unit instead. The second is the subject of this course’s next topic.

The detail can vary between shells; in some shells, whether the hangup signal propagates to jobs is controlled by an option. What is measured is the common behavior, and in the common behavior, & alone is not a protection.

A Job Sitting in the List, Not Progressing

The detachment problem has a counterpart. If a background process tries to read from the terminal, the kernel stops it; the process does not die, it waits. A stopped process appears with state T in ps output, and as “Stopped” in jobs output. Both confirm that the process exists; neither says that there is no progress.

# On top of the previous blocks: SHELL_JOBS, apply, is_alive, distribute_sessions, processes.
stalled = [s for s in SHELL_JOBS if s["reads"]]
print("backgrounded jobs trying to read from the terminal:", len(stalled))
print("  these get STOPPED: `jobs` lists them, `ps` shows them, progress is zero")
print("  `jobs` rows:", len(SHELL_JOBS), "| actually progressing:", len(SHELL_JOBS) - len(stalled),
      "| false diagnosis:", len(stalled))
print()
for seed in (SEED, SECOND_SEED):
    S2 = distribute_sessions(processes(seed), seed)
    K2 = [s for s in S2 if s["shell_job"]]
    d = {y: len({s["pid"] for s in K2} ^ {s["pid"] for s in K2
                                          if is_alive(apply(s, y))})
         for y in ("background", "disown", "nohup")}
    print(f"seed {seed}: shell jobs {len(K2):2d} | false diagnosis {d}"
          f" | stalled {sum(1 for s in K2 if s['reads'])}")
backgrounded jobs trying to read from the terminal: 5
  these get STOPPED: `jobs` lists them, `ps` shows them, progress is zero
  `jobs` rows: 13 | actually progressing: 8 | false diagnosis: 5

seed 20260218: shell jobs 13 | false diagnosis {'background': 13, 'disown': 9, 'nohup': 0} | stalled 5
seed 20260219: shell jobs 11 | false diagnosis {'background': 11, 'disown': 6, 'nohup': 0} | stalled 2

jobs shows 13 lines, the jobs actually progressing are 8. Five jobs are waiting on a password or a confirmation, and nobody can write to them; they are in the list, not in the progress. False diagnosis 5. The practical consequence of this is that every job to be backgrounded needs its input closed too — redirecting output alone is not enough. A process whose input is connected to an empty file is not stopped; a read attempt returns end-of-file immediately, and the process proceeds down its own error path. Stopping itself is not a visible event either: it is written nowhere except the status field in the job table.

With the second seed, the shell’s jobs drop to 11, stalled jobs to 2; the false diagnosis for & is again all the jobs, for disown 6 instead of 9, for nohup again 0. The numbers depend on the fiction; the order and direction do not change& is worst, disown is half protection, nohup and a separate session are full protection.

Orphan and Zombie Processes

When a session closes, the parent process of any surviving processes has died. The kernel does not leave them orphaned: it reassigns their parent process number, and the PPID column in ps output becomes 1. Being orphaned is not a malfunction; three of the detachment methods work exactly this way.

The situation that creates real confusion is the opposite. When a process ends, its exit code is kept in the process table until its parent reaps it. If the parent does not do this reaping, the child stays in the zombie state: it runs no code, holds no memory, but its table entry remains. In ps output its state is Z, and its command name is in square brackets.

Sending a signal to a zombie has no effect at all — a signal is delivered to a process that runs code, and a zombie runs no code. What wait reaps in the Shell Programming course is exactly this; when a parent process is written that does not reap, the result is a zombie buildup. The fix is not to terminate the zombies, but to fix or terminate the parent process; once the parent is gone, the zombies are reassigned and reaped there.

The cost of the buildup is a countable resource: every zombie holds one process table entry and one process number. Once enough have built up, a new process cannot be created. Which error message this limit shows up as is the subject of this topic’s fifth lesson.

For lasting jobs, nohup and setsid are the right tools, but not the only option. Tools in the terminal multiplexer class carry the session themselves: the job does not die when the connection drops, because the terminal the job is attached to is the multiplexer’s, not the operator’s. The difference here is not one of measurement but of ownership — when the multiplexer itself closes, the same two events travel again.

Summary

  • A process is bound to three identities: process group, session, controlling terminal. The shell sends signals to the process group; in ps output, the PGID, SID, and TTY columns show this bond.
  • When the terminal closes, two separate events travel: the hangup signal, and the input/output error a process gets when it tries to write to the terminal. The second event leaves no signal record.
  • & does not detach from the session: all 13 of 13 backgrounded jobs die, false diagnosis 13.
  • disown cuts the signal but not the stream; of 13 jobs, 4 remain, 9 die because their output was going to the terminal. nohup and a separate session meet both events at once, false diagnosis 0.
  • jobs shows 13 lines while the jobs actually progressing number 8; 5 jobs trying to read from the terminal have been stopped and appear to be running in the list.

Next Step

This lesson saw, from the side, how a signal reaches processes: the hangup signal propagated from the shell to its jobs, and some of them ignored it. Ignoring itself has not yet been measured. The next lesson takes up signals directly and counts a single sentence: a termination request is a request, and a command returning without error does not mean the target is gone.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close