← Back to DevBytes

Zsh Scripting: Job Control Complete Guide

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:

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:

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.

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

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

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles