FreeBSD fcntl Record Locks: Byte Ranges, Advisory Coordination, and Close Semantics
Use FreeBSD fcntl(2) byte-range locks correctly, including conflict handling, fork behavior, and the easily missed any-close release rule.
FreeBSD’s fcntl(2) record locks coordinate cooperating processes over byte ranges in a file. The interface is useful for record-oriented data and compatibility with software that expects POSIX-style advisory locks, but its ownership rules are easy to misread. Most importantly, the locks are associated with a process, not with the file descriptor that requested them: closing any descriptor in that process which refers to the same file releases all of that process’s record locks on the file.
That detail makes a seemingly harmless helper-library call capable of dropping a lock. Design the descriptor lifecycle and every participating process around the locking contract, and do not treat the lock as an access-control boundary against code that ignores it.
Describe the range precisely
struct flock identifies a region with l_whence, l_start, and l_len. FreeBSD accepts SEEK_SET, SEEK_CUR, or SEEK_END as the offset origin. A zero length means from the selected start through end-of-file, including later extension. For an entire-file lock, a common request is SEEK_SET, start zero, length zero. Keep the range calculations in a wide signed offset type and check addition, truncation, and record-boundary conditions before asking the kernel to lock it.
F_RDLCK is a shared lock: compatible readers may overlap, but a conflicting writer cannot take the range. F_WRLCK is exclusive against both shared and exclusive conflicting locks. The descriptor must have been opened for reading for a read lock and for writing for a write lock. These are advisory locks; every writer and reader whose correctness depends on the region must participate in the same protocol.
Use F_SETLK when the process should fail immediately if the range is unavailable. FreeBSD reports a conflict as EAGAIN. F_SETLKW waits for a compatible range to become available; a caught signal can interrupt that wait unless the handler uses SA_RESTART. Check every return value and decide explicitly whether a conflict should be retried, reported, or cause the operation to abort. F_GETLK can inspect the first lock that would block a proposed request, but it is diagnostic state, not a reservation: another process can change the lock table before the caller acts. The authoritative operation is still F_SETLK or F_SETLKW.
Blocking does not remove the need for a lock-order design. The FreeBSD manual documents deadlock detection for a blocking request and an EDEADLK result when the kernel detects a cycle. It also documents fair queuing for F_SETLKW on local files: a later conflicting request is not granted ahead of a blocked request in the queue. That is useful when analyzing a local workload, but it is not a portable scheduling guarantee for every filesystem or distributed lock manager. Define a consistent record order across participants, and treat both EDEADLK and signal interruption as normal error paths that leave the transaction incomplete.
Acquire, update, and unlock with a deliberate descriptor owner
This small C example requests an exclusive lock over the whole file without waiting. It illustrates the API shape; it is not a complete record database or a crash-safe transaction:
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
int
lock_whole_file(int fd)
{
struct flock request = {
.l_type = F_WRLCK,
.l_whence = SEEK_SET,
.l_start = 0,
.l_len = 0
};
if (fcntl(fd, F_SETLK, &request) == -1) {
if (errno == EAGAIN)
return 1; /* another participant owns a conflicting range */
perror("fcntl(F_SETLK)");
return -1;
}
return 0;
}
After acquisition, validate the file’s format and the target record before writing. On every exit path, submit a matching F_UNLCK request. Do not close any descriptor that refers to the file until releasing every process-associated lock on it is intended. A lock does not make multiple writes atomic, guarantee durable storage, or roll back a partial update after a crash. Use a journal, checksum, atomic replacement strategy, or database transaction when the data format requires those properties.
The process-wide close rule changes ordinary library design
FreeBSD documents traditional fcntl locks as process-associated. If a process locks a database through descriptor A and a library later opens and closes the same database through descriptor B, that close releases all of the process’s record locks on that file, even if B never requested a lock. The application may continue its critical section believing it is protected while another cooperating process can now enter. Audit helper code, logging paths, callbacks, and error cleanup for opens and closes of the same file.
Fork and exec have different documented behavior. The FreeBSD 15.1 fcntl(2) manual says record locks are not inherited by fork(2), but says locks obtained before execve(2) remain until the new program releases them or exits. The same manual says that closing any descriptor for the file removes all of the process’s locks. The 15.1-RELEASE source creates a conflict for close-on-exec descriptors: FD_CLOEXEC maps to UF_EXCLOSE; do_execve() calls fdcloseexec(), which closes those entries through closef(); and closef() removes the process’s F_POSIX locks on that vnode. Do not rely on a lock surviving an exec that closes a descriptor for the same file; verify the exact release and filesystem, or arrange to reacquire the lock. If a child needs to participate, define an explicit handoff protocol and verify it on the target FreeBSD release.
FreeBSD’s flock(2) has different ownership and close behavior, and the FreeBSD manual recommends it for cases where library descriptor activity or child inheritance matters. It is not a portable synonym for fcntl locks: choose one documented protocol for all participants, confirm how the target filesystem and any network client handle it, and do not infer identical behavior across operating systems.
Diagnose a lock that appears to vanish
Reproduce with two processes and a disposable file. Have process A acquire a range and keep its descriptor open; have process B attempt the same range and verify the conflict. Then, in A, open and close a second descriptor for the same file and retry B’s attempt. This isolates the any-close rule. Add separate cases for a non-overlapping range, a shared/shared pair, an interrupted blocking request, and the explicit unlock path. Record the FreeBSD release, filesystem, exact offsets and lengths, process IDs, and errno values.
For production updates, keep the descriptor owner narrow, centralize open/close behavior, and minimize code that runs while a lock is held. Document whether the lock protects a record, a file-wide metadata operation, or a multi-step protocol, and ensure every writer follows that scope. When the lifecycle cannot meet the process-associated close rule, choose a locking mechanism whose documented ownership model matches the application instead of trying to hide the rule behind another descriptor.
Related:
- FreeBSD cron Operations: Schedules, Environments, and Reliable Jobs
- Linux Open File Description Locks: Byte-Range Coordination Without PID Ownership
Sources:
- FreeBSD 15.1
fcntl(2)manual - FreeBSD 15.1
close(2)manual - FreeBSD 15.1
flock(2)manual - FreeBSD 15.1-RELEASE
kern_exec.cclose-on-exec path - FreeBSD 15.1-RELEASE
kern_descrip.cFD_CLOEXEC to UF_EXCLOSE mapping - FreeBSD 15.1-RELEASE
kern_descrip.cclose-on-exec descriptor-close path - FreeBSD 15.1-RELEASE
closefp()toclosef()path - FreeBSD 15.1-RELEASE
closef()POSIX-lock release