Skip to content

flock() contention starves the libuv pool: more waiters than UV_THREADPOOL_SIZE and nothing is written #221

Description

@EdmondDantes

Summary

Coroutines that wait for a contended file lock exhaust the libuv thread pool and never
come back. The threshold is exactly UV_THREADPOOL_SIZE: with the default of 4, five
coroutines writing to one file with LOCK_EX are enough. Nothing is written — not even
by the coroutine that reports success — and the process ends without reaching the line
after awaitCompletion().

Reproduce

<?php

use Async\Scope;

use function Async\await;
use function Async\spawn;
use function Async\suspend;

$path = __DIR__ . '/contended.log';
@unlink($path);

await(spawn(static function () use ($path) {
    $scope = new Scope();

    for ($i = 0; $i < 5; $i++) {
        $scope->spawn(static function () use ($i, $path) {
            suspend();
            file_put_contents($path, "line{$i}\n", FILE_APPEND | LOCK_EX);
            echo "wrote {$i}\n";
        });
    }

    $scope->awaitCompletion(\Async\timeout(8000));
    echo "done\n";
}));
$ php repro.php
wrote 0
$ wc -c contended.log
0 contended.log

Observed: one line of output, an empty file, the script alive for the 8 s of the timeout
and then gone without printing done. Four coroutines instead of five: wrote 0 to
wrote 3, done, and four lines in the file.

Expected: five lines in the file and done, as with UV_THREADPOOL_SIZE=8.

The threshold is the pool size

Same script, varying the pool and the number of coroutines:

UV_THREADPOOL_SIZE 4 coroutines 5 8 16 17
4 (default) ok stalls stalls stalls stalls
8 ok ok ok stalls stalls
16 ok ok ok ok stalls

What narrows it down

Three variants of the same five-coroutine script:

  • each coroutine locking its own file with LOCK_EX: all finish;
  • LOCK_SH on one file, where no waiting happens: all finish;
  • explicit fopen() + flock($fp, LOCK_EX) on one file: stalls exactly like
    file_put_contents().

So it takes an actual wait for the lock, not the presence of a lock. flock() is
offloaded to the libuv pool and the waiting task holds its thread, which reads as pool
starvation: with every thread parked in flock(), the work that would release the lock
has no thread left to run on.

Why it is worth fixing rather than documenting

Two Laravel defaults hit it on any worker serving five requests at once.
Filesystem::put($path, $data, true) passes LOCK_EX, and both
FileSessionHandler::write() and FileStore::put() call it that way, so
SESSION_DRIVER=file and CACHE_STORE=file stop a worker. Found while building a load
stand for laravel-spawn: sixty concurrent requests, each appending one line to a log with
LOCK_EX from a terminating callback, and the worker served nothing after the fifth.

Raising UV_THREADPOOL_SIZE moves the threshold but does not remove it.

Environment

  • PHP 8.6.0-dev (cli) ZTS DEBUG, TrueAsync ABI v0.24.0, php-8.6.0-trueasync-0.9.2
  • Linux 6.6 (WSL2), 16 cores, ext4
  • No server extension loaded; Async\spawn alone reproduces it

Related: #146 (a use-after-free in the same offload path, cancellation rather than
contention) and #16, which added flock().

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions