Lock Files in PHP & Bash

In: General

2 Jan 2010

I just read “How to use locks in PHP cron jobs to avoid cron overlaps” and I thought I would elaborate on this and provide some more examples. In order for a lock to work correctly it must handle, Atomicity / Race Conditions, and Signaling.

I use the following bash script to create locks for crontabs and ensure single execution of scripts.

“The clever bit is to get a lock file test and creation (if needed) to be atomic, that is done without interruption. The set -C stops a redirection from over writing a file. The : > touches a file. In combination, the effect is, when the lock file exists, the redirection fails and exits with an error. If it does not exist, the redirection creates the lock file and exits without an error.The final part is to make sure that the lock file is cleaned up. To makes sure it is removed even if the script is terminated with a ctrl-c, a trap is used. Simply, when the script exits, the trap is run and the lock file is deleted.”, The Lab Book Pages

In addition it also checks the process list and tests whether the pid within the lock file is active.

#!/bin/bash

LOCK_FILE=/tmp/my.lock
CRON_CMD="php /var/www/..../fork.php -t17"

function check_lock {
    (set -C; : > $LOCK_FILE) 2> /dev/null
    if [ $? != "0" ]; then
        RUNNING_PID=$(cat $LOCK_FILE 2> /dev/null || echo "0");
        if [ "$RUNNING_PID" -gt 0 ]; then
            if [ `ps -p $RUNNING_PID -o comm= | wc -l` -eq 0 ]; then
                echo "`date +'%Y-%m-%d %H:%M:%S'` WARN [Cron wrapper] Lock File exists but no process running $RUNNING_PID, continuing";
            else
                echo "`date +'%Y-%m-%d %H:%M:%S'` INFO [Cron wrapper] Lock File exists and process running $RUNNING_PID - exiting";
                exit 1;
            fi
        else
            echo "`date +'%Y-%m-%d %H:%M:%S'` CRIT [Cron wrapper] Lock File exists with no PID, wtf?";
            exit 1;
        fi
    fi
    trap "rm $LOCK_FILE;" EXIT
}

check_lock;
echo "`date +'%Y-%m-%d %H:%M:%S'` INFO [Cron wrapper] Starting process";
$CRON_CMD &
CURRENT_PID=$!;
echo "$CURRENT_PID" > $LOCK_FILE;
trap "rm -f $LOCK_FILE 2> /dev/null ; kill -9 $CURRENT_PID 2> /dev/null;" EXIT;
echo "`date +'%Y-%m-%d %H:%M:%S'` INFO [Cron wrapper] Started ($CURRENT_PID)";
wait;
# remove the trap kill so it won't try to kill process which took place of the php one in mean time (paranoid)
trap "rm -f $LOCK_FILE 2> /dev/null" EXIT;
rm -f $LOCK_FILE 2> /dev/null;
echo "`date +'%Y-%m-%d %H:%M:%S'` INFO [Cron wrapper] Finished process";

With the implementation described in the post at abhinavsingh.com, it will fail if you put it as a background process as an example see below.

andrew@andrew-home:~/tmp.lock$ php x.php
==16169== Lock acquired, processing the job...
^C
andrew@andrew-home:~/tmp.lock$ php x.php
==16169== Previous job died abruptly...
==16170== Lock acquired, processing the job...
^C

andrew@andrew-home:~/tmp.lock$ php x.php
==16170== Previous job died abruptly...
==16187== Lock acquired, processing the job...
^Z
[1]+  Stopped                 php x.php
andrew@andrew-home:~/tmp.lock$ ps aux | grep php
andrew   16187  0.5  0.5  50148 10912 pts/2    T    09:53   0:00 php x.php
andrew   16192  0.0  0.0   3108   764 pts/2    R+   09:53   0:00 grep --color=auto php
andrew@andrew-home:~/tmp.lock$ php x.php
==16187== Already in progress...

You can use pcntl_signal to trap interruptions to the application and handle cleanup of the process. Here is a slightly modified implementation to handle cleanup. Just to highlight the register_shutdown_function will not help to cleanup on any signal/interruption.

<?php
class lockHelper {

	protected static $_pid;

	protected static $_lockDir = '/tmp/';

	protected static $_signals = array(
		// SIGKILL,
		SIGINT,
		SIGPIPE,
		SIGTSTP,
		SIGTERM,
		SIGHUP,
		SIGQUIT,
	);

	protected static $_signalHandlerSet = FALSE;

	const LOCK_SUFFIX = '.lock';

	protected static function isRunning() {
		$pids = explode(PHP_EOL, `ps -e | awk '{print $1}'`);
		return in_array(self::$_pid, $pids);
	}

	public static function lock() {
		self::setHandler();

		$lock_file = self::$_lockDir . $_SERVER['argv'][0] . self::LOCK_SUFFIX;
		if(file_exists($lock_file)) {
			self::$_pid = file_get_contents($lock_file);
			if(self::isrunning()) {
				error_log("==".self::$_pid."== Already in progress...");
				return FALSE;
			}
			else {
				error_log("==".self::$_pid."== Previous job died abruptly...");
			}
		}

		self::$_pid = getmypid();
		file_put_contents($lock_file, self::$_pid);
		error_log("==".self::$_pid."== Lock acquired, processing the job...");
		return self::$_pid;
	}

	public static function unlock() {
		$lock_file = self::$_lockDir . $_SERVER['argv'][0] . self::LOCK_SUFFIX;
		if(file_exists($lock_file)) {
			error_log("==".self::$_pid."== Releasing lock...");
			unlink($lock_file);
		}
		return TRUE;
	}

	protected static function setHandler() {
		if (!self::$_signalHandlerSet) {

			declare(ticks = 1);

			foreach(self::$_signals AS $signal) {
				if (!pcntl_signal($signal, array('lockHelper',"signal"))) {
					error_log("==".self::$_pid."== Failed assigning signal - '{$signal}'");
				}
			}
		}
		return TRUE;
	}

	protected static function signal($signo) {
		if (in_array($signo, self::$_signals)) {
			if(!self::isrunning()) {
				self::unlock();
			}
		}
		return FALSE;
	}
}

As an example:

andrew@andrew-home:~/tmp.lock$ php t.php
==16268== Lock acquired, processing the job...
^Z==16268== Releasing lock...

Whilst the implementation above simply uses files, it could be implemented with shared memory (SHM/APC), distributed caching (memcached), or a database. If over a network, factors such as packet loss, latency etc can cause race conditions and should be taken into account. Depending on the application it maybe better to implement as a daemon. If your looking to distribute tasks amongst servers, take a look at Gearman

2 Responses to Lock Files in PHP & Bash

Avatar

magic_touch

July 25th, 2011 at 11:03 pm

Hello, what does mean the -t17 in CRON_CMD=”php /var/www/…./fork.php -t17″

Avatar

andrew.johnstone

July 27th, 2011 at 5:25 am

It is simply an argument to a php script I wrote, which forked itself and acted as a time interval for a certain check. There was no significance to the post at all, just something which I had left in from a cron on one of our production servers.

Comment Form

About this blog

I have been a developer for roughly 10 years and have worked with an extensive range of technologies. Whilst working for relatively small companies, I have worked with all aspects of the development life cycle, which has given me a broad and in-depth experience.