Search by

lav45 / amp-rwmutex

LAV45

Keyed read-write mutexes for AMPHP: local, file and Redis

Package info

github.com/lav45/amp-rwmutex

pkg:composer/lav45/amp-rwmutex

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-11-08 19:10 UTC

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 LocalKeyedRWMutex and FileKeyedRWMutex; RedisKeyedRWMutex throws RedisMutexException once the lock timeout passes.
  • A read lock taken again while holding a read lock works, but do not rely on it. In LocalKeyedRWMutex and RedisKeyedRWMutex it deadlocks if a writer starts waiting between the two reads (Redis: RedisMutexException after the timeout). FileKeyedRWMutex always 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 (3 s by default) — a lock of a crashed or stalled process expires after this time;
  • renew interval (1 s by default) — while the process holds locks, it extends them on this interval;
  • lock timeout (10 s by default) — acquireRead() and acquireWrite() throw Amp\Redis\Sync\RedisMutexException if the lock is not granted within this time. If Redis is unreachable, acquiring throws Amp\Redis\RedisException (RedisMutexException extends 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 as local.php (REDIS_URI, redis://localhost:6379 by 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.