FastGlobalLock (fast_global_lock v0.1.1)

View Source

FastGlobalLock is a library that builds on top of :global to minimize the time between locks under contention and to provide a best-effort FIFO locking mechanism.

Key differences from :global

Because FastGlobalLock builds on :global, its lock semantics are similar. There are several key differences to be aware of:

:globalFastGlobalLock
Relies solely on polling with an increasing, random sleep of up to 8 seconds.Uses inter-process communication to minimize time between locks.
Which process acquires a lock under contention is fully random.Orders pending locks and wakes waiting processes in FIFO order.
Multiple processes can acquire the same lock if they use the same LockRequesterId.Only one process can hold a lock for a key on any given ndoe.
Acquires at most one lock no matter how many set_lock/2 calls are made.Supports lock nesting in its native API.
Can release the lock only on selected nodes.Always releases the lock everywhere it was acquired.
Can extend the lock to more nodes.If a lock for a key is already held, it can only be nested on the same node set.
Takes a Retries argument and sleeps for a random time between attempts.Takes a millisecond timeout.
The process calling set_lock/2 is monitored for liveness.A separate linked process is spawned per lock/2 and monitored for liveness.

Important notes

Increased CPU usage compared with :global

FastGlobalLock shortens the wall-clock time between locks, but it achieves this by using more CPU time. A coordination layer on top of :global inevitably adds some processing overhead.

One particularly bad CPU scenario occurs when there is heavy contention for a key, with some processes locking on a single node while others lock cluster-wide. To determine if and where the lock is already present, FastGlobalLock probes the :nodes one at a time. It continues this loop without sleeping until it either acquires the lock or discovers that the key is locked elsewhere.

This design is efficient when all requests target roughly the same set of nodes. However, if one process tries to lock 100 nodes while the lock is already held on just one of them, merely locating the lock can become expensive.

Concurrent locks for disjoint node sets

Multiple concurrent locks (by different processes) can be acquired for the same key if they lock on disjoint :nodes sets. If processes attempt to acquire locks for :nodes spanning multiple existing locks, the fairness mechanism is likely to be disturbed. The fast locking mechanism still works in this scenario.

Note that multiple concurrent locks for the same key cannot be acquired by the same process. FastGlobalLock will either nest the existing lock, or raise, depending on the :on_nodes_mismatch setting. See lock/2 for more information.

Correctness

  • FastGlobalLock inherits most of its correctness guarantees from :global.
  • If any mechanism fails, it falls back to polling :global.set_lock/3.
  • While the lock-holder GenServer process is yet to acquire the lock, every callback either returns a timeout value or terminates the server altogether.
  • Should a bug in FastGlobalLock cause the lock-holder to crash, the lock is released automatically by :global. The process that requested the lock will receive an exit signal through their link.

Installation

The package can be installed by adding fast_global_lock to your list of dependencies in mix.exs:

def deps do
  [
    {:fast_global_lock, "~> 0.1.1"}
  ]
end

Summary

Types

See lock/2 for options' description.

Functions

Sets a :global lock on key for the current process.

Same as lock/2, but raises FastGlobalLock.LockTimeoutError if the lock is not acquired within the timeout.

Decrements the lock-count for key.

Acquires a lock on key and calls fun while the lock is held. The lock is released after fun finishes, whether it returns normally or with an exception/signal.

Same as with_lock/3, but raises FastGlobalLock.LockTimeoutError if the lock is not acquired within the timeout.

Types

options()

@type options() :: [
  timeout: timeout(),
  nodes: [node()],
  on_nodes_mismatch: :ignore | :raise | :raise_if_minority_overlap
]

See lock/2 for options' description.

Functions

lock(key, timeout_or_options \\ [])

@spec lock(key :: any(), timeout() | options()) :: boolean()

Sets a :global lock on key for the current process.

This function is synchronous and will either return true if the lock was acquired, or false if the lock was not acquired within the timeout.

As with :global.set_lock/3, if a process that holds a lock dies, or the node goes down, the locks held by the process are released.

If the lock is already held by the current process, lock/2 will return true and increment the lock-count. While the current process is alive, it must call unlock/2 the same number of times to release the lock.

Important

If a key is already locked by this process, FastGlobalLock will not allow another lock to be acquired, even if given a different set of nodes. See :on_nodes_mismatch option.

Options

  • :timeout - a millisecond timeout after which the operation will fail. Defaults to :infinity

  • :nodes - nodes to lock on. Defaults to Node.list([:this, :visible]).

  • :on_nodes_mismatch - what to do if the lock is already held by this process on a different set of nodes:

    Example

    # Acquire the first lock
    FastGlobalLock.lock(:foo, nodes: [:a, :b])
    
    # Raises an error because of the nodes mismatch
    # This would also happen if we didn't provide the `:nodes` option and the cluster membership
    # changed between the first and second lock
    FastGlobalLock.lock(:foo, nodes: [:a, :b, :c], on_nodes_mismatch: :raise)
    
    # Won't fail as there's an overlap [:a] node with the existing lock's `nodes: [:a, :b]`
    FastGlobalLock.lock(:foo, nodes: [:a, :c], on_nodes_mismatch: :raise_if_disjoint)
    
    # Raises an error because the nodes are disjoint with the existing lock's `nodes: [:a, :b]`
    FastGlobalLock.lock(:foo, nodes: [:c, :d], on_nodes_mismatch: :raise_if_disjoint)
    
    # Won't fail no matter what the requested `:nodes` are
    FastGlobalLock.lock(:foo, nodes: [:c, :d], on_nodes_mismatch: :ignore)

lock!(key, timeout_or_options \\ [])

@spec lock!(key :: any(), timeout() | options()) :: true | no_return()

Same as lock/2, but raises FastGlobalLock.LockTimeoutError if the lock is not acquired within the timeout.

unlock(key)

@spec unlock(key :: any()) :: boolean()

Decrements the lock-count for key.

If the lock-count is decremented to 0, the lock is synchronously released.

Returns true if the lock-count was decremented, false if the lock is not held by the current process.

with_lock(key, timeout_or_options \\ [], fun)

@spec with_lock(key :: any(), timeout() | options(), (-> result)) ::
  {:ok, result} | {:error, :lock_timeout}
when result: any()

Acquires a lock on key and calls fun while the lock is held. The lock is released after fun finishes, whether it returns normally or with an exception/signal.

Returns {:ok, result} if the lock was acquired and fun returned result, or {:error, :lock_timeout} if the lock was not acquired within the timeout.

See lock/2 for more details on options taken by this function.

with_lock!(key, timeout_or_options \\ [], fun)

@spec with_lock!(key :: any(), timeout() | options(), (-> result)) ::
  result | no_return()
when result: any()

Same as with_lock/3, but raises FastGlobalLock.LockTimeoutError if the lock is not acquired within the timeout.