skip to content

In PHP, why is fopen($path, 'w') followed by flock() the wrong way to update a shared file, and what is the correct sequence?

level: middleimportance: should knowfreq 35%

answer

  1. truncation happens before the lock
  2. flock() is advisory
  3. LOCK_SH, LOCK_EX, LOCK_UN, LOCK_NB
  4. open with 'c+', then LOCK_EX
  5. ftruncate() plus rewind(), fflush() before LOCK_UN

basics

~20 s

Mode 'w' truncates the file inside fopen(), before flock() runs, so another process can read an empty file or lose an update. Open with 'c+', take LOCK_EX, read, ftruncate() and rewind(), write, fflush(), then release with LOCK_UN.

solid answer

~40 s

`flock($h, $operation)` takes an **advisory** lock on an open handle: `LOCK_SH` for readers, `LOCK_EX` for a writer, `LOCK_UN` to release, and `LOCK_NB` added with `|` to fail instead of waiting. It blocks by default and returns `true` or `false`. The trap with `'w'` is ordering: `fopen($path, 'w')` truncates the file as it opens, so the data is gone before `flock()` even runs, and a reader holding a shared lock sees it vanish. The manual's answer is mode `'c'` or `'c+'`, which creates or opens without truncating. The sequence is `fopen($path, 'c+')`, `flock($h, LOCK_EX)`, read with `stream_get_contents()` or `fgets()`, `ftruncate($h, 0)`, `rewind($h)`, `fwrite()`, `fflush()`, `flock($h, LOCK_UN)`, `fclose()`. Because the lock is advisory, every reader must take `LOCK_SH` too; `file_get_contents()` takes none.

go deeper

for a junior

Recall the four constants LOCK_SH, LOCK_EX, LOCK_UN and LOCK_NB, and that flock() blocks by default and returns a bool.

for a middle

Explain why 'w' truncates before the lock, why 'c+' exists, and why ftruncate() must be followed by rewind() before writing.

for a senior

Show you know the lock is advisory: every reader takes LOCK_SH, nothing unlocked touches the file, and a crash mid-write still needs a recovery plan.

for a principal

Weigh file locks against moving shared state into a database or queue, especially once more than one host must coordinate.

## What flock() gives you `flock($stream, $operation, &$would_block = null): bool` locks the **whole file** behind an open handle: | Operation | Meaning | |---|---| | `LOCK_SH` | shared lock: many readers may hold it together | | `LOCK_EX` | exclusive lock: one writer, no readers holding `LOCK_SH` | | `LOCK_UN` | release whatever lock this handle holds | | `… \| LOCK_NB` | do not wait; return `false` at once, setting `$would_block` to `1` if the lock is held elsewhere | By default `flock()` **blocks** until the lock is granted. The lock is also released by `fclose()`, when the handle is garbage collected, and therefore at the end of the request. The lock is **advisory**: it only coordinates processes that also call `flock()`. A process that opens the file and writes without locking is not stopped, and `file_get_contents()` never takes a lock. The manual adds platform notes: on Windows the lock is mandatory, flock is unsupported on old FAT file systems, and in a multithreaded server API it may not protect against other threads of the same process. ## Why 'w' then flock() loses data The order of operations is the whole bug: 1. `fopen($path, 'w')` opens **and truncates** the file to zero bytes. 2. `flock($h, LOCK_EX)` waits for the lock. 3. You write the new content. Step 1 has already destroyed the old content, without holding any lock. Consequences: - A reader that holds `LOCK_SH` and is halfway through the file suddenly reads an empty file. - A second writer doing read-modify-write (a counter, a JSON state file) opens with `'w'`, wipes the file, and then waits for the first writer; when it finally reads, there is nothing to read, and its update is built on nothing. - If the script dies between steps 1 and 3, the file stays empty. The manual says it directly: `'c'` exists because `'w'` "could truncate the file before the lock was obtained", and `ftruncate()` can be used after the lock is taken if truncation is wanted. ## The correct sequence ```php <?php declare(strict_types=1); $h = fopen('/var/app/counter.txt', 'c+'); if ($h === false || !flock($h, LOCK_EX)) { throw new RuntimeException('cannot lock counter'); } $count = (int) stream_get_contents($h); ftruncate($h, 0); rewind($h); fwrite($h, (string) ($count + 1)); fflush($h); flock($h, LOCK_UN); fclose($h); ``` Step by step: 1. **`'c+'`** creates the file if needed, keeps its content, allows reading and writing, and starts at offset 0. 2. **`LOCK_EX`** before touching the content, so no other cooperating process is inside. 3. **Read** the current value. 4. **`ftruncate($h, 0)`** empties the file; it does not move the pointer, so **`rewind($h)`** is needed or the write lands at the old offset and leaves a hole. 5. **`fflush($h)`** pushes pending output before the lock is dropped, as in the manual's own example. 6. **`LOCK_UN`**, then `fclose()`. Readers use the mirror image: open with `'r'`, `flock($h, LOCK_SH)`, read, release. ## What file_put_contents(..., LOCK_EX) does `file_put_contents($path, $data, LOCK_EX)` follows the same safe order internally: without `FILE_APPEND` it opens in `'c'` mode, takes `LOCK_EX`, **then** truncates and writes, and closes (releasing the lock). It refuses `LOCK_EX`, with a warning, on streams that cannot be locked, such as a non-`file://` wrapper. It is a good fit for "replace the whole file under a lock", but it cannot read the old value first, so read-modify-write still needs the handle sequence above. ## Mistakes around the lock itself - **Ignoring the return value.** `flock()` returns `false` on failure (for example on a file system without lock support); carrying on as if locked silently removes the protection. - **Slow work inside the lock.** Every other writer waits while you hold `LOCK_EX`; do network calls or heavy computation before taking the lock, and keep the locked section to read, compute, write. - **Releasing before flushing.** Unlocking first lets another process read before your bytes are handed to the operating system. - **Relying on the end of the request.** The lock does go away when the handle closes, but an explicit `LOCK_UN` or `fclose()` shortens the time others wait. ## Limits to know - Locks are per file, not per byte range. - A crash inside the locked section can still leave a half-written file; for a whole-file replacement, writing to a temporary file and moving it into place avoids that. - On network file systems, lock behaviour depends on the server; test it rather than assume it.

  • How do you try a lock without waiting in PHP?
    Add `LOCK_NB`: `flock($h, LOCK_EX | LOCK_NB, $wouldBlock)`. If another process holds a conflicting lock, `flock()` returns `false` immediately and sets `$wouldBlock` to `1`, so you can skip the job, retry later or report "already running". This is the usual way to keep a cron script from running twice.
  • Does file_get_contents() wait for another process's LOCK_EX?
    No. `flock()` is advisory, and `file_get_contents()` never asks for a lock, so it reads whatever is on disk, including a truncated or half-written file. A reader that must see consistent data opens a handle, takes `LOCK_SH`, reads with `stream_get_contents()` or `fgets()`, then releases the lock.
  • In what order does file_put_contents($path, $data, LOCK_EX) truncate and lock?
    Without `FILE_APPEND` it opens the file in `'c'` mode, takes `LOCK_EX`, and only then truncates to zero and writes, so it does not repeat the `'w'` mistake. With `FILE_APPEND | LOCK_EX` it opens in append mode and locks. In both cases it closes the file at the end, which releases the lock.

saying these in an interview costs you the question

  • flock() stops any other program from writing to the file.
  • Opening with 'w' is fine as long as flock() is called right after.
  • ftruncate($h, 0) also moves the file pointer back to the start.
  • file_get_contents() waits until a writer's LOCK_EX is released.
  • LOCK_NB makes flock() wait with a timeout.