NAME
Acme::Parataxis::Sync::Mutex - Non-reentrant mutex with true owner tracking
SYNOPSIS
use v5.40;
use Acme::Parataxis qw[async fiber];
use Acme::Parataxis::Sync::Mutex;
my $cache = {};
my $lock = Acme::Parataxis::Sync::Mutex->new;
async {
my @handles;
for my $id ( 1 .. 4 ) {
push @handles, fiber {
my $guard = $lock->guard; # released at the end of this block
$cache->{"job $id"} = 'done';
};
}
$_->await for @handles;
say 'cached ', scalar keys %$cache, ' entries';
};
# cached 4 entries
DESCRIPTION
A mutual-exclusion lock shared between fibers. The lock knows which fiber holds it, and that fiber is the only one allowed to release it. It is not reentrant, so a fiber that tries to take it twice is a mistake the lock reports rather than something it quietly allows.
use v5.40;
use Acme::Parataxis qw[async fiber];
use Acme::Parataxis::Sync::Mutex;
my $lock = Acme::Parataxis::Sync::Mutex->new;
async {
fiber {
$lock->lock;
say 'fiber ', Acme::Parataxis->current_fid, ' has the lock, ', $lock->waiters, ' queued behind it';
$lock->unlock;
};
};
# fiber 1 has the lock, 0 queued behind it
Everything is cooperative. A lock that finds the lock taken parks the calling fiber until the owner releases, and the release hands the lock directly to the longest-waiting fiber, so the queue is served in arrival order and nobody can cut in line. The hand-off is atomic - owner is set before the waiter is woken - so there is no instant at which the lock looks free and a third fiber can steal it.
A parked lock is an ordinary interruptible park, so a deadline bounds it and a cancellation aborts it. See "Bounding a wait".
METHODS
new( )
use v5.40;
use Acme::Parataxis::Sync::Mutex;
my $lock = Acme::Parataxis::Sync::Mutex->new;
say 'a fresh lock is free, with ', $lock->waiters, ' queued';
# a fresh lock is free, with 0 queued
Creates a free lock. Takes no arguments: an unknown key or a stray positional argument croaks with Unrecognized parameters for "Acme::Parataxis::Sync::Mutex" constructor.
lock( )
use v5.40;
use Acme::Parataxis qw[async];
use Acme::Parataxis::Sync::Mutex;
my $lock = Acme::Parataxis::Sync::Mutex->new;
async {
$lock->lock;
my $me = Acme::Parataxis->current_fid;
say 'this fiber holds it, and lock() returned true: ', ( $lock->owner == $me ? 'yes' : 'no' );
$lock->unlock;
};
# this fiber holds it, and lock() returned true: yes
Takes the lock, parking until it is handed over, and returns true. Croaks off a fiber, and croaks if the calling fiber already holds the lock.
try_lock( )
use v5.40;
use Acme::Parataxis qw[async fiber await_sleep];
use Acme::Parataxis::Sync::Mutex;
my $lock = Acme::Parataxis::Sync::Mutex->new;
async {
say 'on a free lock: ', ( $lock->try_lock ? 'taken' : 'refused' );
$lock->unlock;
my $holder = fiber { $lock->lock; await_sleep( 20 ); $lock->unlock };
await_sleep( 5 );
say 'on a held lock: ', ( $lock->try_lock ? 'taken' : 'refused' );
$holder->await;
};
# on a free lock: taken
# on a held lock: refused
Takes the lock only if it is free, returning true. Returns false without parking when another fiber holds it, which is what makes it the non-blocking half of lock - nothing is taken on the false path, so there is nothing to give back.
try_lock croaks rather than returning false when the calling fiber already holds the lock, exactly as lock does. To ask whether this fiber is the holder, compare owner yourself.
unlock( )
use v5.40;
use Acme::Parataxis qw[async fiber await_sleep];
use Acme::Parataxis::Sync::Mutex;
my $lock = Acme::Parataxis::Sync::Mutex->new;
async {
say 'releasing a free lock: ', ( eval { $lock->unlock; 'allowed' } || 'croaks' );
$lock->lock;
fiber {
await_sleep( 20 );
say 'releasing another fiber lock: ', ( eval { $lock->unlock; 'allowed' } || 'croaks' );
}->await;
$lock->unlock;
};
# releasing a free lock: croaks
# releasing another fiber lock: croaks
Releases the lock, handing it to the next queued fiber if there is one, and returns true. Croaks if the lock is not locked at all, and croaks if the calling fiber is not the owner.
guard( )
use v5.40;
use Acme::Parataxis qw[async];
use Acme::Parataxis::Sync::Mutex;
my $lock = Acme::Parataxis::Sync::Mutex->new;
async {
{
my $guard = $lock->guard; # takes the lock, parking if it has to
say 'inside the block it is held: ', ( defined $lock->owner ? 'yes' : 'no' );
}
say 'and released at the end of it: ', ( defined $lock->owner ? 'no' : 'yes' );
};
# inside the block it is held: yes
# and released at the end of it: yes
Takes the lock and returns an opaque guard object that releases it when the guard is destroyed. Returns as soon as the lock is held, so a guard waits exactly as lock does. Because the release is at destruction, an exception part-way through the block still gives the lock back.
The guard has no methods. It is not a lock and it does not expose the mutex it wraps; its only job is to run unlock at destruction, which makes it a plain scope handle and nothing more.
owner
use v5.40;
use Acme::Parataxis qw[async];
use Acme::Parataxis::Sync::Mutex;
my $lock = Acme::Parataxis::Sync::Mutex->new;
say 'before anything: ', ( defined $lock->owner ? 'held' : 'free' );
async {
$lock->lock;
say 'while held, by fiber: ', $lock->owner;
$lock->unlock;
};
# before anything: free
# while held, by fiber: 0
The id of the fiber holding the lock, or undef when the lock is free. It is a reader, so it works off a fiber as well as on one. The id is a plain integer, directly comparable to Acme::Parataxis->current_fid.
waiters
use v5.40;
use Acme::Parataxis qw[async fiber];
use Acme::Parataxis::Sync::Mutex;
my $lock = Acme::Parataxis::Sync::Mutex->new;
async {
$lock->lock;
my @handles;
for my $id ( 1 .. 3 ) { push @handles, fiber { $lock->lock; $lock->unlock } }
say 'queued: ', $lock->waiters;
$lock->unlock;
say 'after the release, queued: ', $lock->waiters;
say 'and the lock already belongs to fiber ', $lock->owner;
$_->await for @handles;
say 'once everyone is through: ', $lock->waiters;
};
# queued: 3
# after the release, queued: 2
# and the lock already belongs to fiber 1
# once everyone is through: 0
How many fibers are queued for the lock. Also a reader, so it works off a fiber.
The count is the queue, not the number of fibers still to be given the lock. unlock takes the next waiter off the queue as it hands over, so the count drops before that fiber has run - which is why the third line above can name a fiber that has not executed a single statement yet.
How It Works
Three fields and two methods carry the whole primitive. owner is the holding fiber's id or undef; @waiters is the queue of ids, oldest first. Taking the lock is either a plain assignment, or a push followed by a park that returns only once unlock has handed the lock over - which is why lock always returns true.
Releasing is the mirror image, and the release walks the queue until it finds an id that still belongs to a live fiber. That loop is what makes a waiter which died while parked harmless: its id is skipped instead of being handed a lock nobody will come back to collect. The two things that follow from it are both visible from the outside: the queue is served in arrival order, and a hand-off is never lost to a fiber that is no longer there.
use v5.40;
use Acme::Parataxis qw[async fiber await_sleep];
use Acme::Parataxis::Sync::Mutex;
my $lock = Acme::Parataxis::Sync::Mutex->new;
my $log = '';
async {
my $first = fiber {
$lock->lock;
await_sleep( 20 ); # held long enough for the others to queue behind it
$log .= 'A';
$lock->unlock;
};
my @rest;
for my $id ( 1 .. 3 ) {
push @rest, fiber {
$lock->lock;
$log .= "B$id";
$lock->unlock;
};
}
$first->await;
$_->await for @rest;
say 'the lock was held by, in order: ', $log;
};
# the lock was held by, in order: AB1B2B3
Recipes
Guarding a shared structure
The shape to reach for. Take the guard at the top of the block, do the work, and let the block give the lock back - so an exception part-way through cannot leave it held.
use v5.40;
use Acme::Parataxis qw[async fiber];
use Acme::Parataxis::Sync::Mutex;
my $cache = {};
my $lock = Acme::Parataxis::Sync::Mutex->new;
my $reads = 0;
async {
my @handles;
for my $id ( 1 .. 6 ) {
push @handles, fiber {
my $guard = $lock->guard;
$cache->{"job $id"} = { worker => Acme::Parataxis->current_fid, ticket => $reads++ };
};
}
$_->await for @handles;
say 'cached ', scalar keys %$cache, ' entries, taken in ticket order';
};
# cached 6 entries, taken in ticket order
Excluding a fiber that arrives late
try_lock never parks, so it is the way to give a fiber a single attempt and send it on its way if the lock is busy.
use v5.40;
use Acme::Parataxis qw[async fiber await_sleep];
use Acme::Parataxis::Sync::Mutex;
my $lock = Acme::Parataxis::Sync::Mutex->new;
my $log = '';
async {
my $first = fiber { $lock->lock; await_sleep( 20 ); $lock->unlock };
await_sleep( 5 ); # the first fiber has the lock by now
$log .= $lock->try_lock ? 'took it' : 'skipped';
$first->await;
$log .= $lock->try_lock ? 'took it' : 'skipped';
$lock->unlock;
say 'the late arrival: ', $log;
};
# the late arrival: skippedtook it
Bounding a wait
A parked lock is an ordinary park, so with_timeout( $ms, ... ) bounds it. Catching the timeout is the point, because an uncaught one takes the run down. After a catch the mutex is exactly as it was: the timed-out fiber was taken off the queue on its way out, so a retry starts from a clean queue and a measured 50ms budget fires in about 50ms.
use v5.40;
use Acme::Parataxis qw[async fiber await_sleep];
use Acme::Parataxis::Sync::Mutex;
my $lock = Acme::Parataxis::Sync::Mutex->new;
async {
$lock->lock; # this fiber now holds it
fiber {
my $got = eval { Acme::Parataxis::with_timeout( 50, sub { $lock->lock } ) };
say 'the waiter gave up: ', ( $@ ? 'yes' : 'no' );
say 'the lock is still held: ', ( defined $lock->owner ? 'yes' : 'no' );
say 'and nobody is queued: ', $lock->waiters;
};
await_sleep( 500 );
};
# the waiter gave up: yes
# the lock is still held: yes
# and nobody is queued: 0
with_timeout runs its block on a child fiber, and the deadline interrupts whatever that child is parked in. The equivalent with a token is Acme::Parataxis::with_cancel( sub ($token) { ... } ), which raises Acme::Parataxis::Error::Cancelled in the parked fiber instead. Cancelling a token that a waiting fiber published is also how you take a waiter out of the queue deliberately.
Behavior Notes
A lock outlives the fiber that took it
Nothing releases a lock on behalf of a fiber that dies holding it. A fiber that ends inside a critical section leaves the lock held, and the next fiber to ask parks on a lock nobody is ever going to release.
use v5.40;
use Acme::Parataxis qw[async fiber];
use Acme::Parataxis::Sync::Mutex;
my $lock = Acme::Parataxis::Sync::Mutex->new;
async {
fiber { $lock->lock }; # takes the lock, then finishes without releasing
fiber {
my $got = eval { Acme::Parataxis::with_timeout( 50, sub { $lock->lock } ) };
say 'a later attempt: ', ( $@ ? 'gave up' : 'got it' );
say 'and the lock is stranded: ', ( defined $lock->owner ? 'still held' : 'free' );
};
};
# a later attempt: gave up
# and the lock is stranded: still held
There is no API to take a lock back, so the recovery is to stop using that primitive. The habit that prevents it is the guard in "Guarding a shared structure": held in a lexical, taken at the top of the body, released by scope exit whatever happens. That covers an exception, and it covers a fiber that returns early. It does not cover a fiber that is torn down without unwinding.
Fiber ids are recycled the moment a fiber finishes, and that is what turns this failure into a confusing one. A fiber that takes a lock and then dies frees its id, so a later fiber can be handed the same number - and is then told it already holds the lock, which is a reentrancy error rather than the real problem.
A guard must not outlive its fiber
A guard releases the lock from whichever fiber destroys it, and every release checks that the releasing fiber is the one that took it. A guard handed out of its fiber therefore cannot release: the release croaks, the croak is swallowed along with the handle that carried the guard, and the lock is stranded. The only symptom is a lock that is permanently busy.
use v5.40;
use Acme::Parataxis qw[async fiber await_sleep];
use Acme::Parataxis::Sync::Mutex;
my $lock = Acme::Parataxis::Sync::Mutex->new;
async {
my $handle = fiber { $lock->guard }; # taken in the fiber that made it
await_sleep( 10 );
say 'the lock is held: ', ( defined $lock->owner ? 'yes' : 'no' );
}; # $handle is destroyed here, in the wrong fiber
say 'and after the run it is still: ', ( defined $lock->owner ? 'held' : 'free' );
# the lock is held: yes
# and after the run it is still: held
Keep the guard in the fiber that acquired the lock. If the work has to be handed to another fiber, hand over the lock explicitly, from inside the acquiring fiber, rather than the guard.
Dropping a waiter's handle does not dequeue it
A parked waiter stays in the queue even if every reference to its fiber handle goes away. The scheduler holds the fiber itself, so letting go of the handle changes nothing: the fiber is still parked, still queued, and still next in line for the lock.
use v5.40;
use Acme::Parataxis qw[async fiber await_sleep];
use Acme::Parataxis::Sync::Mutex;
my $lock = Acme::Parataxis::Sync::Mutex->new;
async {
$lock->lock;
my $handle = fiber { $lock->lock; $lock->unlock };
await_sleep( 10 );
say 'queued: ', $lock->waiters;
undef $handle; # the handle is gone; the waiter is not
say 'still queued: ', $lock->waiters;
$lock->unlock; # and it is still the one that gets the lock
say 'handed to fiber ', $lock->owner;
};
# queued: 1
# still queued: 1
# handed to fiber 1
To take a waiter out of the queue, interrupt it: a with_timeout deadline or a cancelled token, as in "Bounding a wait". An interrupted waiter has already removed itself by the time a release reaches it, which is why the release can afford to skip ids whose fibers are no longer live.
SEE ALSO
Acme::Parataxis::Sync, Acme::Parataxis::Sync::RwLock, Acme::Parataxis::Sync::WaitGroup, Acme::Parataxis::Sync::Barrier, Acme::Parataxis::Sync::Once, Acme::Parataxis::Error::Timeout, Acme::Parataxis::Error::Cancelled
AUTHOR
Sanko Robinson https://github.com/sanko
LICENSE
Copyright (C) Sanko Robinson.
This library is free software; you can redistribute it and/or modify it under the terms found in the Artistic License 2.