PHP flock() alternative

Here is my "PHP flock() alternative" - build on mkdir().

The idea to do it with mkdir() came from here and here.

My version

  • checks if I already have got lock-access. It also prevents blocking myself if I create and use the class multiple times for the same basedir.name
  • checks if my locking-file, with which I am asking for lock-access, was created
  • lets me get lock-access in the order I came to ask for it
  • stops waiting and looping if it could not get lock-access in the time I specified
  • removes dead lock-files (= files where the SID of the PID does not exist any more)

You can use the PHP-class like this:

//$dir        (string) = base-directory for the lock-files (with 'files' I mean directories => mode 0644)
// 2       (float/int) = time to wait for lock-access before returning unsuccessful (default is 0 <= try once and return)
//'.my_lock'  (string) = the way you want to name your locking-dirs (default is '.fLock')
$lock = new FileLock($dir, 2, '.my_lock');

//start lock - a locking directory will be created looking like this:
//$dir/.my_lock-1536166146.4997-22796
if ($lock->lock()) {
    //open your file - modify it - write it back
} else { /* write alert-email to admin */ }

//check if I had locked before
if ($lock->is_locked) { /* do something else with your locked file */ }

//unlock - the created dir will be removed (rmdir)
$lock->unlock();

Here is the working class:

//build a file-locking class
define('LOCKFILE_NONE', 0);
define('LOCKFILE_LOCKED', 1);
define('LOCKFILE_ALREADY_LOCKED', 2);
define('LOCKFILE_ALREADY_LOCKED_IN_OTHER_CLASS', 3);
define('LOCKFILE_FAILED_TO_OBTAIN_LOCK', false);
define('LOCKFILE_FAILED_TO_OBTAIN_LOCK_BY_TIMEOUT', '');


class FileLock {
    //FileLock assumes that there are no other directories or files in the
    //lock-base-directory named "$name-(float)-(int)"
    //FileLock uses mkdir() to lock. Why?
    //- mkdir() is atomic, so the lock is atomic and faster then saving files.
    //  Apparently it is faster than flock(), that requires several calls to the
    //  file system.
    //- flock() depends on the system, mkdir() works everywhere.

    private static $locked_memory = array();

    public function __construct($lockbasedir, $wait_sec=0, $name='.fLock') {
        $this->lockbasedir = (string)$lockbasedir;
        $this->wait        = (float)$wait_sec;
        $this->name        = (string)$name;

        $this->pid         = (int)getmypid();

        //if this basedir.name was locked before and is still locked don't try to lock again
        $this->is_locked   = empty(self::$locked_memory[$this->lockbasedir . $this->name]) ? LOCKFILE_NONE : LOCKFILE_ALREADY_LOCKED;
    }

    public function lock() {
        if ($this->is_locked) return $this->is_locked;

        $break_time = microtime(true);

        //create the directory as lock-file NOW
        $this->lockdir = "{$this->name}-" . number_format($break_time, 4, '.', '') . "-{$this->pid}";
        @mkdir("{$this->lockbasedir}/{$this->lockdir}", 0644);

        $break_time += $this->wait;

        //try to get locked
        while ($this->wait == 0 || microtime(true) < $break_time) {

            //get all locks with $this->name
            $files = preg_grep("/^{$this->name}-\d+\.\d+-\d+$/", scandir($this->lockbasedir));

            //since scandir() is sorted asc by default
            //$first_file is the next directory to obtain lock
            $first_file = reset($files);

            if (!$first_file) {
                //no lock-files at all
                return $this->is_locked = LOCKFILE_FAILED_TO_OBTAIN_LOCK;
            } elseif ($first_file == $this->lockdir) {
                //Its me!! I'm getting locked :)
                self::$locked_memory[$this->lockbasedir . $this->name] = 1;
                return $this->is_locked = LOCKFILE_LOCKED;
            } elseif (preg_match("/^{$this->name}-\d+\.\d+-{$this->pid}$/", $first_file)) {
                //my process-ID already locked $this->name in another class before
                rmdir("{$this->lockbasedir}/{$this->lockdir}");
                $this->lockdir = $first_file;
                self::$locked_memory[$this->lockbasedir . $this->name] = 1;
                return $this->is_locked = LOCKFILE_ALREADY_LOCKED_IN_OTHER_CLASS;
            }

            //missing lock-file for this job
            if (array_search($this->lockdir, $files) === false) return LOCKFILE_FAILED_TO_OBTAIN_LOCK;

            //run only once
            if ($this->wait == 0) break;

            //check if process at first place has died
            if (!posix_getsid(explode('-', $first_file)[2])) {
                //remove dead lock
                @rmdir("{$this->lockbasedir}/$first_file");
            } else {
                //wait and try again after 0.1 seconds
                usleep(100000);
            }
        }

        return $this->is_locked = LOCKFILE_FAILED_TO_OBTAIN_LOCK_BY_TIMEOUT;
    }

    public function unlock($force=false) {
        if ($force || $this->is_locked == 1) {
            rmdir("{$this->lockbasedir}/{$this->lockdir}");
            self::$locked_memory[$this->lockbasedir . $this->name] = $this->is_locked = LOCKFILE_NONE;
        }
    }
}

There is no alternative available to safely achieve the same under all imaginary possible circumstances. That's by design of computer systems and the job is not trivial for cross-platform code.

If you need to make safe use of flock(), document the requirements for your application instead.

Alternatively you can create your own locking mechanism, however you must ensure it's atomic. That means, you must test for the lock and if it does not exists, establish the lock while you need to ensure that nothing else can acquire the lock in-between.

This can be done by creating a lock-file representing the lock but only if it does not exists. Unfortunately, PHP does not offer such a function to create a file in such a way.

Alternatively you can create a directory with mkdir() and work with the result because it will return true when the directory was created and false if it already existed.


You can implement a filelock - unlock pattern around your read/write operations based on mkdir, since that is atomic and pretty fast. I've stress tested this and unlike mgutt did not find a bottleneck. You have to take care of deadlock situations though, which is probably what mgutt experienced. A dead lock is when two lock attempts keep waiting on each other. It can be remedied by a random interval on the lock attempts. Like so:

// call this always before reading or writing to your filepath in concurrent situations
function lockFile($filepath){
   clearstatcache();
   $lockname=$filepath.".lock";
   // if the lock already exists, get its age:
   $life=@filectime($lockname);
   // attempt to lock, this is the really important atomic action:
   while (!@mkdir($lockname)){
         if ($life)
            if ((time()-$life)>120){
               //release old locks
               rmdir($lockname);
               $life=false;
         }
         usleep(rand(50000,200000));//wait random time before trying again
   }
}

Then work on your file in filepath and when you're done, call:

function unlockFile($filepath){
   $unlockname= $filepath.".lock";   
   return @rmdir($unlockname);
}

I've chosen to remove old locks, well after the maximum PHP execution time in case a script exits before it has unlocked. A better way would be to remove locks always when the script fails. There is a neat way for this, but I have forgotten.


My proposal is to use mkdir() instead of flock(). This is a real-world example for reading/writing caches showing the differences:

$data = false;
$cache_file = 'cache/first_last123.inc';
$lock_dir = 'cache/first_last123_lock';
// read data from cache if no writing process is running
if (!file_exists($lock_dir)) {
    // we suppress error messages as the cache file exists in 99,999% of all requests
    $data = @include $cache_file;
}
// cache file not found
if ($data === false) {
    // get data from database
    $data = mysqli_fetch_assoc(mysqli_query($link, "SELECT first, last FROM users WHERE id = 123"));
    // write data to cache if no writing process is running (race condition safe)
    // we suppress E_WARNING of mkdir() because it is possible in 0,001% of all requests that the dir already exists after calling file_exists()
    if (!file_exists($lock_dir) && @mkdir($lock_dir)) {
        file_put_contents($cache_file, '<?php return ' . var_export($data, true) . '; ?' . '>')) {
        // remove lock
        rmdir($lock_dir);
    }
}

Now, we try to achieve the same with flock():

$data = false;
$cache_file = 'cache/first_last123.inc';
// we suppress error messages as the cache file exists in 99,999% of all requests
$fp = @fopen($cache_file, "r");
// read data from cache if no writing process is running
if ($fp !== false && flock($fp, LOCK_EX | LOCK_NB)) {
    // we suppress error messages as the cache file exists in 99,999% of all requests
    $data = @include $cache_file;
    flock($fp, LOCK_UN);
}
// cache file not found
if (!is_array($data)) {
    // get data from database
    $data = mysqli_fetch_assoc(mysqli_query($link, "SELECT first, last FROM users WHERE id = 123"));
    // write data to cache if no writing process is running (race condition safe)
    $fp = fopen($cache_file, "c");
    if (flock($fp, LOCK_EX | LOCK_NB)) {
        ftruncate($fp, 0);
        fwrite($fp, '<?php return ' . var_export($data, true) . '; ?' . '>');
        flock($fp, LOCK_UN);
    }
}

The important part is LOCK_NB to avoid blocking all consecutive requests:

It is also possible to add LOCK_NB as a bitmask to one of the above operations if you don't want flock() to block while locking.

Without it, the code would produce a huge bottleneck!

An additional important part is if (!is_array($data)) {. This is because $data could contain:

  1. array() as a result of the db query
  2. false of the failing include
  3. or an empty string (race condition)

The race condition happens if the first visitor executes this line:

$fp = fopen($cache_file, "c");

and another visitor executes this line one millisecond later:

if ($fp !== false && flock($fp, LOCK_EX | LOCK_NB)) {

This means the first visitor creates the empty file, but the second visitor creates the lock and so include returns an empty string.

So you saw many pitfalls that can be avoided through using mkdir() and its 7x faster, too:

$filename = 'index.html';
$loops = 10000;
$start = microtime(true);
for ($i = 0; $i < $loops; $i++) {
    file_exists($filename);
}
echo __LINE__ . ': ' . round(microtime(true) - $start, 5) . PHP_EOL;
$start = microtime(true);
for ($i = 0; $i < $loops; $i++) {
    $fp = @fopen($filename, "r");
    flock($fp, LOCK_EX | LOCK_NB);
}
echo __LINE__ . ': ' . round(microtime(true) - $start, 5) . PHP_EOL;

result:

file_exists: 0.00949
fopen/flock: 0.06401

P.S. as you can see I use file_exists() in front of mkdir(). This is because my tests (German) resulted bottlenecks using mkdir() alone.

Tags:

Php

Locking

Flock