Module 12 — Processes, Subshells, and Job Control
Updated 3 September 2026
🧠 concept → 🧩 analogy → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (hidden)
Part A — The process model: parents, children, copies
A1. Everything running is a process with a PID and a parent
Every running program is a process: a live instance with its own memory, its own copies of the environment (Module 2's briefing pack), and a numeric PID. Every process has a parent — the process that started it — so the whole system forms a family tree, and ps -o pid,ppid,stat,comm shows any branch of it: PID, parent PID, state, command. Your shell is a process (echo $$ prints its PID); every external command you run becomes its child for the duration; your scripts run as children of your shell — which is Module 1's "second bash" with its birth certificate finally attached.
The one operational consequence to hold from the start: children inherit copies at birth and can never write back (Module 2's one-way briefing pack — now you know it's not a bash rule but how processes work: separate memory, no shared jars). Everything counter-intuitive in this module is that single fact viewed from different angles.
A2. Subshells: bash's copies of itself
Bash routinely makes copies of itself to run pieces of your code — subshells — and the copy starts with everything the original had (all variables, exported or not, the current directory, open files) but is still a separate process: its changes die with it. You have been creating subshells for ten modules without the name. The complete list of what runs in one: ( commands ) — explicit grouping in parentheses; $( commands ) — every command substitution; each stage of a pipeline; and anything ending in & (Part C). By contrast, { commands; } with braces groups without copying — same shell, changes stick — which is the grouping you used in Module 7's guards.
Where the analogy stops working. Photocopying a notebook is slow; forking a subshell is fast enough that bash does it on every $( ) — thousands of times in a naive loop. Fast is not free (Module 9's fork tax), and invisible is the real hazard: nothing in count=$(...) or | while read looks like a photocopy, yet each is one, and variables set inside are scribbles on the doomed copy.
🧪 Exercise 12.1 — watch the copy live and die
echo "my shell PID: $$"
( echo "subshell PID: $BASHPID"; cd /tmp; x=changed )
echo "after: pwd=$(pwd) x=[${x:-unset}]"
{ y=sticky; }
echo "braces: y=[$y]"✅ Expected result — click to reveal
my shell PID: 4821
subshell PID: 4822
after: pwd=/home/zaeem/bash-course x=[unset]
braces: y=[sticky]What to read out of it (your PIDs and path differ): two different PIDs — the parentheses really made a second process ($BASHPID shows the current process's PID; $$ inside a subshell keeps showing the original's, a subtlety worth one mental note). The subshell cd'd and assigned — and the after-line shows neither survived. The braces line is the contrast: same shell, y stuck. Every "why didn't my variable survive?" mystery in bash reduces to this exercise: find the invisible parentheses.
Interview questions — Part A
🎯 "What is a subshell? How do you use subshells in Bash?" — asked verbatim at Zero To Mastery; Edureka asks "How to print PID of the current shell?"
The direct answer: a subshell is a child copy of the shell process — same variables and directory at birth, separate life, changes discarded at death. Created by ( ), $( ), pipeline stages, and &. Deliberate uses: scoped directory changes ((cd /tmp && tar xf …) — the caller never moves), scoped environment tweaks, and parallel branches.
Going deeper: the inheritance nuance — a subshell inherits everything (a fork), while a child script gets only exported variables (fork + exec of a fresh bash); that one distinction untangles most "why can/can't it see my variable" confusion. $$ vs $BASHPID: $$ is the original shell's PID even inside a subshell; $BASHPID is always the current process.
The details that separate candidates: enumerating the implicit subshells (command substitution, every pipeline stage) rather than just ( ) — because that is where the bugs live; and naming the cost model: forks are cheap enough for correctness, expensive enough that $( ) inside hot loops is a smell (Module 9's review heuristic, now with its mechanism).
🎯 "What are the various stages of a Linux process it passes through?" — asked verbatim at Edureka
The direct answer: running (or runnable — on/awaiting a CPU), sleeping/waiting (blocked on I/O or a resource — where processes spend most of their lives), stopped (suspended, e.g. by Ctrl-Z or a signal — resumable), and zombie (finished, but its exit status has not yet been collected by its parent — a dead entry still in the process table).
Going deeper: read states from ps -o stat — R running, S sleeping, T stopped, Z zombie — and explain the zombie precisely: it is not a runaway process (it consumes no CPU) but an uncollected verdict; the parent reaps it with wait. A parent that never waits leaks zombies; if the parent dies, init adopts and reaps them.
The details that separate candidates: connecting zombies to this module's tools — every wait in your scripts is literally the reaping call, so a script that backgrounds jobs and never waits is manufacturing zombies until it exits; and the triage instinct that a few zombies are cosmetic while a growing count indicts a buggy parent (restart the parent, not the zombies — they cannot be killed, being already dead).
Part B — The pipeline subshell: the most-hit gotcha in bash
B1. Why | while read loses your variables — and the two escapes
Modules 6 and 10 promised this explanation. Each stage of a pipeline runs in its own subshell — so in cmd | while read -r line; do count=$((count+1)); done, the entire while loop is the doomed photocopy: it counts perfectly, finishes, and dies, taking count with it. The parent's count never moved. Nothing warns you; the loop ran.
Two escapes. Escape one — redirect instead of pipe when the producer is a file: while read … done < file (the loop stays in your shell; only fair, there was no pipeline). Escape two — process substitution when the producer is a command: while read … done < <(cmd). The <(cmd) form runs cmd and presents its output as a filename — so the loop reads "a file" and remains in the current shell, while cmd does its work alongside. The same tool unlocks a famous one-liner family: diff <(command1) <(command2) — comparing two command outputs with no temp files.
🧪 Exercise 12.2 — lose the count, then keep it
The first pipeline loses data on purpose.
count=0
printf 'a\nb\nc\n' | while read -r l; do count=$((count+1)); done
echo "after pipe: count=$count"
count=0
while read -r l; do count=$((count+1)); done < <(printf 'a\nb\nc\n')
echo "with < <(cmd): count=$count"
diff <(printf 'a\nb\n') <(printf 'a\nc\n')✅ Expected result — click to reveal (contains a deliberate loss)
after pipe: count=0
with < <(cmd): count=3
2c2
< b
---
> cWhat to read out of it: the piped loop counted to three in a process that no longer exists — the parent reads 0. Process substitution kept the loop home: 3 survives. Then diff compared two commands' outputs directly — the <( ) filenames made programs-as-files a reality (run echo <(true) sometime to see the strange /dev/fd/63-style name it passes). File the shape permanently: pipe into a loop = loop in a subshell; < <(cmd) = loop stays home.
Interview questions — Part B
🎯 "Why is my counter still 0 after the while loop counted the lines?" — the production-shaped form of the most-reported bash gotcha; the mechanics live inside Zero To Mastery's subshell answer, and Module 10 already cited it as the mapfile pipeline trap
The direct answer: the loop ran in a pipeline stage — a subshell — and its variables died with it. Fix by keeping the loop in the current shell: done < file, done < <(cmd), or collect with mapfile -t arr < <(cmd) and loop the array.
Going deeper: the diagnosis generalizes — any state set in any pipeline stage (variables, arrays, cd) evaporates; the probe is echo $BASHPID inside and outside the suspect construct. Mention bash's shopt -s lastpipe (runs the last stage in the current shell, in non-interactive shells) as the existing-code bandage, with the caveat that the portable, readable fix is process substitution.
The details that separate candidates: stating the rule's other victims unprompted — cmd | mapfile (Module 10), a pipeline stage's exit killing only itself (Module 8's audit nuance) — showing you hold the mechanism, not the single symptom; and knowing <( ) is unavailable in POSIX sh (bashism, Module 1's dialect line — the portable alternative is a temp file, mktemp, Module 14).
Part C — Background jobs: &, wait, and controlled parallelism
C1. & starts it and doesn't look back; wait collects the verdict
command & launches the command as a child and returns your prompt immediately — the shell does not wait. Bash records the child's PID in $! (capture it at once — it is as perishable as $?). Later, wait PID blocks until that child finishes and hands you its exit status as wait's own — the verdict machinery of Module 3, extended across time. Bare wait waits for all children; wait -n waits for whichever finishes next (the key to bounded fan-out below). Around the terminal, the job-control household tools: jobs lists background jobs, fg/bg move them between foreground and background, disown detaches one from the shell, and nohup command & starts it immune to the terminal's death — the classic "keep running after I log out" (its cleaner modern relatives are tmux/systemd, but nohup is the interview answer).
🧪 Exercise 12.3 — launch, tag, collect
sleep 2 &
pid=$!
echo "started sleeper as PID $pid; prompt is already back"
wait "$pid"
echo "sleeper finished with $?"
( exit 3 ) & p1=$!
( exit 0 ) & p2=$!
wait "$p1"; echo "job1 verdict: $?"
wait "$p2"; echo "job2 verdict: $?"✅ Expected result — click to reveal
started sleeper as PID 5210; prompt is already back
sleeper finished with 0
job1 verdict: 3
job2 verdict: 0What to read out of it (PIDs yours): the first echo printed while the sleeper still slept — that is &'s whole meaning. The second block is the important pattern: two children, two captured PIDs, two waits, and each child's individual exit code came back through its wait — failure detection survives parallelism. Scripts that background jobs and never wait get neither verdicts nor cleanliness (Part A's zombies are exactly un-waited children).
C2. Parallelism that pays, fan-out that stays bounded
Why bother? Arithmetic. Three 0.3-second sleeps in sequence: ~0.9s. The same three as background jobs with one wait: ~0.3s — the cost of the longest, not the sum. That is the fleet math of Module 6's warning ("500 hosts × 2s = 17 minutes") solved: parallel probes finish in the time of the slowest host. But unbounded fan-out — 500 simultaneous ssh sessions — is a self-inflicted denial of service; production bounds the width. The pure-bash bounded pattern, built from parts you own:
for host in "${hosts[@]}"; do
probe "$host" &
while [ "$(jobs -r | wc -l)" -ge 8 ]; do wait -n; done # ≤ 8 in flight
done
wait # collect the stragglers(jobs -r lists running jobs; when 8 are in flight, wait -n blocks until one finishes, opening a slot. The same shape, outsourced: xargs -P 8 — Module 11 — with the trade-off that xargs can't easily update your shell's variables, subshells being subshells.)
🧪 Exercise 12.4 — feel the parallel arithmetic
time ( sleep 0.3; sleep 0.3; sleep 0.3 )
time ( sleep 0.3 & sleep 0.3 & sleep 0.3 & wait )✅ Expected result — click to reveal
real 0m0.906s
user 0m0.008s
sys 0m0.000s
real 0m0.306s
user 0m0.008s
sys 0m0.000sWhat to read out of it (small variations normal): sequential ≈ 0.9s, parallel ≈ 0.3s — the sum versus the max, measured. Note user time is near zero in both: sleeps cost no CPU, so parallelism here is pure wall-clock win — exactly the profile of network-bound fleet work (ssh, curl, probes), which is why ops parallelism pays so well. CPU-bound work divides less cleanly (cores are finite); measure before promising.
Interview questions — Part C
🎯 "How do you manage background processes in Bash?" — asked verbatim at Zero To Mastery
The direct answer: start with command &; track with $! and jobs; move with fg/bg; wait with wait [pid] (which returns the job's exit status); detach with disown; survive logout with nohup command & (or start under tmux/systemd for anything serious).
Going deeper: the verdict story is what separates scripting from terminal habits — wait "$pid" returning the child's code means parallel steps keep Module-3 discipline; wait -n enables bounded pools; and un-waited children become zombies until the script exits (the Edureka process-stages question, connected).
The details that separate candidates: the Ctrl-Z / bg / disown rescue sequence for "I started a 4-hour job in the foreground of an ssh session" — a genuinely asked scenario; knowing nohup's actual mechanics (SIGHUP immunity + output to nohup.out — Module 13 will name the signal); and the honest boundary that long-running services belong in systemd units, not nohup'd scripts — saying so marks operational maturity.
🎯 "Suppose you execute a command using exec, what will be the status of your current process in the shell?" — asked verbatim at Edureka
The direct answer: exec command replaces the shell process with the command — same PID, no new process created, and nothing after the exec line ever runs; when the command exits, the process is gone (your terminal closes, or your script ends there).
Going deeper: the two production uses — wrapper scripts ending in exec real-tool "$@" (Module 8's Ticket 1: the wrapper vanishes, the tool inherits the PID, signals and exit codes flow directly — beloved by container entrypoints, where PID 1 should be the app, not a lingering bash), and exec > logfile 2>&1 with no command, which permanently rewires the current shell's own streams (Module 4's redirections, applied to yourself — how scripts self-log).
The details that separate candidates: the fork/exec vocabulary — normal commands are fork then exec (copy, then transform the copy); exec alone skips the fork — one sentence that shows the whole model; and the container connection (signal delivery to PID 1) which turns a trivia question into an ops answer.
Part D — The map: where does my code actually run?
D1. The decision
Diagram source
flowchart TD
A["A piece of my script<br>is about to run"] --> B{"Written how?"}
B -->|"plain command<br>(external)"| C["child process:<br>fork + exec"]
B -->|"builtin, function,<br>braces block"| D["current shell —<br>changes stick"]
B -->|"( ... ) or $( ... )<br>or a pipeline stage"| E["subshell: full copy,<br>changes die with it"]
B -->|"./script.sh"| F["child bash: sees only<br>exported variables"]
B -->|"source script.sh"| G["current shell —<br>as if typed here"]
B -->|"command &"| H["background child:<br>capture $!, wait later"]
B -->|"exec command"| I["REPLACES this process —<br>nothing after it runs"]One diagram, twelve modules of mysteries: trace any surprising behavior to its box, and the behavior stops being surprising. The two boxes that decide most real bugs are E (invisible copies — variables lost) and F (children see exports only — variables missing).
Interview questions — Part D
🎯 "What is the difference between running a script with ./script.sh, source script.sh, and bash script.sh — at the process level?" — Module 1's question, re-asked the way senior interviewers ask it; the substance behind Zero To Mastery's and Hirist's source-vs-execute questions
The direct answer: ./script.sh and bash script.sh both create a child process (fork + exec of a bash that reads the file) — the child sees only exported variables and its changes die with it; the difference between them is only who chooses the interpreter (kernel-via-shebang vs you). source script.sh creates no process at all — the current shell reads the lines itself; everything sticks, including its exit.
Going deeper: add the two remaining boxes — subshells (fork without exec: a copy that sees everything, exports or not) and exec (exec without fork: replacement, no return) — and you have named all four combinations of fork/exec, which is the entire process model in one 2×2.
The details that separate candidates: presenting it as that 2×2 (fork+exec: commands and scripts; fork only: subshells; exec only: exec; neither: builtins, functions, source) — interviewers remember candidates who compress twelve behaviors into one table; and one concrete probe per box ($BASHPID, exported-vs-not variables, pwd drift) showing you can demonstrate the model, not just recite it.
Part E — Production
E1. 🏭 Production practices
State-changing loops never sit downstream of a pipe. done < file, done < <(cmd), or mapfile-then-loop — the piped-while subshell is a review-blocking defect with a mechanical fix.
Every & is paired with a captured $! and an eventual wait. Verdicts are collected per job and routed to outcome files; un-waited children (zombie factories) fail review.
Fan-out is bounded, attributed, and logged per target. A width matched to the target's capacity, wait -n slot management (or xargs -P / GNU parallel), and per-host output files — never 500 interleaved stdouts.
Wrappers and container entrypoints end in exec. The tool inherits the PID; signals and exit codes flow without a bash middleman.
Deliberate subshells are used for scoping — (cd "$dir" && tar xf "$archive") beats cd-work-cd-back every time (the return trip can fail; the subshell's scope cannot).
Anything meant to outlive the session runs under a supervisor. nohup answers the interview question; tmux answers the emergency; systemd units answer production.
E2. Production-practice table
| Symptom | What is really happening | What to run | The fix |
|---|---|---|---|
| Counter/array still empty after a loop that visibly processed lines | The loop was a pipeline stage — a subshell took the state to its grave | echo $BASHPID inside vs outside the loop | done < <(cmd) or mapfile-then-loop; never pipe into stateful loops |
| Script sees none of the variables you set in your terminal | Children inherit exports only — plain variables stay behind (fork+exec) | set | grep name vs printenv name (Module 2's pair) | export what children need, or pass as arguments |
| Background jobs "succeeded" but failures went unnoticed | & without wait: verdicts never collected (and zombies accumulate meanwhile) | jobs before script exit; check for un-waited PIDs | Capture $! per job; wait "$pid" and route each verdict |
| Parallel run flattened a rate-limited API / overwhelmed a host | Unbounded fan-out — every job launched at once | Count in-flight jobs at peak: jobs -r | wc -l | Bound with the wait -n slot pattern or xargs -P N |
| Growing count of Z-state processes under one parent | The parent backgrounds children and never reaps (waits for) them | ps -o pid,ppid,stat,comm | grep Z and find the common PPID | Fix or restart the parent; zombies themselves cannot be killed |
| Lines after a certain command never execute; terminal closes on script end | That command is an exec — the process was replaced, there is no "after" | Read the line; exec at its start is the whole story | Remove exec if return was wanted; keep it (deliberately, last line) for wrappers |
E3. 🎓 Capstone — four tickets from the queue
🎓 Ticket 1 — "Health-check script tallies failures: check_hosts | while read -r host status; do [ \"$status\" = down ] && failures=$((failures+1)); done — then alerts if failures > 0. It has never alerted once, including the night 30 hosts were down."
Diagnosis. B1's gotcha carrying a pager: the while loop is a pipeline stage — a subshell; failures incremented 30 times in a doomed copy and the parent's stayed 0 forever. "Has never alerted once" is this bug's signature phrase: the code is not flaky, it is structurally incapable of alerting.
Work the steps: the probe: print $BASHPID inside the loop and outside; different numbers end the argument. Reproduce with Exercise 12.2's printf version.
Fix and prevention: while read -r host status; do …; done < <(check_hosts) — loop stays home, tally survives. Team rule (E1's first line): state-changing loops never sit downstream of a pipe; ShellCheck flags many instances (SC2031/SC2030 family) as mechanical backup.
🎓 Ticket 2 — "To speed up backups, someone added & to each of the four dump commands. Backups now 'finish' in 40 seconds instead of 20 minutes — and the success marker is written while dumps are visibly still running. Also, ops reports a slowly growing pile of defunct processes on that box."
Diagnosis. & without wait, twice over (C1): the script reaches its final line while four children still work — the marker lies exactly as Module 3 Ticket 1's did, by a new mechanism (nobody collected the verdicts, or even the finishes). The defunct pile is the same omission wearing its zombie costume: children finished, verdicts never reaped — and since this parent script is long-running between rounds, the zombies accumulate visibly.
Work the steps: ps -o pid,ppid,stat,comm | grep Z — common PPID = the backup script. Read the script: four &, zero wait.
Fix and prevention: collect properly: pids=(); dump1 & pids+=($!); … ; fail=0; for p in "${pids[@]}"; do wait "$p" || fail=1; done; [ "$fail" -eq 0 ] && touch .success — parallel speed and honest verdicts and reaped children, one pattern. The speedup was never the problem; the missing bookkeeping was.
🎓 Ticket 3 — "A deploy helper does cd \"$RELEASE_DIR\" && unpack && cd - — and once a month, when unpack fails oddly, the rest of the script runs in the wrong directory and litters files into the releases tree."
Diagnosis. The cd-and-return anti-pattern: when unpack fails, the && chain skips cd -, and the script continues wherever it stood — every subsequent relative path lands in the wrong place. The return trip is exactly the fragile part (Module 3's chain semantics meeting directory state).
Work the steps: simulate: cd /tmp && false && cd -; pwd — you are still in /tmp. One line reproduces a month of mystery.
Fix and prevention: the deliberate subshell (E1): ( cd "$RELEASE_DIR" && unpack ) — the directory change is scoped; the parent never moved, so there is no return trip to forget. Rule for review: any cd not inside ( ) (or not the script's single working-directory setup at the top) must justify itself. Subshells-for-scoping is the rare place where spawning a process is the safety feature.
🎓 Ticket 4 — "Our container keeps ignoring docker stop and getting SIGKILLed after the 10-second grace period. The entrypoint is entrypoint.sh, which ends with: python3 server.py."
Diagnosis. The missing exec (Part C's interview answer, in the wild): the entrypoint bash stays alive as PID 1 with python as its child. docker stop sends its stop signal to PID 1 — the bash — which (as you'll formalize in Module 13) does not forward it to the child while waiting; the app never hears the request, grace expires, everyone gets SIGKILLed. Unclean shutdowns, dropped connections, the works.
Work the steps: docker exec <ctr> ps -o pid,ppid,comm — PID 1 is bash, python is its child. That topology is the bug.
Fix and prevention: last line becomes exec python3 server.py — the bash replaces itself; python is PID 1; signals arrive directly; exit codes flow undiluted. Add the E1 rule to the container base-image docs: entrypoint scripts end in exec (or use an init like tini when multiple children are genuinely needed). One keyword, one topology change, whole failure class gone.
E4. Documentation reference
| Topic | Authoritative reference | What you'll find there |
|---|---|---|
| Subshells and execution environments | Bash manual — Command Execution Environment | Exactly which constructs spawn subshells, and what a subshell inherits |
| ( ) vs { } grouping | Bash manual — Command Grouping | The subshell/current-shell grouping pair, with the brace syntax rules |
| Jobs: &, jobs, fg, bg, wait | Bash manual — Job Control Basics | The job table and the control commands around it |
| Process substitution | Bash manual — Process Substitution | <( ) and >( ) — commands as filenames |
| Inspecting processes; surviving logout | ps(1) — man7.org · nohup(1) — man7.org | Process states and columns; nohup's exact redirection behavior |
E5. Self-assessment
Answer from memory, out loud or on paper. Every answer is in this module.
- What is a process, what is a PID, and what does the parent-child tree have to do with Module 2's briefing pack?
- Name the four constructs that run in subshells. Which everyday two are the "invisible" ones?
- Subshell vs child script: what does each see of your variables, and why (fork vs fork+exec)?
- $$ vs $BASHPID inside a subshell — which changes?
- ( cd /tmp; x=1 ) vs { cd /tmp; x=1; } — aftermath of each?
- Why does cmd | while read lose your counter? Give both escapes and say when each applies.
- What does diff <(cmd1) <(cmd2) do mechanically — what do the parentheses become?
- What exactly does & do, where does the PID go, and why must you grab it immediately?
- What does wait "$pid" return, and what is wait -n for? Sketch the bounded fan-out pattern.
- What is a zombie precisely, what creates a growing pile of them, and why can't you kill them?
- What does exec command do to the current process, and name both production uses (wrapper tails; stream rewiring).
- Three sleeps of 0.3s: sequential vs parallel wall-clock — and why does near-zero user time predict good parallel returns for fleet work?
E6. Sources
Zero To Mastery — Bash Interview Prep: 31 Essential Questions and Answers — "What is a subshell? How do you use subshells in Bash?", "How do you manage background processes in Bash?" (published June 18, 2026)
Edureka — Top 60 Shell Scripting Interview Questions and Answers — "What are the various stages of a Linux process it passes through?", "How to print PID of the current shell?", "Suppose you execute a command using exec, what will be the status of your current process in the shell?" (page updated Dec 9, 2024)
A note on the corpus: the subshell/pipeline-variable gotcha — this module's centerpiece — is among the most-asked scenario questions in real screens and among the least-published as fixed wording; the two scenario questions above are labeled accordingly. The process-substitution and fork/exec material appears in tutorials rather than question lists. No questions were invented. All expected-result outputs were produced by running the commands on Ubuntu 24.04 / bash 5.2; PIDs, paths, and small timing variations are flagged as machine-dependent.