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().
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, fivecoroutines writing to one file with
LOCK_EXare enough. Nothing is written — not evenby the coroutine that reports success — and the process ends without reaching the line
after
awaitCompletion().Reproduce
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 0towrote 3,done, and four lines in the file.Expected: five lines in the file and
done, as withUV_THREADPOOL_SIZE=8.The threshold is the pool size
Same script, varying the pool and the number of coroutines:
UV_THREADPOOL_SIZEWhat narrows it down
Three variants of the same five-coroutine script:
LOCK_EX: all finish;LOCK_SHon one file, where no waiting happens: all finish;fopen()+flock($fp, LOCK_EX)on one file: stalls exactly likefile_put_contents().So it takes an actual wait for the lock, not the presence of a lock.
flock()isoffloaded 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 lockhas 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)passesLOCK_EX, and bothFileSessionHandler::write()andFileStore::put()call it that way, soSESSION_DRIVER=fileandCACHE_STORE=filestop a worker. Found while building a loadstand for laravel-spawn: sixty concurrent requests, each appending one line to a log with
LOCK_EXfrom a terminating callback, and the worker served nothing after the fifth.Raising
UV_THREADPOOL_SIZEmoves the threshold but does not remove it.Environment
php-8.6.0-trueasync-0.9.2Async\spawnalone reproduces itRelated: #146 (a use-after-free in the same offload path, cancellation rather than
contention) and #16, which added
flock().