Skip to content

Instantly share code, notes, and snippets.

@milseman
Last active March 12, 2023 15:52
Show Gist options
  • Save milseman/fbf1ec12ac9a6635d830d9457fec0776 to your computer and use it in GitHub Desktop.
Save milseman/fbf1ec12ac9a6635d830d9457fec0776 to your computer and use it in GitHub Desktop.
File Locks

File Locks

Introduction

File locks are a system-provided means for applications and libraries to cooperatively prevent erroneous access to files. File locks are advisory, i.e. they are not checked by read or write calls. They are commonly used, for example, in the implementation of databases.

We propose adding API to System for Open File Description Locks ("OFD locks"). OFD locks are associated with open calls rather than an entire process.

The following API is proposed for Linux and Darwin platforms only. Windows does not support OFD locks, see "Alternatives Considered" below.

Detailed description

We define byte-range-taking lock() and unlock() operations. lock() returns whether the lock requested was actually aquired.

The lock(wait:) overload will (if passed true) wait until the lock can be aquired, and thus is annotated with @discardableResult.

Darwin platforms support an additional lock(waitUntilTimeout:) operation, which will wait until either the lock is aquired or a system-defined timeout occurs.

#if !os(Windows)
extension FileDescriptor {
  /// Set an advisory open file description lock.
  ///
  /// If the open file description already has a lock over `byteRange`, that
  /// portion of the old lock is replaced. If `byteRange` is `nil`, the
  /// entire file is considered. If the lock cannot be set because it is
  /// blocked by an existing lock, that is if the syscall would throw
  /// `.resourceTemporarilyUnavailable`(aka `EAGAIN`), this will return
  /// `false`.
  ///
  /// Open file description locks are associated with an open file
  /// description (see `FileDescriptor.open`). Duplicated
  /// file descriptors (see `FileDescriptor.duplicate`) share open file
  /// description locks.
  ///
  /// Locks are advisory, which allow cooperating code to perform
  /// consistent operations on files, but do not guarantee consistency.
  /// (i.e. other code may still access files without using advisory locks
  /// possibly resulting in inconsistencies).
  ///
  /// Open file description locks are inherited by child processes across
  /// `fork`, etc.
  ///
  /// Passing a lock kind of `.none` will remove a lock (equivalent to calling
  /// `FileDescriptor.unlock()`).
  ///
  /// - Parameters:
  ///   - kind: The kind of lock to set
  ///   - byteRange: The range of bytes over which to lock. Pass
  ///     `nil` to consider the entire file.
  ///   - retryOnInterrupt: Whether to retry the operation if it throws
  ///     ``Errno/interrupted``. The default is `true`. Pass `false` to try
  ///     only once and throw an error upon interruption.
  /// - Returns: `true` if the lock was aquired, `false` otherwise
  ///
  /// The corresponding C function is `fcntl` with `F_OFD_SETLK`.
  @_alwaysEmitIntoClient
  public func lock(
    _ kind: FileDescriptor.FileLock.Kind = .read,
    byteRange: (some RangeExpression<Int64>)? = Range?.none,
    retryOnInterrupt: Bool = true
  ) throws -> Bool

  /// Set an advisory open file description lock.
  ///
  /// If the open file description already has a lock over `byteRange`, that
  /// portion of the old lock is replaced. If `byteRange` is `nil`, the
  /// entire file is considered. If the lock cannot be set because it is
  /// blocked by an existing lock and `wait` is true, this will wait until
  /// the lock can be set, otherwise returns `false`.
  ///
  /// Open file description locks are associated with an open file
  /// description (see `FileDescriptor.open`). Duplicated
  /// file descriptors (see `FileDescriptor.duplicate`) share open file
  /// description locks.
  ///
  /// Locks are advisory, which allow cooperating code to perform
  /// consistent operations on files, but do not guarantee consistency.
  /// (i.e. other code may still access files without using advisory locks
  /// possibly resulting in inconsistencies).
  ///
  /// Open file description locks are inherited by child processes across
  /// `fork`, etc.
  ///
  /// Passing a lock kind of `.none` will remove a lock (equivalent to calling
  /// `FileDescriptor.unlock()`).
  ///
  /// - Parameters:
  ///   - kind: The kind of lock to set
  ///   - byteRange: The range of bytes over which to lock. Pass
  ///     `nil` to consider the entire file.
  ///   - wait: if `true` will wait until the lock can be set
  ///   - retryOnInterrupt: Whether to retry the operation if it throws
  ///     ``Errno/interrupted``. The default is `true`. Pass `false` to try
  ///     only once and throw an error upon interruption.
  ///
  /// The corresponding C function is `fcntl` with `F_OFD_SETLK` or `F_OFD_SETLKW`.
  @discardableResult
  public func lock(
    _ kind: FileDescriptor.FileLock.Kind = .read,
    byteRange: (some RangeExpression<Int64>)? = Range?.none,
    wait: Bool,
    retryOnInterrupt: Bool = true
  )

#if !os(Linux)
  /// Set an advisory open file description lock.
  ///
  /// If the open file description already has a lock over `byteRange`, that
  /// portion of the old lock is replaced. If `byteRange` is `nil`, the
  /// entire file is considered. If the lock cannot be set because it is
  /// blocked by an existing lock and `waitUntilTimeout` is true, this will
  /// wait until the lock can be set(or the operating system's timeout
  /// expires), otherwise returns `false`.
  ///
  /// Open file description locks are associated with an open file
  /// description (see `FileDescriptor.open`). Duplicated
  /// file descriptors (see `FileDescriptor.duplicate`) share open file
  /// description locks.
  ///
  /// Locks are advisory, which allow cooperating code to perform
  /// consistent operations on files, but do not guarantee consistency.
  /// (i.e. other code may still access files without using advisory locks
  /// possibly resulting in inconsistencies).
  ///
  /// Open file description locks are inherited by child processes across
  /// `fork`, etc.
  ///
  /// Passing a lock kind of `.none` will remove a lock (equivalent to calling
  /// `FileDescriptor.unlock()`).
  ///
  /// - Parameters:
  ///   - kind: The kind of lock to set
  ///   - byteRange: The range of bytes over which to lock. Pass
  ///     `nil` to consider the entire file.
  ///   - waitUntilTimeout: if `true` will wait until the lock can be set or a timeout expires
  ///   - retryOnInterrupt: Whether to retry the operation if it throws
  ///     ``Errno/interrupted``. The default is `true`. Pass `false` to try
  ///     only once and throw an error upon interruption.
  ///
  /// The corresponding C function is `fcntl` with `F_OFD_SETLK` or `F_SETLKWTIMEOUT`.
  public func lock(
    _ kind: FileDescriptor.FileLock.Kind = .read,
    byteRange: (some RangeExpression<Int64>)? = Range?.none,
    waitUntilTimeout: Bool,
    retryOnInterrupt: Bool = true
  ) throws -> Bool
#endif

  /// Remove an open file description lock.
  ///
  /// Open file description locks are associated with an open file
  /// description (see `FileDescriptor.open`). Duplicated
  /// file descriptors (see `FileDescriptor.duplicate`) share open file
  /// description locks.
  ///
  /// Locks are advisory, which allow cooperating code to perform
  /// consistent operations on files, but do not guarantee consistency.
  /// (i.e. other code may still access files without using advisory locks
  /// possibly resulting in inconsistencies).
  ///
  /// Open file description locks are inherited by child processes across
  /// `fork`, etc.
  ///
  /// Calling `unlock()` is equivalent to passing `.none` as the lock kind to
  /// `FileDescriptor.lock()`.
  ///
  /// - Parameters:
  ///   - byteRange: The range of bytes over which to lock. Pass
  ///     `nil` to consider the entire file.
  ///   - retryOnInterrupt: Whether to retry the operation if it throws
  ///     ``Errno/interrupted``. The default is `true`. Pass `false` to try
  ///     only once and throw an error upon interruption.
  ///
  /// The corresponding C function is `fcntl` with `F_OFD_SETLK` or
  /// `F_OFD_SETLKW` and a lock type of `F_UNLCK`.
  @_alwaysEmitIntoClient
  public func unlock(
    byteRange: (some RangeExpression<Int64>)? = Range?.none,
    retryOnInterrupt: Bool = true
  ) throws
}
#endif // !os(Windows)

We define a raw-valued flock wrapper. Further fcntl-style API is future work.

#if !os(Windows)
extension FileDescriptor {
  /// Advisory record locks.
  ///
  /// The corresponding C type is `struct flock`.
  @frozen
  public struct FileLock: RawRepresentable, Sendable {
    public var rawValue: CInterop.FileLock

    public init(rawValue: CInterop.FileLock)

    public init()
  
    /// The type of the locking operation.
    ///
    /// The corresponding C field is `l_type`.
    public var type: Kind { get set }
  
    /// The origin of the locked region.
    ///
    /// The corresponding C field is `l_whence`.
    public var origin: FileDescriptor.SeekOrigin { get set }
  
    /// The start offset (from the origin) of the locked region.
    ///
    /// The corresponding C field is `l_start`.
    public var start: Int64 { get set }
  
    /// The number of consecutive bytes to lock.
    ///
    /// The corresponding C field is `l_len`.
    public var length: Int64 { get set }
  
    /// The process ID of the lock holder (if applicable).
    ///
    /// The corresponding C field is `l_pid`
    public var pid: ProcessID { get set }
  }    
}
#endif // !os(Windows)

We define an enum-like raw wrapper for the kinds of file locks.

#if !os(Windows)
extension FileDescriptor.FileLock {
  /// The kind or type of a lock: read (aka "shared"), write (aka "exclusive"), or none
  /// (aka "unlock").
  @frozen
  public struct Kind: RawRepresentable, Hashable, Sendable {
    public var rawValue: CInterop.CShort

    public init(rawValue: CInterop.CShort)

    /// Read lock (aka "shared")
    ///
    /// The corresponding C constant is `F_RDLCK`.
    public static var read: Self

    /// Write lock (aka "exclusive")
    ///
    /// The corresponding C constant is `F_WRLCK`.
    public static var write: Self

    /// No lock (aka "unlock").
    ///
    /// The corresponding C constant is `F_UNLCK`.
    public static var none: Self

    /// Shared (alias for `read`)
    public static var shared: Self

    /// Exclusive (alias for `write`)
    public static var exclusive: Self

    /// Unlock (alias for `none`)
    public static var unlock: Self
  }
}
#endif // !os(Windows)

We define the raw-valued ProcessID type. Further process API is future work.

#if !os(Windows)
/// The process identifier (aka PID) used to uniquely identify an active process.
///
/// The corresponding C type is `pid_t`
@frozen
public struct ProcessID: RawRepresentable, Hashable, Sendable {
  public var rawValue: CInterop.PID

  public init(rawValue: CInterop.PID)
}
#endif // !os(Windows)

Finally, we define supporting CInterop typealiases

extension CInterop {
  /// The C `short` type
  public typealias CShort = Int16

#if !os(Windows)
  /// The C `struct flock` type
  public typealias FileLock = flock

  /// The C `pid_t` type
  public typealias PID = pid_t

  /// The C `off_t` type.
  ///
  /// Note System generally standardizes on `Int64` where `off_t`
  /// might otherwise appear. This typealias allows conversion code to be
  /// emitted into client.
  public typealias Offset = off_t
#endif
}

Alternatives Considered

POSIX-style locks and flock(2)-style locks

POSIX-semantic locks are problematic in the modern world. Under POSIX-semantics, locks are released if any file descriptor for that file is closed. From the fcntl man page:

This semantic means that applications must be aware of any files that a subroutine library may access. For example if an application for updating the password file locks the password file database while making the update, and then calls getpwname(3) to retrieve a record, the lock will be lost because getpwname(3) opens, reads, and closes the password database. The database close will release all locks that the process has associated with the database, even if the library routine never requested a lock on the database.

flock(2) locks (BSD-style) fix the semantic problem in POSIX locks but are still process-associated, which is still problematic. For example, multiple threads within a process share the same locks. Even if the application carefully coordinates between its threads, it may issue a subroutine call (e.g. to a shared library) which happens to open the same file and thus share the same locks.

OFD locks are associated with each open call and are more suitable for the modern world.

OFD locks on Linux/Darwin, process-associated locks on Windows

We chose not to provide significantly different semantics under the same API for different platforms. Instead, Windows can have its own specific API. Windows-specific System API is future work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment