lav45 / amp-rwmutex
Keyed read-write mutexes for AMPHP: local, file and Redis
Requires
- php: >=8.2
- amphp/amp: ^3.1
- amphp/sync: ^2.3
- psr/log: ^1 || ^2 || ^3
- revolt/event-loop: ^1.0
Requires (Dev)
- amphp/redis: ^2.0
- bamarni/composer-bin-plugin: ^1.8
- roave/security-advisories: dev-latest
Suggests
- amphp/redis: Required for RedisKeyedRWMutex, a read-write mutex shared across processes and hosts
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-08 19:25:16 UTC
README
AMPHP is a collection of event-driven libraries for PHP designed with fibers and concurrency in mind.
lav45/amp-rwmutex adds keyed read-write mutexes on top of amphp/sync: any number of
readers may hold a key at once, a writer holds it alone. Each key is locked independently, so one mutex instance guards
a whole set of resources — users, files, cache entries — without creating a mutex per resource.
Installation
This package can be installed as a Composer dependency.
composer require lav45/amp-rwmutex
RedisKeyedRWMutex additionally requires amphp/redis:
composer require amphp/redis
Requirements
- PHP 8.2+
Usage
Every implementation follows the KeyedRWMutex interface. Both methods suspend the current fiber until the lock is
granted and return an Amp\Sync\Lock; call release() when done, best in finally.
Locks are not reentrant. Three cases:
- A write lock taken again, or a read lock taken while holding the write lock of the same key in the same fiber, waits
for itself: forever in
LocalKeyedRWMutexandFileKeyedRWMutex;RedisKeyedRWMutexthrowsRedisMutexExceptiononce the lock timeout passes. - A read lock taken again while holding a read lock works, but do not rely on it. In
LocalKeyedRWMutexandRedisKeyedRWMutexit deadlocks if a writer starts waiting between the two reads (Redis:RedisMutexExceptionafter the timeout).FileKeyedRWMutexalways grants it, because it has no writer priority. - There is no upgrade from reader to writer: acquiring the write lock while holding the read lock of the same key waits for itself, with the same outcome per implementation as above. Release the read lock first and accept that another writer may get in between.
interface KeyedRWMutex { public function acquireRead(string $key): Lock; public function acquireWrite(string $key): Lock; }
use Lav45\Amp\Sync\LocalKeyedRWMutex; $mutex = new LocalKeyedRWMutex(); $lock = $mutex->acquireWrite('user:42'); try { // only this fiber holds 'user:42'; readers and writers of other keys are not affected } finally { $lock->release(); }
Implementations
| Class | Scope | Writer priority | When a writer releases | Backend |
|---|---|---|---|---|
LocalKeyedRWMutex |
Fibers of one process | Yes | All waiting readers first | Memory |
FileKeyedRWMutex |
Processes of one host | No | Whoever polls first | flock() on files |
RedisKeyedRWMutex |
Processes on any number of hosts | Yes | Next writer in the FIFO queue | Redis (amphp/redis) |
Writer priority means that once a writer is waiting, new readers wait behind it, so a steady flow of readers cannot starve writers.
LocalKeyedRWMutex
Keeps state in memory and shares it between fibers of the current process. When a writer releases a key, all readers waiting for it are admitted before the next writer. State of a key is removed once nobody holds or waits for it.
FileKeyedRWMutex
use Lav45\Amp\Sync\FileKeyedRWMutex; $mutex = new FileKeyedRWMutex('/var/run/my-app/locks');
Locks <directory>/<sha256(key)>.lock with flock(), so a key of any length or characters maps to a safe file name and
a trailing slash of the directory is ignored. The directory must exist; otherwise acquiring throws
Amp\Sync\SyncException, as it does when a lock file cannot be opened (the message carries the PHP error) or flock()
fails for a reason other than the lock being held. A $retryInterval of 0 or less makes the constructor throw
\Error. Lock files are not deleted after release. flock() has no queue, so the mutex polls every $retryInterval
seconds (0.001 by default) and gives neither writer priority nor fairness. flock() is advisory and may not work on
network file systems.
RedisKeyedRWMutex
use Amp\Redis\Sync\RedisMutexOptions; use Lav45\Amp\Sync\RedisKeyedRWMutex; use function Amp\Redis\createRedisClient; $options = (new RedisMutexOptions()) ->withKeyPrefix('my-app:') ->withLockTimeout(30); $mutex = new RedisKeyedRWMutex( redis: createRedisClient('redis://localhost:6379'), options: $options, );
Takes the same RedisMutexOptions as Amp\Redis\Sync\RedisMutex:
- lock expiration (
3s by default) — a lock of a crashed or stalled process expires after this time; - renew interval (
1s by default) — while the process holds locks, it extends them on this interval; - lock timeout (
10s by default) —acquireRead()andacquireWrite()throwAmp\Redis\Sync\RedisMutexExceptionif the lock is not granted within this time. If Redis is unreachable, acquiring throwsAmp\Redis\RedisException(RedisMutexExceptionextends it, so catching it covers both); - key prefix — prepended to the Redis keys listed below.
Readers are kept in a sorted set with a deadline per reader, so a crashed reader expires on its own without blocking writers. Writers wait in a FIFO list, and a released writer hands the key to the next one in that list. The optional third argument is a PSR-3 logger; it receives a warning when a lock had already expired by the time it was released and an error when a release or renew request to Redis fails.
Redis keys
For a lock key <key> the mutex uses three Redis keys, each with its own expiration:
| Redis key | Type | Holds | Expires after |
|---|---|---|---|
<prefix>rwlock:readers:<key> |
sorted set | reader tokens, scored by their deadline | lock expiration, extended while a reader is held |
<prefix>rwlock:writer:<key> |
string | token of the writer that holds the key | lock expiration, extended while the writer is held |
<prefix>rwlock:queue:<key> |
list | tokens of the writers waiting, in FIFO order | lock expiration + lock timeout, set on each wait |
The layout changes only in a major version, so other processes and tools may rely on it.
Use a single Redis instance. Redis Cluster is not supported: the three keys of one lock have no hash tag, so they land
in different slots and the acquire scripts already fail with CROSSSLOT. The renew request fails the same way, because
it extends the keys of all locks of the process in one EVAL. Amp\Redis\Sync\RedisMutex has the same limitation.
amphp/redis has no Sentinel client, and Redis replication is asynchronous. With Sentinel or replicas, connect to the
current master and expect that a failover can drop held locks: two processes may then hold the same key.
Examples
The examples directory runs the same case for each implementation: two readers, then a writer, then a
third reader on one key:
local.php— readers run together, the writer waits for them and blocks the third reader;file.php— no writer priority: the third reader joins the first two, the writer waits for all three;redis.php— the same order aslocal.php(REDIS_URI,redis://localhost:6379by default).
php examples/local.php
Development
Tools run in Docker, the host needs no PHP.
./build.sh # build the amp-rwmutex:tool image ./composer install ./tests/Unit/start.sh # unit tests: LocalKeyedRWMutex, FileKeyedRWMutex, # RedisKeyedRWMutex on Redis failures without a server ./tests/Functional/start.sh # Functional suite: RedisKeyedRWMutex against Redis in Docker, # FileKeyedRWMutex across two processes, the examples ./composer cs:fix ./composer phpstan ./composer mutation # Infection on the unit tests, reports in .cache/infection ./composer check # composer-require-checker ./composer unused # composer-unused
Versioning
lav45/amp-rwmutex follows the semver semantic versioning specification.
License
The MIT License (MIT). Please see LICENSE for more information.