Locking in Bash: flock, PID Files and Race Conditions
AI generated
Bash · flock · PID Files · Race Conditions · Linux
Locking in Bash: flock, PID Files and Race Conditions
atomic locking and reliable double execution control

Bash scripts that run without locking in cron jobs or deployment pipelines risk race conditions when executed more than once. PID files are common but not atomic. flock offers kernel backed locks that are released automatically even after a crash, making it the right tool for reliable locking in shell scripts.

12 min read flock · PID files · mkdir · stale locks · race conditions Linux · Bash 4.x · 5.x · Cron · CI/CD

1. Why locking is essential in Bash scripts

Locking in Bash is not an academic topic. It is a daily practical problem in any infrastructure that uses cron jobs, parallel CI jobs, or deployment scripts. If a backup script starts at 2 a.m. and the previous run has not finished yet, both instances produce inconsistent backup states. If two deployment runs start at the same time because a manual trigger collides with a still running cron trigger, you risk corrupted deployments. The core problem is always the same: a script that has no way of knowing whether another instance is already running.

Naive locking in Bash with if [ -f lockfile ]; then exit; fi; touch lockfile does not solve the problem. Between the check and the creation of the file there is a time gap in which a second instance performs the same check and also finds no lock. This classic race condition is reproducible under high load or when CI jobs start simultaneously. Kernel backed locking with flock closes this gap completely, because the atomicity lives in the kernel, not in the shell.

2. Understanding race conditions in shell scripts

A race condition in a shell script occurs when the outcome of an operation depends on the timing of other processes. For locking, the classic scenario is this: process A checks whether a lock file exists (it does not), gets interrupted by the scheduler in between, process B also checks (it does not exist either), process A creates the lock file and starts, process B also creates the lock file and starts. Both now run at the same time and no locking has actually taken place. In practice this window is often only milliseconds wide, but with sufficiently frequent execution or under load it occurs reliably.

The severity of the consequence depends heavily on the script. A backup script produces duplicate backups with inconsistent states. A deployment script can run database migrations twice when two deployments happen at once, resulting in a corrupted schema. A cleanup script can delete files that a second instance is still using. Race conditions in locking are particularly insidious because they occur intermittently and are never visible in tests that run sequentially.

3. PID files: the common but error prone pattern

The PID file pattern for locking in Bash writes the process ID of the running instance to a file and checks on startup whether that file exists and whether the stored process is still running. It is widespread but has structural weaknesses. The main weakness: checking whether the PID file exists and creating the file are not one atomic operation, so the race condition is only narrower, not eliminated. On top of that, stale PID files left behind by crashed scripts must be detected and removed manually.

A stale PID file appears when a script is terminated by a kill signal or a system crash without running its cleanup handler. On the next run, the script finds the PID file and checks with kill -0 $PID whether the process is still alive, which can give the wrong answer if the PID has been recycled: that PID could belong to a completely different process started later. This PID recycling problem makes plain PID file locking implementations unreliable for critical workflows.


#!/usr/bin/env bash
# pid-file-locking.sh - PID file locking with stale detection (better than naive)
set -euo pipefail

PID_FILE="/var/run/myapp.pid"

acquire_pid_lock() {
  if [[ -f "$PID_FILE" ]]; then
    local old_pid
    old_pid=$(< "$PID_FILE")

    # Check if PID is still alive AND belongs to this script
    if kill -0 "$old_pid" 2>/dev/null; then
      # Extra check: verify it's actually our script type
      local proc_name
      proc_name=$(ps -p "$old_pid" -o comm= 2>/dev/null || echo "")
      if [[ "$proc_name" == "bash" ]]; then
        echo "[ERROR] Another instance is running (PID $old_pid)" >&2
        return 1
      fi
    fi
    # Stale PID file, previous run crashed
    echo "[WARN] Removing stale PID file (PID $old_pid is gone)" >&2
    rm -f "$PID_FILE"
  fi

  # Write current PID, still not 100% atomic!
  echo $$ > "$PID_FILE"
}

release_pid_lock() {
  [[ -f "$PID_FILE" ]] && rm -f "$PID_FILE"
}

trap release_pid_lock EXIT

acquire_pid_lock || exit 1
echo "[INFO] Lock acquired (PID $$)"
# ... main logic here ...

4. flock: atomic locking with kernel support

flock is a Linux command that implements kernel backed locking over file descriptors. Unlike the PID file pattern, flock is fully atomic: the lock is set in the kernel, and two processes can never hold the same exclusive lock on the same file descriptor at the same time. This brings two further decisive advantages over PID files. The lock is released automatically when the process ends, whether normally, through an error, or through a crash, without any cleanup handler having to run. And stale locks are physically impossible, because the kernel ties the release of the lock to the process lifecycle.

The usage form exec 9>lockfile; flock -n 9 opens a file descriptor on the lock file and tries to set an exclusive, non blocking lock. If the lock is not available because another process holds it, flock -n returns immediately with exit code 1. This non blocking pattern suits scripts that should not wait but abort right away if an instance is already running. The lock file itself does not matter, it is never read or written, it only serves as an anchor for the kernel level lock. The file can stay empty.

5. flock patterns: non blocking, timeout and FD based

There are three essential flock usage patterns in Bash scripts: the non blocking pattern, the timeout pattern, and the FD based wrapper pattern. The non blocking pattern (flock -n 9) suits cron jobs that should simply be skipped if an instance is already running. The timeout pattern (flock -w 30 9) waits at most 30 seconds for the lock and then aborts, suitable for scripts that can afford to wait for a running predecessor. The FD based wrapper pattern encapsulates the whole lock lifecycle in a function that opens the FD, holds the lock, and releases it automatically once the function ends.

A particularly elegant flock pattern uses the subshell variant: flock -n 9 bash -c "..." or (flock -n 9; your_function) 9>lockfile. Here the entire locked block of code runs inside a subshell that owns the FD, and once the subshell exits the lock is released automatically. This pattern avoids having to manage the FD explicitly and makes the lock scope visible in the code. For Bash scripts where several sections need different locks, this pattern is the clearest solution.


#!/usr/bin/env bash
# flock-patterns.sh - All flock locking patterns for production use
set -euo pipefail

LOCK_DIR="/var/lock"
SCRIPT_NAME="$(basename "$0" .sh)"
LOCK_FILE="${LOCK_DIR}/${SCRIPT_NAME}.lock"

# Pattern 1: Non-blocking, exit immediately if already running
exec 9>"$LOCK_FILE"
if ! flock -n 9; then
  echo "[INFO] Another instance of $SCRIPT_NAME is already running. Exiting." >&2
  exit 0
fi
echo "[INFO] Lock acquired (PID $$, FD 9)"

# Pattern 2: Timeout, wait up to 30 seconds
acquire_with_timeout() {
  local timeout="${1:-30}"
  exec 8>"${LOCK_FILE}.secondary"
  if ! flock -w "$timeout" 8; then
    echo "[ERROR] Could not acquire lock within ${timeout}s" >&2
    return 1
  fi
  echo "[INFO] Lock acquired after waiting"
}

# Pattern 3: Subshell scope, lock is released when subshell exits
run_exclusive() {
  local lock_file="$1"
  shift
  (
    exec 7>"$lock_file"
    flock -n 7 || { echo "[ERROR] Resource is locked" >&2; exit 1; }
    "$@"
  )  # lock automatically released here
}

# Example usage
run_exclusive "${LOCK_FILE}.db" bash -c 'echo "Exclusive DB operation"'

# Lock info: show who holds a lock
show_lock_info() {
  local file="$1"
  if [[ -f "$file" ]]; then
    local holder
    holder=$(fuser "$file" 2>/dev/null || echo "none")
    echo "Lock file: $file | Held by PID: $holder"
  fi
}

show_lock_info "$LOCK_FILE"

6. Detecting and cleaning up stale locks automatically

Stale locks, meaning outdated locks that were not cleaned up after a process ended, are structurally impossible with the flock approach, because the kernel manages the lock together with the process lifecycle. With the PID file pattern, however, stale locks are a real problem. The reason: if a process is terminated with kill -9 or the system restarts abruptly, the cleanup handler (trap) cannot run, and the PID file is left behind.

A robust detection pattern for stale locks checks three things: does the PID file exist? Is the PID stored in it still an active process? Does that process belong to a script with the expected name? Only when all three conditions hold is the lock considered valid. For automatic cleanup of stale PID files, an age check is useful: a PID file older than the maximum expected runtime of the script can be treated as stale. With find "$PID_FILE" -mmin +120 you can check whether the file is older than 120 minutes.


#!/usr/bin/env bash
# stale-lock-cleanup.sh - Stale PID file detection and cleanup
set -euo pipefail

PID_FILE="/var/run/backup.pid"
MAX_AGE_MINUTES=120  # Maximum expected runtime

is_stale_lock() {
  local pid_file="$1"

  # No file, no lock
  [[ -f "$pid_file" ]] || return 1

  local stored_pid
  stored_pid=$(< "$pid_file") 2>/dev/null || return 1

  # Validate: must be a number
  [[ "$stored_pid" =~ ^[0-9]+$ ]] || { echo "[WARN] Invalid PID in lock file" >&2; return 0; }

  # Check 1: Is the process alive?
  if ! kill -0 "$stored_pid" 2>/dev/null; then
    echo "[WARN] Stale lock: PID $stored_pid is gone" >&2
    return 0  # stale
  fi

  # Check 2: Is it too old? (could be a stuck process)
  if find "$pid_file" -mmin +"$MAX_AGE_MINUTES" | grep -q .; then
    echo "[WARN] Lock file older than ${MAX_AGE_MINUTES}min, possible stuck process" >&2
    return 0  # treat as stale
  fi

  # Check 3: Process name check
  local proc_cmd
  proc_cmd=$(ps -p "$stored_pid" -o args= 2>/dev/null || echo "")
  if ! echo "$proc_cmd" | grep -q "backup"; then
    echo "[WARN] PID $stored_pid is not our process (PID recycled?)" >&2
    return 0  # stale
  fi

  return 1  # Lock is valid
}

if is_stale_lock "$PID_FILE"; then
  echo "[INFO] Removing stale lock file" >&2
  rm -f "$PID_FILE"
fi

# Now try to acquire with flock for real atomicity
exec 9>"${PID_FILE%.pid}.flock"
flock -n 9 || { echo "[ERROR] Lock held by active process" >&2; exit 1; }
echo $$ > "$PID_FILE"
trap 'rm -f "$PID_FILE"' EXIT

7. mkdir as an atomic locking mechanism

The mkdir command is a lesser known but portable alternative to flock for atomic locking in Bash. The operating system guarantees that mkdir is atomic: two processes that run mkdir /tmp/mylock at the same time are guaranteed to have only one of them succeed with exit code 0, the other fails. This atomicity is anchored in the POSIX standard and is available on every system, even where flock is not installed (for example some container images or NFS mounts that do not support flock).

The downside of the mkdir locking pattern is that, unlike flock, it is not crash safe. If the script ends without a cleanup handler, the directory remains and blocks every subsequent run. The way out is the PID in directory method: the lock directory contains a file with the PID of the lock owner, which enables stale lock detection. This pattern is especially suited to portable scripts that must run on various systems without guaranteed flock access, or on NFS shares where flock does not work reliably due to NFS limitations.

8. Locking in cron jobs and deployment pipelines

Locking in Bash is especially critical in cron jobs, because the cron daemon offers no inherent serialization. If a job runs longer than its execution interval, cron starts a new instance without waiting for the running one to finish. The pattern for cron safe locking is a non blocking flock at the start of the script that, on a failed lock, aborts immediately with exit code 0 (not 1, to avoid generating cron error emails) and writes a short message to a log file. That way the skipped run is documented without alerting the cron daemon.

CI/CD pipelines are a different context: here it is often desirable for a second deployment run to wait until the first one has finished, instead of simply aborting. The timeout pattern of flock is suited for that: flock -w 300 9 waits up to 5 minutes. Combined with a progress indicator (a periodic echo running in the background while waiting), it prevents CI systems from marking the job as hung due to a lack of output. This pattern is complete and addresses both the requirement for atomic locking and CI specific behavior requirements.

9. Locking methods compared directly

Every locking method in Bash has specific strengths and limitations. Choosing the right pattern depends on the environment, the required crash safety, and the desired behavior for competing instances.

Method Atomic? Crash safe? NFS suitable? Recommendation
flock (FD) Yes (kernel) Yes (auto release) Limited First choice on Linux
PID file No (TOCTOU) No (stale) Conditional Only with stale detection
mkdir Yes (POSIX) No (cleanup needed) Yes (POSIX) Portable fallback
ln -s (symlink) Yes (POSIX) No (cleanup needed) Yes Legacy, rarely needed
lockfile (procmail) Yes No Yes Legacy, procmail dependent

The recommendation for production use is clear: flock on Linux systems for all local file systems. mkdir as a portable fallback for systems without flock or for NFS shares. PID files only combined with stale detection and an additional flock for race condition free creation. In Docker containers and CI environments, flock is available in most base images and is always the first choice.

Mironsoft

Shell automation, robust scripts and deployment infrastructure

Want to protect your Bash scripts against race conditions and double execution?

We analyze existing shell scripts for race conditions and unsafe locking patterns and replace fragile PID file approaches with atomic flock based locking for cron, CI/CD and deployment workflows.

Race condition analysis

Checking scripts for TOCTOU vulnerabilities and unsafe locking patterns

flock migration

Replacing PID file patterns with atomic flock locking and testing them

Cron & CI hardening

Implementing locking strategies for cron jobs and parallel CI pipelines

10. Summary

Locking in Bash with flock solves the fundamental race condition problem of double execution through atomic, kernel backed locking. The PID file pattern is widespread but has structural weaknesses: no atomicity at creation time, no automatic cleanup after crashes, and PID recycling can make a stale lock falsely appear valid. flock -n 9 on an open file descriptor is the reliable alternative: atomic, crash safe, no manual cleanup required. The lock file only serves as an anchor, its content is irrelevant.

For cron jobs, the non blocking pattern with exit code 0 on a failed lock is the right choice: no error emails, but still logging. For CI/CD pipelines, the timeout pattern offers an elegant way to wait. On NFS or in environments without flock, mkdir is the atomic POSIX fallback. Anyone who needs to cover all three scenarios in deployment workflows should implement a locking function that switches between flock and mkdir depending on what the system supports.

Locking in Bash: the essentials at a glance

flock: the first choice

exec 9>lockfile; flock -n 9 || exit 0: atomic, crash safe, no cleanup needed. Kernel releases the lock automatically when the process ends.

PID files only with stale detection

kill -0 $PID checks existence. Compare the process name against PID recycling. Age check for stuck processes. Never use it without these three checks.

Understanding race conditions

Check then act without atomicity always creates a race condition. The TOCTOU window is usually just milliseconds wide, but occurs reproducibly under load.

mkdir as a fallback

POSIX atomic, NFS suitable. But not crash safe: trap 'rmdir lockdir' EXIT is mandatory. PID in a file inside the directory for stale detection.

11. FAQ: Locking in Bash, flock, PID Files and Race Conditions

1What is a race condition when locking in Bash?
Two processes check for a free lock at the same time (both see: free) and both set the lock. flock solves this through kernel backed atomicity.
2Why is flock safer than a PID file?
flock is atomic and crash safe. PID files have a TOCTOU window and are left behind after crashes. flock is released automatically when the process ends.
3What does flock -n mean?
Non blocking: immediate exit code 1 if the lock is unavailable. For cron jobs: if ! flock -n 9; then exit 0; fi, skip instead of wait.
4What is a stale lock?
A leftover lock from a crashed process. Not possible with flock, the kernel releases the lock automatically. For PID files: check kill -0, process name and age.
5Clean up the lock file with flock?
Not necessary. flock only uses the file as an FD anchor. The kernel lock is tied to the FD, not to the file content.
6flock on NFS?
Only reliable to a limited extent on NFSv3. Use mkdir on NFS shares as an atomic POSIX locking pattern.
7flock -n vs. flock -w?
-n gives up immediately. -w SECS waits at most SECS seconds. Cron: -n. CI/CD pipelines: -w with a sensible timeout (for example 300s).
8Why is mkdir atomic?
POSIX guarantees that only one process can successfully create a directory with the same name. All others receive EEXIST. This also applies on NFS.
9Locking in cron jobs?
flock -n with exit 0 on a failed lock, no error emails. Write a message to a log file. Cron interval at least twice as long as the maximum runtime.
10flock in Docker containers?
Yes, on local file systems (tmpfs, overlayfs). Not on NFS volumes. flock is included in most Linux base images via util-linux.