flock() is a Suggestion, Not a Law
OLYMPUS DIGITAL CAMERA

flock() is a Suggestion, Not a Law

I spend a fair amount of my professional life staring at static analysis reports for a living, and every so often a rule reminds me that I’ve been trusting a piece of the standard library a little too much. This week it was flock().

The original question was: “does file locking actually work on macOS?” And I did what any reasonable person does when a simple question deserves an unreasonable amount of verification: I wrote code to prove it. How would you live without interesting questions?

Short answer: yes, flock() works perfectly on macOS. Also: no, it doesn’t protect from concurent interventions, unless everyone agreed to play by the rules first. Paradoxical? may be. Let me show you.

The setup

flock() implements what’s called an advisory lock. The word advisory is doing a lot of quiet, apologetic work there. “Advisory” means the kernel will very politely make one process wait for another: that is, only if both processes bothered to ask. Nobody is forced to ask. There’s no bouncer at the door, just a sign that says “please form a queue,” and most people do, and some people just… walk in.

I wrote three tiny PHP scripts sharing one file:

  • locker opens the file, calls flock($fh, LOCK_EX), holds the lock for three seconds (pretend it’s doing something important), then writes and releases.
  • cooperative also calls flock($fh, LOCK_EX) before touching the file — a well-behaved citizen who waits its turn.
  • rogue just… reads the file. No flock() call. No manners.
<?php

switch ($mode) {
    case 'locker':
        flock($fh, LOCK_EX);
        sleep(3);
        fwrite($fh, "written-by-locker\n");
        flock($fh, LOCK_UN);
        break;

    case 'cooperative':
        flock($fh, LOCK_EX);   // blocks here until locker is done
        $content = fread($fh, 8192);
        flock($fh, LOCK_UN);
        break;

    case 'rogue':
        // no flock() call at all
        $content = fread($fh, 8192);
        break;
}

?>

Then I fired all three at roughly the same moment and watched the clock.

The results

[t+0.118s] [locker pid=49841]      requesting LOCK_EX
[t+0.118s] [locker pid=49841]      acquired LOCK_EX, holding for 3s
[t+0.339s] [cooperative pid=49843] requesting LOCK_EX (will block until locker releases)
[t+0.339s] [rogue pid=49844]       ignoring lock, reading immediately (no flock call)
[t+0.339s] [rogue pid=49844]       read file WITHOUT waiting: "initial-content"
[t+3.124s] [locker pid=49841]      wrote data, releasing lock
[t+3.124s] [cooperative pid=49843] acquired LOCK_EX -- had to wait for the locker
[t+3.124s] [cooperative pid=49843] read file after waiting: "written-by-locker"

Look at that timeline. cooperative asks for the lock at t+0.339s and doesn’t get it until t+3.124s: it waited, exactly like the manual promises. On the other hand, rogue asks for nothing, gets everything, immediately. It read the file while the exclusive lock was still held, and the kernel didn’t so much as raise an eyebrow.

This isn’t a macOS bug. Linux does exactly the same thing: flock() has always been advisory on both. macOS gets accused of weird filesystem behavior: that happens often enough, and other OS also get accused of this.

A second landmine, hiding in fopen() itself

While I was in there, I re-read the fopen() manual page for the first time in years, and found a trap that’s arguably worse because it looks so innocent. Watch what happens here:

$fh = fopen($path, 'w');
flock($fh, LOCK_EX);
fwrite($fh, $newContent);

That looks properly locked. It isn’t, as it behaves just like flock(). Mode 'w' truncates the file to zero bytes the instant fopen() runs: not when you write, when you open. That happens before flock() ever gets called. Any other process peeking at the file in that gap sees it empty, lock or no lock, because there was no lock yet when the damage was done. You’ve dutifully built a fence around an empty field.

This is exactly why PHP has a 'c' mode, and the manual is refreshingly honest about why it exists:

“This may be useful if it’s desired to get an advisory lock (see flock()) before attempting to modify the file, as using 'w' could truncate the file before the lock was obtained.”

'c' opens the file for writing, creates it if missing, but leaves the existing contents and the file pointer alone: no truncation, no surprise. You do the truncating yourself, on purpose, after you hold the lock:

<?php

$fh = fopen($path, 'c+');   // create if missing, never truncate on open
flock($fh, LOCK_EX);        // now the lock is protecting something real
ftruncate($fh, 0);          // truncate deliberately, lock already held
rewind($fh);
fwrite($fh, $newContent);
flock($fh, LOCK_UN);

?>

It’s the same lesson as the rest of this post, just one layer earlier: flock() only protects what happens after you call it. If the file mode already mutated the file before the lock was acquired, it doesn’t matter how disciplined your locking code is downstream. Our demo scripts dodged this by opening with 'r+' against a file that already existed. It is worth knowing that 'c' is the mode built for the case where you can’t assume that.

Okay, but does file_put_contents() lock too?

Fair question, and one I wasn’t going to answer from memory after everything above. file_put_contents() accepts a LOCK_EX flag:

<?php

    file_put_contents($path, $newContent, LOCK_EX);

?>

I made it prove itself the same way: one process holds a real flock() for two seconds, another calls file_put_contents($file, ..., LOCK_EX) a fraction of a second later, and a third peeks at the file mid-wait.

locker: acquired, holding 2s
rogue peek at t+0.5 (while writer is still BLOCKED waiting for lock):
old-content-should-still-be-here
locker: released
---
final content:
new-content

Two things worth noting. First, file_put_contents() genuinely blocked on the held lock: it didn’t return until the locker let go two seconds later. Second, and this is the pleasant surprise: it did not fall into the 'w'-mode trap from the previous section. While the call was sitting there waiting for the lock, the file still held its old content, untouched. It only truncates and writes once the lock is actually in hand, which is exactly the 'c' + flock() + ftruncate() dance done for you.

So: yes, it locks, and it locks correctly on the write side. The asterisk is that it’s still just sugar for one write. It’s advisory like everything else here: a file_get_contents() with no lock will still happily read whatever’s there, same as our rogue script. There’s no equivalent LOCK_SH convenience for file_get_contents(), and there’s no non-blocking option. You can’t tell it “try for the lock and fail fast” the way you can with flock($fh, LOCK_EX | LOCK_NB). Reach for file_put_contents(..., LOCK_EX) for simple single-shot writes; reach for manual fopen()+ flock() the moment you need a read-modify-write to happen atomically, or need to bail instead of block.

Why this is fine, actually

I want to be clear this isn’t a rant about broken APIs. Advisory locking is a deliberate, sensible design. The kernel doesn’t know which of your processes are trustworthy and which are about to fopen()your data file and start scribbling. Mandatory locking, where the OS refuses reads/writes to anyone who doesn’t hold the lock, exists in theory but it’s clunky, mostly deprecated, and not something you get by default on macOS or modern Linux. So flock() provides a coordination tool for cooperating processes, and trusts you to make sure everyone touching the file is, in fact, cooperating.

The bug isn’t in flock(). The bug is in assuming “I called flock() in my code” means “this file is now safe,” when really it means “this file is now safe from other code that also called flock().”

So what do you actually do about it?

A few options, roughly ordered from “cheapest” to “you now run a database”:

  1. Make locking mandatory in the codebase, structurally Don’t sprinkle flock() calls around and hope. Wrap all access to the file in one class or function that nobody is allowed to bypass. Create a LockedFile class whose constructor requires a lock before you can call read() orwrite(). If the only door in is a locked door, people stop finding side windows.
  2. Audit for the side windows This is the part where I plug my day job a little: if you have a codebase with a dozen places that touch the same file, some using flock() and some not, that’s exactly the kind of inconsistency a static analysis pass finds in ten seconds and a human finds in three days of production incidents. Grep for every fopen()/file_put_contents() touching that path and check they’re all going through the same locked gate.
  3. Use LOCK_NB and fail loudly instead of silently racing If a process can’t get the lock, don’t have it fall back to unlocked access “just this once.” Use the non-blocking flag, check the return value, and either retry with backoff or bail with a clear error. A crash is more honest than silent corruption.
  4. Move contested data into something with real concurrency control SQLite, even as a single file, gives you proper transactional locking semantics instead of “please and thank you.” For anything busier, a real database, or Redis with SETNX/Redlock-style locks, gets you guaranteesflock() was never designed to provide, especially across machines — flock() doesn’t work over NFS the way you’d hope, and doesn’t work at all across a network boundary.
  5. Restrict who can even open the file If rogue processes are rogue because they’re a different team’s script, different service, or, future me at 2 a.m. skipping the lock “just to check something quick”: filesystem permissions are the one enforcement mechanism the kernel will back you up on. If a process literally cannot open the file for writing, it doesn’t matter whether it remembered to call flock().
  6. Use a lock file, atomically, as a mutex of last resort mkdir() and rename() are atomic at the filesystem level in ways plain writes aren’t. A “lockfile via atomic rename” pattern, (write to a temp file, rename() into place) sidesteps the whole locking question for the write itself, at the cost of readers potentially seeing old-but-never- corrupt data instead of a coordinated view.

The moral

flock() does exactly what it says on the box, and the box is honest about being advisory. The failure mode isn’t the API lying: it’s treating a gentleman’s agreement as if it were a padlock. Test the assumption once, the way we just did, and you’ll know whether you’re protecting a shared resource or just protecting the well-behaved half of your own codebase.

The rogue script is still out there, reading your files, feeling nothing. Go find it before it finds you.