Back to blog

Atomic file locking in PHP: the mkdir trick that needs no Redis

PHP developers have three common approaches to preventing parallel cron execution: advisory locks in the database, Redis SETNX with TTL, or ignoring the problem and getting duplicate runs. There's a fourth option: atomic mkdir, a POSIX primitive available on any filesystem with no external dependencies.

I needed 13 cron jobs to never run in parallel. All of them share one Chrome profile — the same LinkedIn session I logged in manually a month ago. Two instances at once and the profile breaks.

First instinct: Redis SETNX with TTL. I stopped. Why add Redis just for a lock? That's a separate service, separate monitoring, separate failure point — for a problem that has nothing else to do with Redis.

The solution: mkdir cron/.lock.

Why Redis isn't always the right answer for concurrency

Redis SETNX is the right call when Redis is already in your stack. If you're running Bitrix with Redis as your session store and cache backend, adding SETNX for a lock costs nothing extra.

But if Redis isn't needed for anything else, you're adding a service for one atomic operation. The same logic applies to sem_get() — it needs the sysvsem extension, which isn't guaranteed on your production server. flock() is unreliable over NFS, and that limitation tends to surface at the worst possible time.

For most single-server PHP setups, the right question is: what guarantees atomicity at the OS level, with zero dependencies?

The answer is the filesystem.

mkdir as an atomic operation

mkdir is atomic on Linux and macOS by POSIX guarantee. Either the directory was created, or it wasn't. There's no in-between.

If two processes call mkdir("cron/.lock") at the same moment, exactly one succeeds. The other gets EEXIST — guaranteed by the kernel, not your application code.

A cheap primitive that works on any server with a filesystem.

Full implementation

<?php

$lockDir = __DIR__ . '/cron/.lock';
$pidFile = $lockDir . '/pid';

function acquireLock(string $lockDir, string $pidFile): bool
{
    // Check if previous lock holder is still alive
    if (is_dir($lockDir) && file_exists($pidFile)) {
        $pid = (int) file_get_contents($pidFile);
        // posix_kill with signal 0 doesn't kill — it just checks if process exists
        if ($pid > 0 && posix_kill($pid, 0)) {
            return false; // owner is alive, back off
        }
        // Owner is dead — clean up the stale lock
        @unlink($pidFile);
        @rmdir($lockDir);
    }

    if (!@mkdir($lockDir, 0755)) {
        return false; // someone else got there first
    }

    file_put_contents($pidFile, getmypid());
    return true;
}

function releaseLock(string $lockDir, string $pidFile): void
{
    @unlink($pidFile);
    @rmdir($lockDir);
}

// --- Start ---

if (!acquireLock($lockDir, $pidFile)) {
    file_put_contents('cron/logs/T01.jsonl',
        json_encode(['ts' => date('c'), 'status' => 'skipped_locked']) . "\n",
        FILE_APPEND
    );
    exit(0);
}

register_shutdown_function('releaseLock', $lockDir, $pidFile);

// Main task logic...

acquireLock does three things. It checks for an existing lock directory with a PID. If one exists, it verifies the process is alive via posix_kill($pid, 0). If the process is dead, it clears the stale lock and tries to acquire fresh. register_shutdown_function ensures cleanup on any exit path, including exit() and non-fatal PHP errors.

Crash recovery via PID

The problem with a bare mkdir lock: what if the process crashes? The directory stays. The next run sees it and refuses to start forever.

The PID file inside the lock directory handles this. When the next task starts:

  1. Directory exists — read the PID.
  2. posix_kill($pid, 0) returns false — process is dead.
  3. Clear the lock, create a fresh one, continue.

posix_kill($pid, 0) sends no signal. It's just the kernel's way of saying "this PID exists." When it returns false, the process is gone.

In my setup, this kicked in twice over the first week: once when Claude Code hung and got killed manually, once when a task died on a fatal PHP error. Both times the next tick recovered without manual intervention.

The STOP file

At the root of 06 - Операции/cron/ there's an empty file called STOP. Every wrapper script checks for it as the first line:

if (file_exists(__DIR__ . '/STOP')) {
    error_log('Cron stopped via STOP file');
    exit(0);
}

One command stops all 13 tasks:

touch "06 - Операции/cron/STOP"

Why does this matter? If LinkedIn throws a security alert or a CAPTCHA, I need everything to stop faster than launchctl unload on 13 plist files. Faster than hunting PIDs. One file, one check at the top of every script. Done.

When this doesn't apply

Three situations where mkdir lock doesn't give you the guarantees you need.

NFS mounts: atomicity of mkdir over NFS depends on the client and server implementation. If multiple servers are writing to the same NFS share, use a proper distributed lock like Redis with the Redlock algorithm.

Docker bind-mounts: overlay filesystem within a single Docker host supports POSIX semantics. Bind-mounts from the host across different Docker nodes don't. On one node, you're fine.

Multiple machines: if your cron jobs can start on different servers simultaneously, filesystem locking doesn't help. You need a network primitive.

For a single server with local storage, it works reliably.

The bottom line

For one server, one PHP process, and the requirement "don't run two at once" — mkdir with a PID file handles it. No Redis. No extensions. Atomicity guaranteed by POSIX. Crash recovery built in. Kill switch: one file.

Cheap, boring, works in production.

I use this pattern for overnight Elasticsearch reindexing, 1C imports, and Bitrix cache warming. The full context behind the 13-task automation is in the SSI automation article. If you want the Bitrix-specific implementation, DM me.