Filesystems · Network Filesystems

Byte-Range Locks and What Happens When a Client Dies

The server owns the lock, the client owns the local enforcement, and neither can see the other side. That split explains lock leasing, the difference between local and NLM locking, and why a dead client blocks a range until the server notices.

Two processes on different machines open the same file and each takes an exclusive lock on the same 4096-byte range. One of them succeeds. On a local filesystem the kernel arbitrates: it holds both open file descriptions, it compares the ranges, and it returns an error to the loser. Over the network the kernel holds only one of the descriptions. The other process is on another machine, represented at the server by nothing more than a client identifier and a range.

Locking is the point where a network filesystem must keep state it otherwise tries hard to avoid. The file handle model works precisely because the server remembers nothing; a lock is a promise the server must remember, and remembering creates the obligation to detect when the holder is gone.

Two layers, and the gap between them

POSIX record locking via fcntl has semantics that are hard to map onto a network. Locks belong to a process, not to a descriptor; closing any descriptor for the file releases all of that process’s locks on it; and a lock request from the same process replaces its previous lock rather than conflicting with it. The last rule means a process can silently downgrade or extend its own lock, and the kernel is expected to coalesce the ranges.

On a client, the kernel can implement all of that locally. It tracks which local processes hold which ranges, merges them, and when a request would conflict with another local process it resolves it without any network traffic. Only when the request conflicts with, or must be visible to, another machine does the client contact the server.

That split produces the central asymmetry: the server knows about ranges held by clients, and the client knows about ranges held by local processes, and neither has a complete picture. When a local process requests a lock, the client must decide whether to consult the server. Consulting on every request would be correct and slow. Not consulting would let two clients both believe they hold the same exclusive range.

The resolution is that the client maintains a per-file notion of what the server has granted it, and sends a request to the server whenever the union of local locks exceeds what it currently holds. Locks held by local processes that fall inside an already-granted server lock need no traffic. When the last local holder releases a range, the client eventually tells the server, but it may defer that release — the client keeps the server-side lock for a while in case another local process wants it. Deferring means the server thinks the range is held when nobody is using it, which blocks other clients unnecessarily; releasing immediately means a lock-unlock-lock pattern costs two round trips instead of none.

The separate lock service

Version 2 and version 3 have no lock operation. Locking is provided by the Network Lock Manager, a separate ONC RPC service with its own procedures: lock, unlock, test, and a pair of calls for the recovery protocol. The split is a consequence of the statelessness decision — the file protocol has no place to put state, so the state lives in a different protocol.

NLM has no persistent store of its own. The lock manager on the server keeps a table in memory and relies on the host’s own locking primitives for enforcement against local processes. When a client asks for a range, the server translates the request into whatever the underlying filesystem offers and answers with granted or denied.

The awkward part is identity. NLM identifies a caller by a set of fields including a caller name, a host address, and an opaque owner handle that the client chooses and the server never interprets. The owner handle is how the client distinguishes its own processes; it is a byte string, not a number, and its meaning is entirely local to the client. When a client retransmits a lock request because a reply was lost, the server must recognise it as the same owner requesting the same range rather than as a second request that conflicts with the first. Getting this wrong produces the strange failure where a client deadlocks against itself.

What happens to a lock when its holder stops answering

A lock held by a process on a machine that has crashed is the hard case. The process will never release it, and the server cannot distinguish “crashed” from “busy” by looking at the lock table. Nothing in the lock request tells the server how to reach the holder later, so a separate mechanism has to supply that.

That mechanism is a status monitor, a service that runs on both peers and whose only job is to report reboots. When a client first takes a lock from a server, the lock managers on both sides register each other with their local monitor and record the peer’s identity durably. The record holds enough to reach the peer after a local restart: a name to contact, a way to recognise the peer when it reports, and a route back to the local lock manager.

The protocol is notification, not polling. When a machine reboots, a helper started at boot walks the list of peers that were monitoring it and sends each one a message naming the machine and a state counter that changed at boot. The receiving monitor matches the name against its records, notes the new counter, and passes the news to its lock manager, which drops every lock held by that host. A client that reboots therefore releases its locks implicitly, without ever sending an unlock. On Linux the two halves are the rpc.statd daemon, which listens for these notices, and the sm-notify helper, which sends them after a reboot.

The design’s limit is visible in what it does not do. There is no probe, no timeout, and no aging of a lock whose holder is silent. A client that is partitioned rather than rebooted never sends a notice, because it never rebooted, so its locks are never released. The server keeps them for as long as the partition lasts, and other clients are blocked for that entire period. The alternative — releasing locks after some interval of silence — would break exclusion during a partition, which is precisely the case the lock exists to handle. So the protocol chooses a stalled lock over a violated one, and the recovery path is an administrative one: restart the client, or clear the lock state on the server.

Version 4 addresses the same problem differently. Locks are part of the file protocol, and the server uses the client’s own lease — the period during which the server promises to honour the client’s cached state — as the liveness signal. A client that stops renewing its lease is presumed gone and its state is dropped. This ties lock liveness to the same mechanism that protects delegations, which is simpler than a separate notification service but makes the lease duration a parameter that governs both caching and locking. It also converts the stalled-lock outcome into a bounded one, at the cost of the partition hazard described above.

Recovery: the reclaim window

A server restart loses the lock table. Every client that held a lock now holds nothing, but the clients do not know this and will keep writing under the assumption of exclusion. Two things must happen: the clients must learn that their locks are gone, and they must be given a chance to reacquire them before new lock requests are granted to anyone else.

The second part is the grace period. For a defined interval after a restart, the server accepts lock requests only from clients that are reclaiming locks they held before the restart, and rejects fresh requests, answering the fresh ones with a specific error that means “denied because the server is in grace”. This lets each client restore its previous state before competition begins. The interval must be long enough for every client to notice the restart and reclaim, which means it depends on how many clients there are and how quickly they detect the failure. The NLM protocol defines the error that reports the grace state but fixes no duration, and the version 4 specification ties the window to the lease interval rather than to a constant, so the length is a server configuration whose right value comes from the client population.

Reclaim is where the statelessness of the file protocol and the statefulness of the lock protocol collide. The server cannot verify a reclaim, because it has no record of what the client held. A client could claim any set of ranges. The protocol relies on the client being honest — a client that reclaims a range it never held is behaving incorrectly, and there is nothing in the message exchange that prevents it. This is acceptable because the participants are cooperating kernels rather than mutually distrusting parties, but it means lock recovery is a trust boundary rather than a verified protocol.

The reclaim must also be idempotent. A client that sends a reclaim and receives no reply will send it again, and the server must treat the repeat as a duplicate rather than as a second request. The same duplicate detection that protects ordinary calls applies, but with a twist: during the grace period the server is deliberately accepting requests it would otherwise reject, so the state machine has more paths than usual.

/* Sketch of the server-side lock state machine during recovery. */
enum lock_result try_lock(struct client *c, struct range *r, bool reclaim)
{
    if (in_grace_period() && !reclaim)
        return LOCK_DENIED_GRACE;      /* wait until reclaims finish */

    if (conflicts_with_local(r))
        return LOCK_DENIED;

    if (conflicts_with_other_client(r, c))
        return LOCK_DENIED;

    insert_lock(c, r);
    return LOCK_GRANTED;
}

A client that fails to reclaim within the grace period is out of luck. Its locks are gone, other clients may now hold the ranges, and the client’s writes proceed without protection. The protocol offers no way for the server to notify it, and no way for the client to discover the loss except by attempting an operation that fails. This is why the grace period is usually set generously: the cost of a long grace period is a delay in accepting new locks, while the cost of a short one is silent loss of exclusion.

What locking does not provide

Record locks in NFS are advisory. Nothing prevents a process that ignores locks from writing to a range another process has locked, and the server will not stop it. This matches POSIX, where fcntl locks are advisory, but the network case is more dangerous because the assumption of cooperation now spans machines whose administrators may not have agreed on anything.

Locking also does not interact well with the caching model. A client that holds a lock reads and writes through its own cache, and the server’s grant does not invalidate another client’s cached pages. The other client is expected to be blocked by the lock, but a process that never takes the lock sees whatever its cache holds. The two mechanisms — locks for mutual exclusion and close-to-open for visibility — are orthogonal, and neither substitutes for the other.

The final limitation is performance. Every lock acquisition that crosses a client boundary is a round trip, and the local fast path only applies when the ranges are already covered by a held server lock. A program that locks and unlocks a small range in a tight loop will spend most of its time in the lock protocol unless the client is configured to hold server locks longer than the local locks require. That deferral is a bet that no other client wants the range, and it is the reason lock behaviour on a network mount is sensitive to configuration in ways that local locking is not.

References

  1. RFC 1813: NFS Version 3 Protocol Specification — IETF
  2. RFC 8881: Network File System (NFS) Version 4 Minor Version 1 Protocol — IETF
  3. fcntl(2) — Linux manual page — man7.org
  4. NFS Illustrated — Brent Callaghan, Addison-Wesley, 2000