Introduction to Job Control in Zsh
Job control is one of the most powerful features of the Zsh shell, allowing developers to manage multiple processes from a single terminal session. Whether you are writing automation scripts, building CLI tools, or just trying to be more productive in your daily workflow, understanding how Zsh handles background and foreground processes is essential.
In Zsh, job control lets you suspend, resume, background, and foreground processes. It also allows scripts to spawn parallel tasks, monitor their completion, and react to their exit status. This guide walks you through everything from the fundamentals to advanced patterns you can use in production scripts.
What Is Job Control?
Job control refers to the mechanism by which a shell manages multiple running processes, known as jobs. Each job is a process (or pipeline of processes) that the shell has launched. The shell tracks these jobs, assigns them identifiers, and allows you to manipulate their execution state.
A job can be in one of several states:
- Foreground: The job is currently attached to the terminal and receiving input.
- Background: The job is running but detached from the terminal input.
- Stopped: The job has been suspended (paused) and is not consuming CPU.
- Done: The job has completed execution.
Zsh, like Bash, implements job control using POSIX signals such as SIGTSTP, SIGCONT, and SIGINT. However, Zsh offers additional features and a slightly different syntax in some cases that make it a compelling choice for scripting.
Why Job Control Matters
Job control matters because it transforms a single-threaded terminal experience into a multi-tasking environment. Without it, you would need to open a new terminal window for every long-running command. With it, you can:
- Run long builds in the background while continuing to work.
- Parallelize independent tasks in scripts to reduce total runtime.
- Suspend a process to inspect system state, then resume it.
- Build robust scripts that monitor and react to child process states.
- Avoid blocking on slow operations like network downloads.
For script authors, job control is the foundation of concurrency in shell scripting. While it is not a replacement for true multi-threading, it is more than sufficient for many automation tasks.
Enabling Job Control in Scripts
By default, interactive Zsh sessions have job control enabled. However, when running a script non-interactively, job control is typically disabled. To use job control features inside a script, you must explicitly enable it.
Using the monitor Option
The monitor option (also known as set -m) enables job control. You should place this near the top of your script:
#!/usr/bin/env zsh
# Enable job control in this script
setopt monitor
# Equivalent to: set -m
echo "Job control is now enabled."
Without setopt monitor, commands like fg, bg, and jobs will not behave as expected inside scripts, and background jobs may not report their status changes.
Basic Job Control Commands
Zsh provides a set of built-in commands for managing jobs. These are the same commands you would use interactively, but they also work in scripts when job control is enabled.
Backgrounding a Job
Append an ampersand (&) to a command to run it in the background:
#!/usr/bin/env zsh
setopt monitor
# Start a long-running task in the background
sleep 30 &
echo "Started background job with PID $!"
The special variable $! holds the process ID of the most recently backgrounded job. This is useful for tracking or signaling the process later.
Suspending a Foreground Job
Interactively, you can press Ctrl+Z to suspend the current foreground job. In a script, you can send the equivalent signal using kill:
#!/usr/bin/env zsh
setopt monitor
sleep 100 &
JOB_PID=$!
# Suspend the background job (equivalent to Ctrl+Z)
kill -TSTP $JOB_PID
echo "Job $JOB_PID has been suspended."
Resuming a Job in the Foreground or Background
Use fg to bring a job to the foreground and bg to resume a stopped job in the background:
#!/usr/bin/env zsh
setopt monitor
sleep 50 &
JOB_ID=$!
# Wait a moment, then bring it to the foreground
sleep 2
fg %1
The %1 syntax refers to job number 1. You can also refer to jobs by process ID or by the command name. For example, %sleep refers to the most recent job whose command starts with "sleep".
Listing Jobs
The jobs command lists all active jobs:
#!/usr/bin/env zsh
setopt monitor
sleep 10 &
sleep 20 &
sleep 30 &
jobs
Output will look something like:
[1] running sleep 10
[2] running sleep 20
[3] running sleep 30
Job Specifiers in Zsh
Zsh supports several ways to refer to jobs. Understanding these specifiers is crucial for writing clear scripts.
%n: Job number n (e.g.,%1).%%or%+: The current job (most recently stopped or backgrounded).%-: The previous job.%string: The most recent job whose command begins withstring.%?string: The most recent job whose command containsstring.
Example usage:
#!/usr/bin/env zsh
setopt monitor
tar -czf backup.tar.gz /home/user &
rsync -av /data/ /backup/ &
# Bring the tar job to the foreground
fg %tar
# Or reference by substring
fg %?rsync
Waiting for Jobs
The wait builtin blocks until a specified job (or all jobs) completes. This is essential for synchronizing parallel tasks in scripts.
Waiting for All Jobs
#!/usr/bin/env zsh
setopt monitor
for i in {1..5}; do
sleep $(( RANDOM % 5 + 1 )) &
done
echo "Waiting for all background jobs to finish..."
wait
echo "All jobs complete."
Waiting for a Specific Job
#!/usr/bin/env zsh
setopt monitor
sleep 3 &
JOB_PID=$!
echo "Waiting for job $JOB_PID..."
wait $JOB_PID
EXIT_STATUS=$?
echo "Job exited with status $EXIT_STATUS."
The wait command returns the exit status of the job it waited for, allowing you to handle failures gracefully.
Capturing Exit Status of Background Jobs
One common challenge in shell scripting is determining whether a background job succeeded. Zsh provides the wait command for this, but you can also use the JOBCOUNT and job status arrays.
#!/usr/bin/env zsh
setopt monitor
run_task() {
local task_id=$1
local sleep_time=$(( RANDOM % 4 + 1 ))
sleep $sleep_time
if (( RANDOM % 2 == 0 )); then
echo "Task $task_id succeeded after ${sleep_time}s"
return 0
else
echo "Task $task_id failed" >&2
return 1
fi
}
pids=()
for i in {1..4}; do
run_task $i &
pids+=($!)
done
failures=0
for pid in $pids; do
wait $pid
if (( $? != 0 )); then
(( failures++ ))
fi
done
echo "$failures task(s) failed out of ${#pids}."
Parallel Processing Patterns
Job control shines when you need to run multiple independent tasks in parallel. Here is a practical pattern for limiting concurrency using a simple counter.
Controlled Parallelism
#!/usr/bin/env zsh
setopt monitor
MAX_JOBS=3
urls=(
"https://example.com/file1"
"https://example.com/file2"
"https://example.com/file3"
"https://example.com/file4"
"https://example.com/file5"
"https://example.com/file6"
"https://example.com/file7"
)
download() {
local url=$1
local filename=$(basename $url)
curl -s -o "/tmp/$filename" "$url"
echo "Downloaded $filename"
}
for url in $urls; do
download "$url" &
# Limit concurrency
while (( $(jobs -p | wc -l) >= MAX_JOBS )); do
sleep 0.5
done
done
wait
echo "All downloads complete."
This approach ensures that no more than MAX_JOBS processes run simultaneously, which is important when dealing with rate-limited APIs or limited system resources.
Handling Signals and Cleanup
When running background jobs, it is important to clean up child processes if the script is interrupted. Zsh supports the trap builtin for this purpose.
#!/usr/bin/env zsh
setopt monitor
child_pids=()
cleanup() {
echo ""
echo "Caught interrupt. Cleaning up child processes..."
for pid in $child_pids; do
if kill -0 $pid 2>/dev/null; then
kill -TERM $pid
echo "Terminated PID $pid"
fi
done
exit 1
}
trap cleanup INT TERM
for i in {1..5}; do
sleep 100 &
child_pids+=($!)
done
echo "Started ${#child_pids} background jobs. Press Ctrl+C to stop."
wait
The kill -0 check tests whether a process is still running without actually sending a signal. This is a safe way to verify process existence before terminating.
Disowning Jobs
The disown command removes a job from the shell's job table, meaning the shell will no longer track or report on it. This is useful when you want a background process to continue running even after the shell exits.
#!/usr/bin/env zsh
setopt monitor
# Start a long-running process
nohup ./long_running_server.sh &
SERVER_PID=$!
# Disown it so it survives shell exit
disown %1
echo "Server is running with PID $SERVER_PID and will survive this script."
Combining nohup with disown ensures the process is immune to hangup signals and is no longer managed by the shell.
Monitoring Job State Changes
Zsh can notify you when a background job changes state. The notify option controls this behavior:
#!/usr/bin/env zsh
setopt monitor
setopt notify # Report status of background jobs immediately
sleep 3 &
echo "Doing other work..."
sleep 5
echo "Done with other work."
With notify enabled, Zsh will print a message as soon as the background sleep 3 completes, rather than waiting until the next prompt.
Best Practices for Job Control in Zsh Scripts
- Always enable job control explicitly with
setopt monitorat the top of scripts that use background jobs. - Track PIDs using
$!immediately after backgrounding a job, storing them in an array for later management. - Always call
waitat the end of your script to ensure all background jobs complete before the script exits. - Use
trapfor cleanup to kill child processes on interruption, preventing orphaned processes. - Limit concurrency when spawning many jobs to avoid exhausting system resources or hitting rate limits.
- Check exit statuses of background jobs using
wait $PIDand inspecting$?. - Avoid relying on job numbers (
%1,%2) in scripts, as they can shift unpredictably. Prefer PIDs. - Use
disownsparingly and only when you intentionally want a process to outlive the shell. - Log output from background jobs to files, since their stdout and stderr may interleave unpredictably with the main script.
- Test scripts with
set -xto trace execution when debugging complex job control logic.
Advanced Example: Parallel Build Pipeline
Here is a more complete example that demonstrates a realistic use case: running multiple build steps in parallel and collecting their results.
#!/usr/bin/env zsh
setopt monitor
BUILD_DIR="./build"
LOG_DIR="./logs"
mkdir -p "$LOG_DIR"
# Define build tasks as an associative array
typeset -A tasks
tasks=(
compile "gcc -c src/*.c -o $BUILD_DIR/obj"
lint "clang-tidy src/*.c -- -Iinclude"
test "ctest --output-on-failure"
docs "doxygen Doxyfile"
)
pids=()
task_names=()
for name in ${(k)tasks}; do
cmd=${tasks[$name]}
echo "Starting task: $name"
eval "$cmd" > "$LOG_DIR/${name}.log" 2>&1 &
pids+=($!)
task_names+=($name)
done
echo ""
echo "All tasks started. Waiting for completion..."
echo ""
failures=()
for i in {1..$#pids}; do
pid=${pids[$i]}
name=${task_names[$i]}
wait $pid
status=$?
if (( status == 0 )); then
echo " [PASS] $name"
else
echo " [FAIL] $name (exit $status, see $LOG_DIR/${name}.log)"
failures+=($name)
fi
done
echo ""
if (( ${#failures} == 0 )); then
echo "All tasks completed successfully."
exit 0
else
echo "Failed tasks: ${failures}"
exit 1
fi
This script demonstrates several best practices: it tracks PIDs, logs output to files, waits for each job, checks exit statuses, and provides a clear summary at the end.
Common Pitfalls
- Forgetting
setopt monitorin scripts leads to silent failures of job control commands. - Not waiting for jobs before the script exits can leave background processes orphaned or incomplete.
- Assuming job numbers are stable can cause scripts to manipulate the wrong job. Always prefer PIDs.
- Ignoring exit statuses of background jobs means failures go unnoticed, which is dangerous in CI/CD pipelines.
- Reading from stdin in background jobs causes them to block. Redirect stdin from
/dev/nullif needed:cmd < /dev/null &. - Signal handling without cleanup leaves child processes running after the parent is killed.
Conclusion
Job control in Zsh is a versatile and powerful feature that enables true concurrency within shell scripts. By mastering commands like bg, fg, wait, jobs, and disown, along with proper signal handling and PID tracking, you can build robust automation scripts that run tasks in parallel, handle failures gracefully, and clean up after themselves. The key to success is discipline: always enable job control explicitly, track your processes, wait for completion, and implement cleanup traps. With these practices in place, Zsh job control becomes an indispensable tool in any developer's scripting toolkit.