Skip to content

Prevent Overlapping Bash Jobs with flock

When a scheduled task takes longer than expected, cron, a systemd timer, or a second manual launch can start another copy before the first one finishes. On Linux, the flock utility can hold an advisory lock for the duration of a script. This guide builds a small Bash job that exits with status 75 if another copy already holds the lock.

01

Check That flock Is Available

Prerequisites

Check that the host has the expected utility and confirm the account’s home directory. This example keeps its lock under $HOME so the same account uses the same path from an interactive shell, cron, or a systemd service.

Terminal window
command -v flock
flock --version
printf 'HOME=%s\n' "$HOME"
❯ Illustrative Output (Results Vary)
/usr/bin/flock
flock from util-linux 2.x
HOME=/home/sam

02

Create a Script That Holds an Exclusive Lock

Implementation

Save this as single-instance.sh. File descriptor 9 stays open for the script, and flock tries to lock that descriptor without waiting. The ten-second sleep makes the overlap test easy; replace it with the real job after testing.

#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
if [[ -z "${HOME:-}" ]]; then
printf 'HOME is not set.\n' >&2
exit 1
fi
lock_dir="$HOME/.local/run/report-job"
if [[ -e "$lock_dir" || -L "$lock_dir" ]]; then
[[ -d "$lock_dir" && ! -L "$lock_dir" ]] || {
printf 'Lock path exists but is not a real directory; stop.\n' >&2
exit 1
}
else
mkdir -p -- "$lock_dir" || exit 1
chmod 700 -- "$lock_dir" || exit 1
fi
lock_owner=$(stat -c '%u' -- "$lock_dir") || exit 1
lock_mode=$(stat -c '%a' -- "$lock_dir") || exit 1
if [[ "$lock_owner" != "$EUID" ]] || (( (8#$lock_mode & 077) != 0 )); then
printf 'Lock directory must belong to this account and be private; stop.\n' >&2
exit 1
fi
lock_file="$lock_dir/report-job.lock"
if [[ -L "$lock_file" ]]; then
printf 'Lock file must not be a symbolic link; stop.\n' >&2
exit 1
fi
exec 9>>"$lock_file"
if flock --nonblock --conflict-exit-code 75 9; then
:
else
status=$?
if (( status == 75 )); then
printf 'Another report job is already running.\n' >&2
else
printf 'Could not acquire the lock (flock exit %s).\n' "$status" >&2
fi
exit "$status"
fi
printf 'Lock acquired; running the job.\n'
sleep 10 # Replace with the real work after testing.
printf 'Job complete.\n'
❯ Illustrative Output (Results Vary)
The first process holds the lock until its file descriptor closes.

03

Check the Script and Make It Executable

Validation

Check the Bash syntax without running the job, then allow your account to execute the file. Run it from its containing directory for the test.

Terminal window
bash -n single-instance.sh
chmod u+x single-instance.sh
❯ Illustrative Output (Results Vary)
No output and a zero exit status from bash -n mean the file passed the syntax check.

04

Confirm a Second Run Exits Instead of Waiting

Concurrency Test

Open two terminal sessions. Start the script in the first, then start it in the second before the ten-second test sleep finishes. The first run should complete; the second should print the busy message and return status 75.

Terminal window
# Terminal 1
./single-instance.sh
# Terminal 2, while Terminal 1 is still running
./single-instance.sh
printf 'exit=%s\n' "$?"
❯ Illustrative Output (Results Vary)
Another report job is already running.
exit=75

05

Replace the Test Work with Your Real Job

Use in Automation

Replace the sleep command with the task, keeping it after the lock check. Use the same script and lock path for manual runs and scheduled runs. A failed nonblocking acquisition returns 75, which a scheduler can record as a skipped overlap.

Terminal window
# Example shape after replacing the test sleep:
if ! /usr/local/bin/report-generator --output "$HOME/reports/latest.csv"; then
printf 'Report generation failed.\n' >&2
exit 1
fi
❯ Illustrative Output (Results Vary)
The lock remains held while the foreground command runs and is released when the script exits.

Comments