Concepts and design

This section explains the ideas behind file locking, how filelock works across different platforms, and the trade-offs involved in choosing a lock type.

Why file locks?

Multi-process applications often need to coordinate access to shared resources. Without coordination, processes can interfere with each other and cause data corruption or inconsistent state.

For example, imagine two processes writing to the same configuration file:

        sequenceDiagram
    box rgba(21, 101, 192, 0.16) Processes
        participant A as Process A
        participant B as Process B
    end
    box rgba(46, 125, 50, 0.16) Shared state
        participant File as Configuration File
    end
    activate A
    activate B
    A->>+File: Read
    File-->>-A: 1
    B->>+File: Read
    File-->>-B: 1
    Note over A: Increment to 2
    Note over B: Increment to 2
    A->>File: Write 2
    deactivate A
    B->>File: Write 2
    deactivate B
    Note over File: Final value: 2 (should be 3!)
    

This scenario is a race condition. Process B’s write overwrites Process A’s changes, and the final result is incorrect.

File locks prevent this by ensuring only one process can access a resource at a time:

        sequenceDiagram
    box rgba(21, 101, 192, 0.16) Processes
        participant A as Process A
        participant B as Process B
    end
    box rgba(230, 81, 0, 0.16) Coordination
        participant Lock as File Lock
    end
    box rgba(46, 125, 50, 0.16) Shared state
        participant File as Configuration File
    end
    activate A
    A->>Lock: Acquire lock
    activate Lock
    B->>Lock: Try to acquire (waits...)
    A->>+File: Read
    File-->>-A: 1
    Note over A: Increment to 2
    A->>File: Write 2
    A->>Lock: Release lock
    deactivate Lock
    deactivate A
    Lock->>B: Lock acquired!
    activate B
    activate Lock
    B->>+File: Read
    File-->>-B: 2
    Note over B: Increment to 3
    B->>File: Write 3
    B->>Lock: Release lock
    deactivate Lock
    deactivate B
    Note over File: Final value: 3 ✓
    

Now both processes complete successfully and their changes are preserved.

Self-deadlock detection

A common mistake is creating two separate FileLock instances for the same file and trying to acquire both in the same thread:

lock_a = FileLock("work.lock")
lock_b = FileLock("work.lock")

with lock_a:
    with lock_b:  # RuntimeError: Deadlock detected!
        pass

This would deadlock forever because lock_b waits for a lock that lock_a holds in the same thread. filelock detects this and raises RuntimeError with a message suggesting is_singleton=True as the fix.

This detection only applies to blocking acquires without a limit (a negative or infinite timeout) within the same thread. Non-blocking or timed acquires raise Timeout as usual.

Async locks scope the check to the asyncio task instead of the thread, so a sync lock and an async lock do not see each other’s holds. Holding a FileLock on the event loop thread and then awaiting a blocking AsyncFileLock acquire of the same file polls forever instead of raising; give that acquire a timeout.

Detection compares path spellings after resolving the parent directory, not the files they name. Two spellings that one filesystem maps to the same file, such as Work.lock and work.lock on a case-insensitive volume (APFS and NTFS by default) or the NFC and NFD forms of an accented name on APFS, count as different locks: the second acquire waits forever instead of raising. is_singleton=True keys on the same spelling, so spell each lock path one way.

How file locking works

File locking takes two approaches:

OS-level locking (FileLock on Windows/Unix, UnixFileLock, WindowsFileLock)

The operating system tracks locks on open handles and releases them when a process exits. These locks coordinate code that follows the same locking convention; they do not prevent unrelated access to the protected resource.

Pros

Cons

✓ Enforced by the kernel.

✗ Exclusive only, with no reader/writer distinction (use ReadWriteLock for that).

✓ Works even if your process crashes.

✗ Unreliable on some network filesystems.

✓ Lower overhead.

Soft locks / PID locks (SoftFileLock, the fallback on systems without fcntl)

A shared marker indicates that a resource is in use. The marker contains the PID, hostname, and a process start token of the holder. A process attempts acquisition with O_CREAT | O_EXCL and releases by deleting the pathname. The filesystem must provide coherent exclusive creation and directory updates to every participant.

This is the same concept as a traditional PID lock file (as used by Unix daemons and the deprecated lockfile library’s PIDLockFile). The PID stored in the file identifies the lock holder via the pid property and lets a waiter detect stale locks when the holding process has died.

Same-host contenders reclaim a marker only when the recorded owner is provably gone. Every platform records a process start token alongside the PID, so a live PID whose start token differs from the record is a recycled PID, not the original holder. A live PID whose token still matches, a token that cannot be read, and a marker from another host all read as held.

Strict soft locks (StrictSoftFileLock) use immutable owner claims instead of replacing one shared marker. Each contender writes a complete private record, publishes a unique intent through a hard link, and publishes a unique held claim only after it wins the intent order. The winner rescans before entering and keeps both its intent and its held claim for the whole hold: the intent has existed unchanged since the winner published it, so a contender’s directory scan is guaranteed to observe it, whereas a scan racing the freshly linked held claim may miss it. That stable witness is what keeps two contenders from both reading themselves as the lowest claim. Release removes both claims by name, so it cannot detach or delete a successor at another pathname. There is no queue: whichever contender next finds the claim directory empty wins, so a waiter can be passed over while others re-acquire.

Added in version 3.30.0.

The protocol keeps <path> as a permanent compatibility sentinel and stores claims under <path>.filelock/claims. A legacy SoftFileLock holder can finish before the first strict acquisition. Once strict mode activates the path, the sentinel prevents legacy clients from entering again. Every strict participant needs filelock 3.30.0 or newer. Legacy clients configured with lifetime, callers of break_lock(), and code that deletes lock files can remove the sentinel; mixing them with strict clients voids mutual exclusion.

The lock creates <path>.filelock and its claims directory with its mode, plus search permission where the mode grants read, so mode=0o600 gives 0o700. It sets the sticky bit (a directory flag that lets only a file’s owner remove it) on both, so only the account that published a claim can remove it. On each acquisition it narrows a directory it owns to the mode, which covers one an earlier release created wider. Under a sticky parent such as /tmp, acquisition raises SoftFileLockProtocolError when <path>.filelock belongs to an account that owns neither the lock nor the parent. Where the platform supports dir_fd, the lock reaches claims through a descriptor of <path>.filelock opened without following symlinks. A symlink swapped in for either directory then fails the operation instead of redirecting it.

Strict claims require coherent directory reads and atomic no-replace hard links. The class reports a protocol error when the filesystem reports that it cannot publish hard links. NFS and SMB remain outside this guarantee until their mount and server combination passes the protocol tests. Linux documents the hard-link lock pattern in open(2); Microsoft documents NTFS hard links in Hard Links and Junctions.

A link() that fails with EEXIST still counts as published when the claim name now resolves to the contender’s own record. NFS retransmits a request whose reply was lost, and the retry reports EEXIST for the link the first request made; open(2) and liblockfile check the same way.

Strict locks do not infer that an owner died. An orphaned, damaged, or newer-version claim blocks entry. filelock therefore avoids overlapping a paused holder at the cost of operator recovery after a crash. A strict lock also removes nothing it finds at the lock path in place of its sentinel. A soft-lock marker whose owner on this host has exited, a directory or symlink, or a file it cannot parse that stays unchanged for two seconds makes acquisition raise SoftFileLockProtocolError naming it. See Use fail-closed soft locks for inspection and recovery.

Private publication records are not ownership claims. Each attempt uses a fresh random name. Linked private names are discarded immediately, while unlinked records left by a crash receive a two-second grace period before reclamation. Losing a private name makes the publisher retry; it never converts an unpublished record into ownership.

Pros

Cons

✓ Does not require a native file-locking API.

✗ Not enforced; it requires cooperation.

✓ Uses filesystem operations available on supported platforms.

✗ Higher overhead than OS-level locks.

✓ Can inspect same-host PID records.

✗ Network filesystems require deployment-specific tests for creation and cache coherence.

Platform-specific details

Windows

Uses the WindowsFileLock class, backed by LockFileEx/UnlockFileEx over a one-byte range on a handle opened with NtCreateFile. Processes using the same byte-range convention contend.

The lock is exclusive and works reliably on local filesystems. Network filesystem (SMB) support is available but considered less reliable.

Lock file cleanup: Windows attempts to delete the lock file after release, but deletion is not guaranteed in multi-threaded scenarios. Windows cannot delete files with open handles, so if another thread acquires the lock before the previous holder finishes cleanup, the lock file persists. It does not affect lock correctness. To keep a stable pathname across releases, construct the lock with preserve_lock_file=True (see Keep the lock file on release).

An oplock (opportunistic lock: a notice sent to a process holding a handle before another process opens the file, which the opener waits on until the holder answers) on the lock file does not hold an acquire past its timeout. The open reports contention, and the retry loop keeps the deadline. ReadWriteLock waits for its timeout on an oplocked database and then raises Timeout. Release deletes the lock file through a handle that does not wait for an oplock holder either, so a holder that never answers leaves the file in place.

Unix and macOS

Uses the UnixFileLock class, backed by the kernel’s fcntl.flock interface. The interface is common on Unix systems but is not specified by POSIX. The lock is exclusive and enforced by the kernel.

Works best on local filesystems. Network filesystems (NFS) may have issues; locking isn’t always reliable on NFS.

Lock file cleanup: the native lock file remains after release. A persistent empty file does not indicate ownership. Keeping one pathname prevents contenders from coordinating through different inodes, which would break mutual exclusion; remove the file only after the complete lock protocol has stopped using it.

Filesystems without flock: when flock fails with ENOSYS, the lock switches to SoftFileLock and emits a warning, unless constructed with fallback_to_soft=False. Before switching it removes the empty file only if this acquire created it. It does so while still holding the file open, with the exchange a stale break uses, so a peer’s marker that replaced it is put back rather than deleted. A file that already existed may be another process’s soft marker, so it stays and the soft lock waits on it. An empty file there reads as a holder that recorded nothing, as a release before 3.22 writes. A leftover one therefore waits for timeout, lifetime or break_lock().

Other platforms without fcntl

Falls back to SoftFileLock and emits a warning. The lock is not enforced by the OS, but filelock includes stale lock detection (though without fcntl, this detection is less reliable than on systems with full fcntl support).

Which lock type should I use?

Start with FileLock, the platform-aware alias that automatically chooses the best backend:

from filelock import FileLock

lock = FileLock("work.lock")

This gives you OS-level locking on Windows and Unix/macOS, with a soft-lock alias on builds without fcntl. To require the Unix backend and reject its runtime ENOSYS fallback, construct UnixFileLock(..., fallback_to_soft=False) explicitly. The option cannot change which class FileLock aliases when fcntl is unavailable.

Use UnixFileLock or WindowsFileLock when the application requires that backend or its platform-specific behavior.

Use SoftFileLock when:

  • The filesystem lacks a usable native lock, and operators have verified exclusive creation and cache behavior.

  • Cooperating processes can tolerate shared-marker recovery and its overlap risks.

Use StrictSoftFileLock when:

  • You need mutual exclusion a shared marker cannot promise, without a native lock.

  • Operator recovery after a crash is acceptable: it never breaks a claim by age, and a crashed holder’s claim is cleared with force_break.

Use SoftFileLease when:

  • Overlap is acceptable and you want a claim that expires, so a peer can make progress if a holder wedges.

  • You fence the protected resource, because a lease token names a claim but does not fence one.

Use ReadWriteLock when:

  • Your workload is read-heavy with occasional writes.

  • Multiple processes need to read simultaneously.

  • You want a single writer while no readers are active.

  • The lock file lives on a local filesystem. ReadWriteLock is SQLite-backed and is unsafe on NFS.

Use SoftReadWriteLock when:

  • You need reader/writer semantics on a tested shared filesystem that provides no-replace hard links.

  • Callers can stop protected work when on_compromise reports the hold lost, or fence the protected resource with the generation the hold was granted at.

Lock selection flowchart:

        flowchart TD
    start["Choose a lock type"] --> question1{"Read-heavy workload?"}
    question1 -->|Yes| questionRwNet{"Network<br/>filesystem?"}
    questionRwNet -->|Yes| questionRwFs{"Verified marker and<br/>cache behavior?"}
    questionRwFs -->|Yes| srw["Use SoftReadWriteLock"]
    questionRwFs -->|No| service["Use a lock service"]
    questionRwNet -->|No| questionAsync{"Async code?"}
    questionAsync -->|Yes| arw["Use AsyncReadWriteLock"]
    questionAsync -->|No| rw["Use ReadWriteLock"]
    question1 -->|No| question2{"Need network<br/>filesystem support?"}
    question2 -->|Yes| questionSoftFs{"Verified exclusive create<br/>and cache behavior?"}
    questionSoftFs -->|No| service
    questionSoftFs -->|Yes| questionContract{"Which soft-lock<br/>contract?"}
    questionContract -->|"Strict, never overlap"| strict["Use StrictSoftFileLock"]
    questionContract -->|"Overlap OK, fence resource"| lease["Use SoftFileLease"]
    questionContract -->|"Cooperative best-effort"| soft["Use SoftFileLock"]
    question2 -->|No| question3{"Need platform<br/>specific control?"}
    question3 -->|Yes| platform["Use UnixFileLock<br/>or WindowsFileLock"]
    question3 -->|No| default["Use FileLock<br/>(recommended)"]

    %% docs/_static/custom.css colors these classes, once per light and dark theme
    classDef recommended stroke-width:2px
    classDef alternative stroke-width:2px
    classDef special stroke-width:2px
    classDef escape stroke-width:2px
    class default recommended
    class soft,strict,lease,platform,srw alternative
    class rw,arw special
    class service escape
    

Exclusive locks compared

Feature

FileLock

SoftFileLock

StrictSoftFileLock

SoftFileLease

Exclusion contract

Enforced among lockers (advisory)

Cooperative shared marker

Cooperative, never overlaps

Expiring, overlap permitted

Platform enforcement

OS-level (Windows/Unix)

No (file-based)

No (owner claims)

No (marker + heartbeat)

Stale recovery

N/A (OS-enforced)

Reclaims a provably-gone owner

Operator force_break only (fail-closed)

Expiry + heartbeat; on_compromise

Owner inspection

No

pid, is_lock_held_by_us

claims, force_break

owner, token, compromise

Network filesystem

Not reliable

If verified

If verified (atomic hard links)

If verified

Cancel acquisition

Yes (cancel_check)

Yes (cancel_check)

Yes (cancel_check)

Yes (cancel_check)

Async support

AsyncFileLock

AsyncSoftFileLock

AsyncStrictSoftFileLock

AsyncSoftFileLease

Overhead

Low

High

High (claim directory)

High (heartbeat thread)

Read/write locks compared

Feature

ReadWriteLock

SoftReadWriteLock

Readers / writers

Multiple readers, one writer

Multiple readers, one writer

Backing

Local SQLite

Generation log of immutable snapshots, committed by no-replace hard link, plus a nonce heartbeat

Network filesystem

Not reliable (local only)

Works, including cross-host on multi-node clusters

Stale recovery

SQLite transactions

A holder whose nonce stays unchanged for stale_threshold on the observer’s own clock is evicted; on_compromise tells it, generation fences it

Async support

AsyncReadWriteLock

AsyncSoftReadWriteLock

Singleton default

Yes

Yes

Overhead

Medium (SQLite)

Medium (daemon heartbeat thread + one hard link per transition)

TOCTOU vulnerability

TOCTOU (Time-of-Check-Time-of-Use) is a potential security issue: a time gap exists between when you check something and when you act on it.

For example, this code has a TOCTOU vulnerability:

import os

if os.path.exists("sensitive.txt"):  # Check
    data = open("sensitive.txt").read()  # Use (race window here!)

An attacker with filesystem access could create a symlink between the check and the use, redirecting you to read a different file.

How filelock mitigates this:

The defense differs by platform:

  • On Unix, filelock opens lock files with O_NOFOLLOW, so a symlink planted at the lock path makes the open fail instead of reaching its target. O_NOFOLLOW does not catch a hard link (a second name for the same file) or a file renamed to the lock path, and such a file is indistinguishable from a lock file. UnixFileLock therefore locks a file it did not create as found, without truncating it or changing its mode.

  • Windows has no O_NOFOLLOW. WindowsFileLock opens the file without following reparse points and rejects a symlink or junction by inspecting the handle it locks, so a swap between check and use cannot redirect it. Only the last path component is guarded; the lock directory must not be writable by untrusted users.

  • SoftFileLock creates its marker with O_CREAT | O_EXCL, which fails on any existing name. Before reading a marker it classifies the path with lstat and treats anything other than a regular file as malformed, then checks the opened handle again with fstat. On Windows it reads and breaks markers through handles that do not wait for an oplock holder to answer, so an oplock on a marker ends as contention and the timeout holds.

  • StrictSoftFileLock opens <path>.filelock with O_NOFOLLOW and reaches claims relative to that descriptor on platforms that support dir_fd. A symlink swapped in for either directory after the check cannot redirect where claims are created, listed or removed.

  • SoftReadWriteLock opens <path>.rw and then gen or holders without following symlinks for every record operation on platforms that support dir_fd. A tree swapped in after an earlier acquire cannot redirect its writes. It rewrites a holder record only when the name still leads to the file it created and that file has no second name, and it opens the record without blocking. It leaves alone a hard link (a second name for the same file) or a FIFO (a named pipe) renamed over the record.

ReadWriteLock refuses a symlink at its database path. In a directory that processes of other accounts can write, it opens SQLite through a hard link (a second name for the same file) in a directory only the locking account can read, so a link or journal file another process puts beside the database cannot point the lock at another file. On Windows the directory counts as writable by other accounts unless its owner and every entry of its access control list (ACL) that allows adding or removing names belong to the locking account, SYSTEM or Administrators.

What filelock doesn’t protect against

Lock files on the actual resource

Don’t use a lock on the file you want to protect:

lock = FileLock("data.txt")  # ⚠️ Wrong!
with lock:
    data = open("data.txt").read()

This doesn’t work reliably. If you delete data.txt, the lock is gone too. Instead, create a separate lock file:

lock = FileLock("data.txt.lock")  # ✓ Right
with lock:
    data = open("data.txt").read()
Locks on network filesystems

NFS and SMB behavior depends on protocol versions, mount options, servers, and client caching. filelock does not promise one backend across those configurations. Use a soft lock only after verifying exclusive creation, rename, unlink, timestamps, and cache visibility on the target mount. Keep SQLite-backed ReadWriteLock on a local filesystem supported by the active SQLite VFS.

Locks across different machines

Separate machines need a central lock service or a shared filesystem with a tested contract. A shared pathname alone does not establish cross-host correctness.

Read-write semantics

FileLock is exclusive only; readers block writers and vice versa. If you need multiple readers with occasional writers, use ReadWriteLock instead.

Atomicity without locks

If another process doesn’t use the lock, it will still interfere:

# Process A (uses lock)
with lock:
    # Process B might still write at the same time
    # if Process B doesn't use the lock
    Path("data.txt").write_text("A")

Locks require cooperation; all code accessing a resource must use the same lock.

Trust boundaries and ownership scope

Every lock in filelock coordinates cooperating participants that agree to use the same lock for the same resource. None of them deny direct access to the protected resource, and none of them defend against a process that refuses to cooperate. This section states, for each public lock, what it coordinates, what owns the claim, and what survives fork(), descriptor duplication, task cancellation, and process exit.

The same-UID boundary

filelock defends one UID’s cooperating processes against each other’s timing, not against a hostile peer. A process running under the same effective UID as the holder can read, rewrite, or delete any lock file or marker directly, whatever its mode bits say. 0o600 and 0o700 keep other UIDs out; they do not make a same-UID co-tenant safe, because that co-tenant owns the files and can bypass the protocol. Treat every same-UID process on the host as trusted to follow the protocol. Where that assumption does not hold, a file lock is the wrong tool; use an OS mechanism that enforces access, such as a privilege boundary or a broker process.

The directory boundary

Every protection filelock gives against processes of other accounts holds only while none of them can replace the lock’s directory or any of its ancestors. Each ancestor must belong to the account the lock runs as or to root, and must either not be writable by others or carry the sticky bit. A process that can rename entries in a non-sticky ancestor can move the lock file or protocol directory aside, even one it cannot read, and put its own in its place. Holders that reached the old one keep using it while new contenders use the replacement, so two of them hold the lock at once and neither learns of it. No check inside the lock can rule that out, because the owner of such a directory controls every name in it. Keep locks in a directory that only the lock’s users can write, or in a sticky one such as /tmp.

Native locks are advisory

FileLock, UnixFileLock, and WindowsFileLock place an OS lock on an open file. The lock is advisory: it blocks another acquirer that calls the same lock, and it does nothing to a process that opens and writes the protected file without locking. On Unix the lock lives on the open file description (flock), so a fork or dup shares one lock and it releases when the last descriptor sharing that description closes or the process exits. Java’s FileLock draws the same advisory boundary and tells callers to hold one channel per file; because filelock’s Unix backend locks the open file description rather than the POSIX fcntl process owner, a second open of the same path in the same process does not silently drop the lock, but a caller passing its own descriptor through lock_descriptor still owns keeping one lock per descriptor.

Ownership scope per lock

Lock

Owns the claim

Survives / releases

FileLock and the native backends

The acquisition object, per open file description

A forked child or a duplicated descriptor shares the same open file description and its one OS lock; unlocking, or the last shared descriptor closing or exiting, releases it for both.

SoftFileLock

The acquisition object; the marker names the holder’s PID, host, and process start token

A crash leaves the marker; a contender reclaims it only once the recorded owner is provably gone. A forked child does not hold the lock. Release unlinks the marker.

StrictSoftFileLock

The acquisition object, through an owner-specific claim

Never reclaims a claim on its own: a crashed holder’s claim persists until an operator calls force_break. This is the fail-closed contract.

SoftFileLease

A claim that expires; a peer may take it while the previous holder still runs

A heartbeat refreshes the claim; on_compromise fires when it is lost. Overlap is permitted by design, so a lease is a hint about who should work, not a guarantee that only one worker does. A forked child holds no claim.

ReadWriteLock

The acquisition object; state lives in a local SQLite database

Requires a local filesystem the SQLite VFS supports. A forked child must build a fresh instance. Crash recovery comes from SQLite’s transactions.

SoftReadWriteLock

The acquisition object; the latest snapshot names its token and its holder record carries the heartbeat nonce

A peer evicts a holder whose record has not changed within stale_threshold and the holder’s next heartbeat reports the loss through on_compromise. A forked child loses the lock; the inherited instance is fork-invalidated and its release() is a no-op.

Task cancellation

The async wrappers (AsyncFileLock and the async soft, strict, lease, and read-write variants) run each blocking step on a worker thread. Canceling the awaiting task while an acquisition is in flight rolls the attempt back atomically, so a canceled acquire never leaves a half-held lock. A task holding a lock that is canceled still owns releasing it; cancellation does not release the lock for you.

Strict claims, leases, and fencing

A StrictSoftFileLock publishes an owner-specific claim: each holder writes its own claim file and removes only the claims it published, so no holder can detach a peer and no stale break can delete a live successor. claims names the published owners, and force_break names the exact claim it removes. Because it never breaks a claim by age, a strict lock needs no liveness guess; a claim it cannot read without risking overlap raises SoftFileLockProtocolError rather than reclaiming it.

A SoftFileLease trades exclusion for progress. Its token names the claim this process published; it does not fence the protected resource. Reporting a compromise tells the previous holder to stop, but it cannot stop a holder that paused past its expiry and resumes afterwards. For true mutual exclusion where an expired holder can still write, the protected resource must be linearizable and must reject any operation carrying a fencing generation lower than the highest it has accepted, the way Chubby’s lock sequence number and ZooKeeper’s zxid are checked by the resource, not the client. A token names a claim; only a generation the resource validates can fence one.

A lease expires on the contender’s own clock. The holder’s heartbeat rewrites the marker’s modification time, and a contender times how long that time stays the same on its time.monotonic(), the rule SoftReadWriteLock applies to its nonce. The marker’s mtime carries the writer’s or the file server’s clock, so the contender only compares it with its own earlier reading, never with its wall clock: skew between hosts cannot expire a live claim. In exchange a contender that first sees a long-abandoned marker still waits a full lease_duration before taking it, and the filesystem must store modification times finer than lease_duration.

Malformed and legacy markers

A soft lock treats a record it cannot parse as malformed, not as a holder. An empty marker is the exception: it is the whole marker of a SoftFileLock before 3.22, held for as long as its owner runs, and also what a current acquirer shows before it writes its record. A contender therefore waits on it like on any live holder. A plain SoftFileLock self-heals any other malformed marker once it has watched it stay unchanged for a short grace window on its own monotonic clock, which absorbs the brief gap between creating a marker and writing its record.

It tells markers apart by modification time, inode number and change time (the time the kernel last changed the file’s metadata). An owner can set a file’s modification time, and ext4 hands a freed inode number to the next file, but no process can set the change time. A new marker therefore never inherits the time spent watching an old one.

A strict lock never age-breaks, so it fails closed on an unreadable claim. A marker written by an older filelock stays readable: the process start token is an integer, so a 3.29 reader parses a newer marker as well-formed and stays conservative rather than evicting a live holder.

Filesystem support matrix

The contracts above assume the filesystem provides coherent exclusive creation, rename, unlink, timestamps, and cache visibility to every participating process. The table records where that has been measured.

Filesystem

Status

Notes

Local Linux (ext4, xfs, btrfs, tmpfs)

Supported

Native and soft backends verified in CI.

Local macOS (APFS)

Supported

Native and soft backends verified in CI.

Local Windows (NTFS)

Supported

Native and soft backends verified in CI.

NFS (v4 and v3)

Verified in CI

Mount every client that uses StrictSoftFileLock with noac and lookupcache=none. NFS can still serve a removed held or intent claim from a client cache; strict acquisition checks its existence on the server before waiting. CI mounts one loopback export twice, with nosharecache on the second mount, and requires every hold to finish without overlap on NFSv4 and NFSv3 (retrying transient ESTALE during NFSv3 claim churn). The other locks are unsafe or unreliable there: native flock does not exclude across NFS client caches, and the poll-based SoftFileLock can starve under contention. POSIX advisory locking is unreliable across NFS, so also keep the SQLite-backed ReadWriteLock off it.

SMB / CIFS

Verified in CI

A CI lane mounts a loopback Samba share twice as independent client caches. CIFS byte-range locks are mandatory and server-enforced, so the gate enforces the native FileLock, which excludes and finishes every hold. SoftFileLock excludes too but prints ungated, since its poll-acquire can starve under contention. StrictSoftFileLock is not supported on SMB: its claim protocol needs an atomic no-replace hard link that SMB does not provide.

The CI lanes measure the mount and server settings named above. Run tasks/verify_filesystem.py against your own mount to confirm the lock under your options, since cache and locking settings change the result.

Migrating from timed stale breaking

Earlier soft locks broke a marker purely by age: a configured lifetime let any contender remove a marker older than the lifetime, including while its holder was still running. That is a lease, not mutual exclusion, and it silently permitted two holders. If you relied on timed breaking to recover from crashes while believing it guaranteed exclusion, move to one of the explicit contracts: StrictSoftFileLock for real exclusion with an operator break for crash recovery, or SoftFileLease when overlap is acceptable and you fence the protected resource. The age-based lifetime on SoftFileLock remains for backward compatibility and warns at construction with a FutureWarning, which Python shows by default.

Design trade-offs

Why not use OS-level locks everywhere?

OS-level locks are fast and enforced by the kernel. Their network-filesystem behavior depends on the server, protocol, mount, and client. SoftFileLock provides a cooperative fallback when its filesystem operations have been verified.

Why does SoftFileLock use a separate file instead of just checking a directory?

A directory can’t reliably be atomically created and deleted across platforms. A file can be created with O_EXCL (atomic, all-or-nothing) to detect conflicts. This is why SoftFileLock uses files.

How does stale lock detection work across platforms?

Every platform records the holder’s PID, hostname, and a process start token in the marker, and a contender reclaims it only when the recorded owner is provably gone. A PID that no longer exists is gone; a live PID whose start token differs is a recycled PID, so the process that wrote the marker is gone; a live PID whose token still matches, a token that cannot be read, and a marker from another host all read as held. The start token is kill(pid, 0) plus the starttime from /proc/<pid>/stat folded with the boot id on Linux, sysctl process start time on macOS, and the GetProcessTimes creation time on Windows.

A malformed or unparsable record follows a separate rule: a waiter may remove it once it has seen it unchanged for two seconds, so a half-written marker never wedges acquisition. An empty marker is the exception and always reads as held. This is the rule PostgreSQL, Qt QLockFile, and Mercurial converge on: break a stale lock only on proof of death.

Two Linux processes do not always read the same token for one task. Since Linux 5.6 the kernel adds the reader’s boottime offset (the shift a time namespace applies to the boot clock) to starttime. A process in a time namespace with such an offset therefore records no start token and reads none. It falls back to the PID alone, so a recycled PID keeps a dead owner’s marker until timeout, lifetime or break_lock(). A time namespace without an offset, as container runtimes create, keeps the token. A process that cannot read /proc/sys/kernel/random/boot_id (systemd’s ProcSubset=pid hides it) records starttime without the boot id. A contender then compares starttime alone whenever either side lacks the boot id.

A contender never leaves the lock path empty while it breaks a marker. Right before breaking it reads the marker’s inode (the kernel’s identity for a file, independent of its name) and modification time again. If they moved since the stale check, it leaves the marker alone, the way Mercurial re-reads a lock under its break lock before removing it. On Linux (renameat2) and macOS (renamex_np) it then writes a complete marker of its own under a private <lock>.break.* name and exchanges the two names in one step. A contender that polls meanwhile finds a marker and waits.

If the file it took is the one judged stale, it removes that file and holds the lock from then on. A peer that exchanges after that moves the holder’s marker aside only while it checks what it took, and puts it back. A holder that releases while its marker is aside leaves a record no lock parses, so the put-back lands a marker that ages out. Otherwise a peer recreated the marker, and a second exchange puts the live marker back before its own is removed. Contenders that race for one marker nest, each holding what the one before it left at the path, so the exchanges back run in reverse: each contender exchanges when the path names its own marker, and one that stalls exchanges anyway to free a marker only it can move. It finishes once it holds its own marker again. If the filesystem refuses the return exchange, it leaves its marker at the lock path for a later contender to recover.

The private name is <lock>.break.<pid>.<token>: the breaker’s PID, then 32 random hex digits. A breaker killed between its exchange and the put-back leaves its own marker at the lock path and a live holder’s marker under that name. A contender that finds the breaker’s marker dead first looks in the lock’s directory for names that start with <lock>.break.<pid>., where <pid> is the dead breaker’s. It first renames the record to <lock>.restore.<pid>.<token> with its own PID, so only one of several contenders acts on it; a record whose recoverer died there is claimable again, and a claim a failed put-back left in the same process is retried on the next poll. It then exchanges back a marker whose owner is alive or cannot be proven dead, and removes one whose owner is dead. Where the filesystem refuses that exchange, the lock counts as held until timeout or break_lock(). The placeholder a break writes while it works carries the same PID, so a contender that evicts a placeholder a killed process left finds the same names.

The contender holds the judged marker open from before it reads the record until the break ends. ext4 gives a freed inode number to the next file created, so a successor’s marker could otherwise carry the number of the marker judged stale.

Windows has no such exchange, but it refuses to rename a marker its live holder keeps open. There the contender renames the marker aside, checks it, and links back a marker it took by mistake; link never replaces a name. Anywhere else without an exchange, including NFS and SMB, which refuse it, a contender does not move a marker at all, because the path would stand empty beside a possibly live holder. The marker then counts as held until timeout or break_lock(). The lifetime and SoftFileLease expiry breaks follow the same rules, and a failed acquire removes its own marker by the same exchange before it closes it.

A PID means something only inside its PID namespace, and two containers in one pod share the hostname and the boot id without sharing one. On Linux the recorded hostname therefore names the namespace too, as <host>?pidns-<inode> with the inode of /proc/self/ns/pid in hex, the way Mercurial’s lock prefix does. A contender in a sibling container reads the marker as foreign and waits instead of probing a PID that names another process there. A process in the host’s initial namespace records the bare hostname, as earlier releases did. The namespace cannot be folded into the start token instead: a 3.30 reader compares the whole token and would read any new one as a recycled PID.

A release before the namespace was recorded wrote the bare hostname from inside a container too, so a contender in the host’s namespace reads such a marker from a container that shares the hostname (host networking) as local, where its PID names another process or none. Before it calls that owner dead it looks for a task in another namespace whose innermost PID (NSpid in /proc/<pid>/status) and start token match the record, since starttime and the boot id read the same in every PID namespace, and keeps the marker if one exists. From a time namespace with a boottime offset it matches the innermost PID alone.

Three limits remain. A marker without a start token, a kernel before 4.1 without NSpid, or a /proc this process cannot list leaves the check nothing to find. Where /proc/self/ns/pid cannot be read, the bare hostname is all that separates namespaces. And a contender never reclaims a marker from another namespace: a container that crashes and is replaced comes back in a new namespace. A Timeout names such a holder. Containers that can crash while holding a soft lock should use SoftFileLease, set lifetime, or call break_lock() at entrypoint once nothing else can hold the path.

Why is ReadWriteLock backed by SQLite?

A simple file can only track “locked” or “not locked.” To track multiple readers + one writer, you need state management. SQLite handles: - Atomic transactions - Multiple concurrent readers - Exclusive write transactions - Persistence across process crashes

This makes it the natural choice for read-write locks without adding network dependencies.

The async variant (AsyncReadWriteLock) wraps the same SQLite-backed implementation. Because Python’s sqlite3 module has no async API, all blocking operations are dispatched to a thread pool via loop.run_in_executor. This is the same approach used by BaseAsyncFileLock.

The sync lock tracks its owner per thread, and the executor thread that runs a call does not identify the calling task. The async wrappers therefore track ownership per task, as aiorwlock does. Only a task’s first acquire and last release reach the sync lock; the wrapper queues or nests the calls in between. Holds on BaseAsyncFileLock belong to the instance instead, so tasks sharing one nest into the same hold.

How does SoftReadWriteLock work on shared filesystems?

SoftReadWriteLock keeps its whole state in a generation log under <path>.rw. Deploy it on a shared filesystem only after verifying its required operations and cache behavior:

  • <path>.rw/gen/<N> is an immutable snapshot: the writer token, if any, and the reader tokens holding the lock at generation N. Names are zero-padded so the newest sorts last.

  • <path>.rw/gen/epoch is a random token the first participant creates, naming this incarnation of the log the way restic’s repository id and PostgreSQL’s system identifier name theirs. Removing the directory removes it.

  • <path>.rw/gen/HEAD is the epoch followed by a copy of the newest snapshot, replaced by rename after every commit. Within one epoch a crash between the link and the rename, or two committers’ renames landing out of order, leaves it behind the head, never ahead. A rename that lands after the log was removed carries the old epoch, and readers ignore it. Unlike a snapshot it is not flushed to stable storage, so a crash can leave it unreadable; readers then start from the listing and the probe.

  • <path>.rw/holders/<token> is one record per participant, carrying its token, pid, hostname, its stale_threshold as a lease, and a nonce the heartbeat rewrites in place. A peer evicts the participant only once the record has stayed unchanged for the longer of its own threshold and that lease, as the DynamoDB lock client waits out the lease the holder recorded.

Every transition (a reader entering, a writer entering, anyone leaving, a stale member being evicted) is one commit: the participant reads the latest snapshot, writes the successor it wants to a private temporary file, and hard-links that file to gen/<N+1>. A no-replace hard link is an atomic compare-and-swap: one link to that name succeeds, the loser re-reads and tries again, and the record is complete before the name exists. There is no state mutex, so there is no critical section for a crash on any host to leave half done. A dead process leaves behind either a snapshot that still names it, which a contender evicts, or an orphaned record, which a sweep collects.

A participant finds the latest generation by reading the newest generation that gen/HEAD, its listing of the directory, or its memory names, then probing forward by name across a window of sixty-four generations. The probe bridges a generation removed between the listing and the read and a hole left by a committer that died before it compacted. It is not a bound on how stale a listing may be: an NFS client may serve a directory listing and negative lookups from its cache for up to acdirmax (60 seconds by default), long enough for peers to compact far past the window. HEAD covers that case, because opening a file by name makes the client check it against the server (close-to-open consistency), so a stale listing can never pull the start below the head the last commit published. HEAD and memory only say where to look. The participant reads the generation they name from disk like any other, because a committer stalled between its link and its rename can put HEAD back on a generation peers have compacted since. When the generation HEAD names does read back, the window past it has no holes, so the probe stops at the first missing name. Both are bound to the epoch, so an instance never continues a removed log’s numbering into its replacement. A listing, HEAD or memory that names generations of which none can be read marks a client too far behind the log to trust anything it reads; the acquire then raises SoftFileLockProtocolError rather than restart the sequence. A commit also re-reads the epoch and checks that the generation it extends still exists, and counts as lost otherwise, so the participant re-reads the log instead of linking into a new one or into a slot compaction freed.

Readers enter as soon as no live writer is named. A writer enters as soon as no live writer is named, which blocks every new reader, then waits for the named readers to leave. New readers wait behind the named writer, which gives writers preference among cooperating participants.

Generation log walkthrough

The prose above states the protocol; the sequences below show it running.

A writer’s first commit finds no live writer and lands on the first try. The lock file is /var/lock/app.lock, so the log lives at /var/lock/app.lock.rw, and the writer’s holder record is named after its own token, a10f6c2e8b3d4a1f9c7e2b6d0a4f8c31:

        sequenceDiagram
    box rgba(21, 101, 192, 0.16) Processes
        participant A as Writer (token a10f6c2e...f8c31)
    end
    box rgba(46, 125, 50, 0.16) /var/lock/app.lock.rw
        participant FS as gen/ and holders/
    end
    A->>+FS: list gen/, read gen/00000000000000000007
    FS-->>-A: writer=None, readers={}
    Note over A: write successor to .commit-9e4b1a7c3f082d6e5a0c9b2f7e1d4a83, create holders/a10f6c2e...f8c31
    A->>+FS: link .commit-9e4b1a7c3f082d6e5a0c9b2f7e1d4a83 -> gen/00000000000000000008
    FS-->>-A: link landed
    Note over A: holds the lock at generation 8
    

Two contenders can read the same latest snapshot and both try to claim the next one. Only one hard link lands; the loser’s failure is the signal to retry, not an error to report. Writer A keeps the token above; Writer B’s token is b20e5d1f7a2c3b0e8d6f4a9c1b7e3052:

        sequenceDiagram
    box rgba(21, 101, 192, 0.16) Processes
        participant A as Writer (token a10f6c2e...f8c31)
        participant B as Writer (token b20e5d1f...e3052)
    end
    box rgba(46, 125, 50, 0.16) /var/lock/app.lock.rw
        participant FS as gen/ and holders/
    end
    A->>+FS: read gen/00000000000000000007
    FS-->>-A: writer=None
    B->>+FS: read gen/00000000000000000007
    FS-->>-B: writer=None
    A->>+FS: link .commit-2d8f6b0a4e7c1930b5d8a2f6c9e0b174 -> gen/00000000000000000008
    FS-->>-A: landed
    B->>+FS: link .commit-6a1c9e3d7f0b2854c6a9d1e3f5b7c082 -> gen/00000000000000000008
    FS-->>-B: name already exists
    Note over B: re-read the latest generation
    B->>+FS: read gen/00000000000000000008
    FS-->>-B: writer=a10f6c2e...f8c31
    Note over B: retry from gen/00000000000000000009 once A leaves or goes stale
    

A writer that arrives while readers already hold the lock names itself immediately, which blocks every later reader before the writer itself is granted, then waits for the readers already in to leave. Reader R1’s token is c1e9b4d2f6a08c3e7b1d5f902a6c48e7, the writer’s is e5a1c9f307b28d4e6a0c3f819d5b7e24, and Reader R2’s is d4f2a8c6e0b3719d5a2c7e8f014b6d93:

        sequenceDiagram
    box rgba(21, 101, 192, 0.16) Processes
        participant R1 as Reader (token c1e9b4d2...c48e7)
        participant W as Writer (token e5a1c9f3...b7e24)
        participant R2 as Reader (token d4f2a8c6...b6d93)
    end
    box rgba(46, 125, 50, 0.16) /var/lock/app.lock.rw
        participant FS as gen/ and holders/
    end
    R1->>+FS: link .commit-3f0e8c2a6d1974b0e5a8c1d3f6b9e207 -> gen/00000000000000000005 (readers={c1e9b4d2...c48e7})
    FS-->>-R1: landed
    Note over R1: holds a read lock
    W->>+FS: commit gen/00000000000000000006 (writer=e5a1c9f3...b7e24, readers={c1e9b4d2...c48e7})
    FS-->>-W: landed
    Note over W: named as writer, every new reader now blocks
    R2->>+FS: read gen/00000000000000000006
    FS-->>-R2: writer=e5a1c9f3...b7e24
    Note over R2: writer already named, waits behind W
    R1->>+FS: commit gen/00000000000000000007 (writer=e5a1c9f3...b7e24, readers={})
    FS-->>-R1: landed
    Note over R1: releases, removed from readers, holders/c1e9b4d2...c48e7 unlinked
    W->>+FS: commit gen/00000000000000000008 (writer=e5a1c9f3...b7e24, readers={})
    FS-->>-W: landed
    Note over W: readers drained, granted at generation 8
    

Cross-host stale detection

On a multi-node cluster, kill(pid, 0) on node-17 cannot detect that a process on node-42 crashed while holding the lock, and nothing distinguishes a replacement pod under a new hostname from a stranger. Liveness rests on a nonce:

  • Each holder’s daemon heartbeat thread rewrites the nonce in its record every heartbeat_interval seconds (default 30 s), and a contender waiting to enter rewrites its own on every poll.

  • A contender records the bytes it read from each member’s record and the moment it read them on its own time.monotonic(). Once a member’s bytes have stayed unchanged for stale_threshold seconds (default 90 s), the contender evicts it by committing a snapshot without it, in the same commit that admits the contender, and removes its record.

  • The protocol compares no timestamp across hosts. A heartbeat’s mtime would carry the file server’s clock and be read against the client’s, so it plays no part; the one clock in the protocol measures the gap between two observations made by one process. Clock skew between hosts, or against the file server, cannot make a live holder look dead.

  • The heartbeat also re-reads the latest snapshot, so an evicted holder learns it on its next tick: the thread stops, the lock sets compromise, and on_compromise runs.

A writer on node-17 that crashes mid-hold leaves a snapshot naming it and a heartbeat that stops. A contender on node-42 cannot ask node-17 whether the process is still alive, so it watches the nonce instead, and evicts once that nonce has sat unchanged for a full stale_threshold. The writer’s token is a10f6c2e8b3d4a1f9c7e2b6d0a4f8c31, and the contender’s is f30c8e1a2d4b6970c3e5a8f1b2d4e607:

        sequenceDiagram
    box rgba(21, 101, 192, 0.16) node-17
        participant A as Writer (token a10f6c2e...f8c31)
    end
    box rgba(46, 125, 50, 0.16) /var/lock/app.lock.rw
        participant FS as gen/ and holders/
    end
    box rgba(230, 81, 0, 0.16) node-42
        participant B as Contender (token f30c8e1a...4e607)
    end
    A->>+FS: commit gen/00000000000000000009 (writer=a10f6c2e...f8c31)
    FS-->>-A: landed
    A->>FS: heartbeat: rewrite holders/a10f6c2e...f8c31 nonce
    Note over A: crashes, heartbeat stops
    B->>+FS: read gen/00000000000000000009, read holders/a10f6c2e...f8c31
    FS-->>-B: writer=a10f6c2e...f8c31, nonce=7f2e9a01
    Note over B: records nonce 7f2e9a01 and its own time.monotonic()
    loop every poll, until stale_threshold elapses
        B->>+FS: read holders/a10f6c2e...f8c31
        FS-->>-B: nonce still 7f2e9a01
    end
    Note over B: nonce unchanged for stale_threshold, a10f6c2e...f8c31 counts as stale
    B->>+FS: commit gen/00000000000000000010 (writer=f30c8e1a...4e607, a10f6c2e...f8c31 evicted)
    FS-->>-B: landed
    B->>FS: unlink holders/a10f6c2e...f8c31
    Note over B: granted at generation 10, the crashed writer's holder record collected
    

The generation a hold was granted at is generation, a monotonic fencing token within one incarnation of the log: a resource that rejects writes carrying a lower generation than the highest it has accepted refuses a holder that paused past stale_threshold, was evicted, and resumed. The lock itself cannot stop that holder, so stale_threshold must still exceed any realistic pause a holder might hit (GC, syscall delay, filesystem delay).

The protocol needs atomic no-replace hard links, exclusive creation, and a coherent read of a file opened by name. NFSv3 and later provide all three, and Amazon EFS is NFSv4.1. Where the filesystem refuses hard links the lock raises SoftFileLockProtocolError rather than degrade.

Fork semantics

Python threads do not survive fork(). A process that forks while holding a SoftReadWriteLock would leave the child with the holder record, the lock-level state, and no heartbeat thread; the parent would keep refreshing while the child would not, and both would believe they hold the lock. SoftReadWriteLock registers an os.register_at_fork(after_in_child=...) hook that replaces the inherited threading.Lock objects with fresh ones and marks the instance fork-invalidated. release() on an invalidated instance is a no-op, so an inherited with lock.read_lock(): block can unwind in the child without raising. The child must construct a fresh SoftReadWriteLock(path) before it can acquire again. This matches PyMongo’s connection-pool semantics.

Trust boundary. The class coordinates cooperating processes. Directory ownership, ACLs, and 0o600 / 0o700 permissions can exclude another UID. Under a sticky parent such as /tmp, acquisition raises RuntimeError when <path>.rw, gen or holders belongs to an account that owns neither the lock nor the parent. The refusal also applies to a lock object that used an earlier tree: it repeats the check whenever a directory it opens is not the one it prepared. A process with the same effective UID can alter the records. A filesystem that delays a rewrite’s visibility past stale_threshold can also trigger expiry while a holder remains active.

File permissions and mode

By default, filelock does not set explicit permissions on the lock file (mode=-1). This lets the OS control permissions through umask and default ACLs. In shared directories with POSIX default ACLs, this preserves ACL inheritance so the lock file gets the directory’s default permissions rather than the creating user’s umask.

When you pass an explicit mode value (e.g., mode=0o644), filelock creates the file with it. On Unix, UnixFileLock and SoftFileLock then apply it with fchmod, so the umask cannot narrow it. This overrides any default ACLs on the directory. A Unix lock file that already exists keeps the mode it has. A soft lock’s marker never gets the group or other write bits, whatever the mode or umask. Contenders only read and rename it, and they act on the record its owner wrote.

Windows has no mode bits. A new file inherits the ACL (access control list, the Windows permission list) of its directory. WindowsFileLock honors the one mode that has a Windows counterpart: a mode without group and other bits, such as 0o600, creates the lock file with an ACL that grants access to its owner, SYSTEM and Administrators only, the ACL os.mkdir(mode=0o700) applies. The ReadWriteLock database, always 0o600, gets the same ACL. A file that already exists keeps its own ACL. Every other mode inherits the directory’s ACL, as do the files of the soft locks, which os.open creates; restrict the directory’s ACL to keep other accounts out.

An explicit mode must be an int within 0o000-0o777 that grants the owner read and write; construction raises TypeError for another type and ValueError otherwise. A lock reopens, reads, and deletes the files it creates, so without those bits it fails later and stays broken: a native lock on its second acquire, a StrictSoftFileLock on its first, and a SoftFileLock when it tries to reclaim a crashed holder’s marker.

Thread-local vs shared state

Each FileLock instance tracks its lock counter, file descriptor, and configured defaults (poll_interval, timeout, blocking, mode, lifetime) in a single context object. By default (thread_local=True), each thread gets its own context via threading.local. This means:

  • Two threads holding the same FileLock object each maintain independent lock counters.

  • Thread A releasing the lock doesn’t affect Thread B’s counter.

  • Setting a configuration property (for example lock.poll_interval = 0.5) affects only the thread that performed the write. Other threads continue to see the value supplied to the lock’s constructor; threading.local re-applies the original constructor arguments the first time each new thread accesses the context.

  • mode is the one exception: it has no setter, so construction is the only place it is ever set and lock.mode = ... raises AttributeError.

  • The instance still keeps track of every thread’s hold. release(force=True) also releases holds left by threads that exited, and garbage collecting the lock releases every thread’s hold.

When thread_local=False, all threads share the same context, including configuration values. This is useful for objects passed between threads or for cases where you want a property setter to affect all threads. The shared instance runs one acquire() or release() at a time, so threads calling them concurrently, with or without the GIL, keep the counter and the held lock consistent. It is still a single owner: a second thread’s acquire() nests into the hold rather than waiting for it, and any thread’s release() counts against the shared counter, so balancing them is up to the caller.

Async locks default to thread_local=False because the thread that calls acquire() (via run_in_executor) may differ from the thread that calls release(). Using thread_local=True with run_in_executor=True raises ValueError.

        flowchart LR
    subgraph "thread_local=True (default)"
        T1["Thread A<br/>counter: 2"] --- L1["Lock File"]
        T2["Thread B<br/>counter: 1"] --- L1
    end

    subgraph "thread_local=False"
        T3["Thread A"] --- SC["Shared<br/>counter: 3"] --- L2["Lock File"]
        T4["Thread B"] --- SC
    end

    %% docs/_static/custom.css colors these classes, once per light and dark theme
    classDef thread stroke-width:2px
    classDef counter stroke-width:2px
    classDef store stroke-width:2px
    class T1,T2,T3,T4 thread
    class SC counter
    class L1,L2 store
    

When not to use file locks

High-frequency synchronization

File locks have high latency and are inefficient for protecting variables that change frequently. Use threading locks or multiprocessing synchronization primitives instead.

Distributed systems without shared filesystems

If processes are on different machines without a shared filesystem, file locks don’t work. Use a centralized lock service (Redis, Consul, Zookeeper, etc.).

Real-time systems

File locking is subject to filesystem delays and network latency. Real-time systems need sub-millisecond guarantees that file locks can’t provide.

Very large numbers of locks

Creating thousands of lock files consumes filesystem resources. For that scale, use an in-memory lock service.

Next steps