Flockit (flockit v1.0.0)

Copy Markdown View Source

Advisory file locks using flock(2).

{:ok, lock} = Flockit.lock("/tmp/my.lock")
# ... critical section ...
:ok = Flockit.unlock(lock)

or, equivalently:

{:ok, result} = Flockit.with_lock("/tmp/my.lock", fn -> critical_section() end)

The lock file is created if it does not exist, and is never written to or removed. Locks are advisory: they only exclude other flock(2) users, in this VM or in other OS processes.

Lifetime

A lock is released by whichever comes first:

  • unlock/1, which may be called from any process;
  • the exit of the process that acquired it, for any reason;
  • garbage collection of the last reference to the lock.

Rely on unlock/1 rather than garbage collection for prompt release.

Each call is a separate lock

flock(2) locks belong to an open file, and every call here opens the file afresh. Two calls from the same process therefore conflict just like calls from different processes: a second exclusive lock/2 on a path the caller already holds waits for the caller itself.

Waiting

lock/2 never blocks a thread in flock(2). It makes non-blocking attempts, and between them waits in the calling process for the kernel to report that the file may have been unlocked:

  • on macOS, a kqueue NOTE_FUNLOCK event, raised by every unlock, including when the holder closes the file or exits;
  • on Linux, an inotify close event, raised when the holder closes the file or exits.

Attempts are also retried on a timer, backing off to at most :max_poll_interval milliseconds (default 250). That covers releases the kernel does not report: on Linux, another program unlocking without closing the file, and anywhere, holders on other hosts of a network filesystem. On other platforms the timer is all there is.

A caller that times out or exits simply stops waiting; nothing is left behind.

Release notifications are pumped by a process in the :flockit application, which also closes the files of locks released by an owner exiting or by garbage collection, off the normal schedulers. It starts automatically when :flockit is a dependency. Without it, locks still work, but waiters rely on timed retries and such files are closed during later calls into the library.

config :flockit, max_poll_interval: 250

Platform notes

Tested on Linux and macOS. Other Unix systems should work, with timed retries in place of release notifications, but are untested. Only regular files can be locked: directories give {:error, :eisdir} and other special files {:error, :einval}. On NFS, flock(2) is emulated with byte-range locks, so exclusive locks need write access to the lock file.

Hot code upgrades of this library are not supported: upgrading it requires a VM restart.

Summary

Types

:exclusive for a write lock (the default), :shared for a read lock.

t()

A lock acquired by lock/2 or try_lock/2.

Functions

Acquires a lock on path, waiting until it is available.

Acquires a lock on path only if it is available right now.

Releases lock. Releasing a lock that is already released is a no-op.

Runs fun while holding a lock on path, and releases it afterwards, even if fun raises. Takes the same options as lock/2.

Types

lock_option()

@type lock_option() :: {:mode, mode()} | {:timeout, timeout()}

mode()

@type mode() :: :exclusive | :shared

:exclusive for a write lock (the default), :shared for a read lock.

t()

@opaque t()

A lock acquired by lock/2 or try_lock/2.

Functions

lock(path, opts \\ [])

@spec lock(Path.t(), [lock_option()]) ::
  {:ok, t()} | {:error, :timeout | File.posix()}

Acquires a lock on path, waiting until it is available.

Options

  • :mode - :exclusive (default) or :shared.
  • :timeout - how long to wait, in milliseconds, or :infinity (default). Returns {:error, :timeout} when it expires; a lock is never returned after that. 0 makes a single attempt, like try_lock/2 but with {:error, :timeout} for a busy lock.

Other errors are POSIX reasons from opening or locking the file, such as :enoent when the parent directory does not exist.

try_lock(path, opts \\ [])

@spec try_lock(Path.t(), [{:mode, mode()}]) :: {:ok, t()} | {:error, File.posix()}

Acquires a lock on path only if it is available right now.

Returns {:error, :eagain} if a conflicting lock is held. Accepts the :mode option of lock/2.

unlock(lock)

@spec unlock(t()) :: :ok

Releases lock. Releasing a lock that is already released is a no-op.

with_lock(path, opts \\ [], fun)

@spec with_lock(Path.t(), [lock_option()], (-> result)) ::
  {:ok, result} | {:error, :timeout | File.posix()}
when result: var

Runs fun while holding a lock on path, and releases it afterwards, even if fun raises. Takes the same options as lock/2.