Lock files are a surprisingly effective, albeit sometimes crude, way to prevent multiple instances of a bash script from running concurrently and corrupting shared resources.

Imagine a script that updates a shared counter file. If two instances run at the exact same time, they might both read the same initial value, increment it, and then write back the same incremented value, losing one of the updates. This is a race condition. Lock files solve this by creating a temporary "lock" file that only one script can hold at a time.

Let’s see this in action with a simple script that increments a counter.

#!/bin/bash

COUNTER_FILE="/tmp/my_counter.txt"
LOCK_FILE="/tmp/my_counter.lock"

# Attempt to acquire the lock
if ! mkdir "$LOCK_FILE" 2>/dev/null; then
    echo "Another instance is running. Exiting."
    exit 1
fi

# Ensure the lock is released on exit, even if errors occur
trap 'rmdir "$LOCK_FILE"' EXIT

# Initialize counter if it doesn't exist
if [ ! -f "$COUNTER_FILE" ]; then
    echo "0" > "$COUNTER_FILE"
fi

# Read the current value
current_value=$(cat "$COUNTER_FILE")

# Simulate some work
sleep 1

# Increment and write back
new_value=$((current_value + 1))
echo "$new_value" > "$COUNTER_FILE"

echo "Counter updated to: $new_value"

exit 0

If you run this script multiple times in rapid succession, you’ll see:

$ ./increment_counter.sh
Counter updated to: 1
$ ./increment_counter.sh
Another instance is running. Exiting.
$ ./increment_counter.sh
Another instance is running. Exiting.
$ ./increment_counter.sh
Counter updated to: 2

The mkdir command is the core of the locking mechanism. mkdir is an atomic operation on most file systems. This means that when two processes try to create the same directory simultaneously, the operating system guarantees that only one of them will succeed. The other will fail, and mkdir will return a non-zero exit code. We capture this failure with 2>/dev/null to suppress the "File exists" error message and then check the exit status with if ! ....

The trap 'rmdir "$LOCK_FILE"' EXIT is crucial. It registers a command (rmdir "$LOCK_FILE") to be executed when the script exits, regardless of whether it exits normally, via exit, or due to a signal. rmdir will remove the empty lock directory. This ensures that the lock is always released, preventing deadlocks where a script crashes and leaves the lock file behind, preventing any subsequent runs.

The mental model here is that the lock file (in this case, a directory) acts as a gate. A script must successfully create the lock directory to pass through. If the directory already exists, it means another script is currently "inside" and holding the lock, so the new script waits or exits. Once the script finishes its critical section (the part that needs to be exclusive), it removes the lock directory, allowing the next waiting script to proceed.

A common pitfall is relying on touch for lock files. While touch also has some atomicity guarantees, mkdir is generally considered more robust for this specific purpose because it’s designed to fail unambiguously if the target already exists, whereas touch might update timestamps.

The sleep 1 is there to increase the probability of a race condition occurring if the lock wasn’t in place. In a real-world scenario, this "work" could be a database transaction, a file write, or any operation that needs to be performed by only one process at a time.

Another thing to consider is what happens if the script is killed forcefully (e.g., kill -9). The EXIT trap will not be triggered in such cases. For more robust locking, especially in distributed or highly critical systems, you might look into tools like flock or lockf which can handle more complex scenarios, including waiting for locks to be released.

The next problem you’ll likely encounter is how to handle multiple scripts waiting for a lock instead of just exiting, which involves implementing a polling or queuing mechanism.

Want structured learning?

Take the full Bash course →