fork dup and Shared File Offsets
A file descriptor is an integer that indexes a per-process table; what it points at is a
struct file— the open file description — which holds the file offset, the status flags, and a link to the inode. The single most consequential fact about descriptors is that several descriptors can point at the samestruct file, and when they do, they share that one offset.dup/dup2/dup3/fcntl(F_DUPFD)create a new descriptor aliasing the same open file description.forkcopies the descriptor table but the copied slots still point at the parent’sstruct files — so a child and parent reading the same inherited fd advance one shared offset. Contrast this with a freshopen()of the same file, which always creates a new, independent open file description with its own offset. This note (pinned to Linux 6.12 LTS, 2024-11-17) traces the actual code —get_file/f_countrefcounting,fd_install,do_dup2— that makes aliasing work, and the classic interleaved-output bug it causes.
Mental Model
There are two distinct objects and you must keep them apart. The descriptor (the integer + its table slot) is per-process. The open file description (struct file, with f_pos the offset) is a heap object that can be pointed at by many descriptors, in the same process or across processes. “Sharing an offset” always means “pointing at the same struct file.”
flowchart TB subgraph P["Parent process fd table"] PA["fd 3"] PB["fd 4 (= dup of 3)"] end subgraph C["Child process fd table (after fork)"] CA["fd 3"] end subgraph O["Open file descriptions (struct file)"] F1["struct file A<br/>f_pos = 100<br/>f_count = 3"] F2["struct file B<br/>f_pos = 0<br/>f_count = 1"] end PA --> F1 PB --> F1 CA --> F1 OTHER["fd 5 (fresh open of same inode)"] --> F2 F1 --> IN["inode (the file on disk)"] F2 --> IN
Three descriptors aliasing one open file description versus a fresh open. What it shows: parent fd 3, the dup’d parent fd 4, and the child’s inherited fd 3 all point at the same struct file A (offset 100, refcount 3) — they share one position. A separate open() of the same file (fd 5) gets struct file B with its own offset 0; both descriptions resolve to the same inode. The insight to take: shared offset ⇔ shared struct file. dup and fork produce sharing; a new open produces independence — and the f_count reference count is exactly “how many descriptors point here.”
What “Open File Description” Means
The term is POSIX’s. The open(2) man page defines it precisely: “The term open file description is the one used by POSIX to refer to the entries in the system-wide table of open files… in kernel-developer parlance—a struct file.” Each open() creates a new open file description; “thus, there may be multiple open file descriptions corresponding to a file inode.” This is the crux: the inode is the file; the open file description is one act of having it open, carrying the cursor (f_pos), the access mode and status flags (f_flags), and a f_count reference count. The full anatomy of struct file is the subject of The struct file and Open File Description; here we focus on the sharing of it.
In struct file (include/linux/fs.h, v6.12) the two fields that matter are:
struct file {
atomic_long_t f_count; /* reference count: how many descriptors point here */
...
loff_t f_pos; /* the shared file offset (the cursor) */
...
};f_pos is the offset; it lives in the open file description, not in the descriptor and not in the inode. Every descriptor pointing at this struct file sees the same f_pos. When one of them does lseek, read, or write, it mutates this single field, and all the others observe the change.
dup, dup2, dup3, F_DUPFD: Aliasing the Same struct file
The dup family creates a new descriptor that, per dup(2), “refers to the same open file description as the descriptor oldfd” and therefore “share[s] file offset and file status flags; for example, if the file offset is modified by using lseek(2) on one of the file descriptors, the offset is also changed for the other.” The kernel makes this true by literally storing the same pointer in two table slots and bumping f_count. Look at the dup syscall (fs/file.c, v6.12):
SYSCALL_DEFINE1(dup, unsigned int, fildes)
{
int ret = -EBADF;
struct file *file = fget_raw(fildes); /* look up oldfd, take a ref (f_count++) */
if (file) {
ret = get_unused_fd_flags(0); /* allocate lowest free fd */
if (ret >= 0)
fd_install(ret, file); /* store the SAME struct file* at the new slot */
else
fput(file); /* on failure, drop the ref we took */
}
return ret;
}fget_raw(fildes) resolves the old descriptor to its struct file and increments f_count (that is what “get” means here). fd_install(ret, file) then does rcu_assign_pointer(fdt->fd[ret], file) — it plants the identical pointer in the new slot. Two descriptors, one struct file, one f_pos. Note dup clears FD_CLOEXEC on the new fd: the close-on-exec flag is a property of the descriptor, not the open file description, so it is not inherited by the alias.
dup2(oldfd, newfd) and dup3 target a specific destination fd rather than the lowest free one. The engine is do_dup2():
static int do_dup2(struct files_struct *files, struct file *file, unsigned fd, unsigned flags)
{
struct file *tofree;
fdt = files_fdtable(files);
fd = array_index_nospec(fd, fdt->max_fds);
tofree = fdt->fd[fd]; /* whatever was at newfd, if anything */
if (!tofree && fd_is_open(fd, fdt))
goto Ebusy; /* slot reserved but not yet installed: -EBUSY */
get_file(file); /* f_count++ for the new alias */
rcu_assign_pointer(fdt->fd[fd], file); /* install at exactly newfd */
__set_open_fd(fd, fdt);
... set/clear close_on_exec per flags ...
spin_unlock(&files->file_lock);
if (tofree)
filp_close(tofree, files); /* close the old occupant, AFTER the swap */
return fd;
}Two subtleties the man page calls out and the code enforces. First, the close-and-reuse is atomic: dup2 installs the new pointer and only then closes whatever was at newfd (the tofree close happens after the slot has already been overwritten under file_lock). Doing this with close(newfd); dup(oldfd) in userspace would be racy — a signal handler or sibling thread could grab newfd in between. Second, dup2(fd, fd) with equal descriptors is a special no-op: the dup2 syscall wrapper checks newfd == oldfd, verifies oldfd is valid (returns EBADF if not), and returns oldfd unchanged without clearing close-on-exec — whereas dup3(fd, fd) deliberately returns EINVAL for the equal case, because its whole purpose is to set flags and that is meaningless on a no-op.
fcntl(fd, F_DUPFD, arg) is the same aliasing with a twist: per F_DUPFD(2const) it allocates “the lowest-numbered available file descriptor greater than or equal to arg.” Its implementation f_dupfd() calls alloc_fd(from, nofile, flags) then get_file(file); fd_install(err, file) — once again, the same struct file, shared offset. F_DUPFD_CLOEXEC does the same but sets close-on-exec on the new fd in one step.
fork: Copy the Table, Share the Open Files
fork() is where the gotcha bites hardest, because the sharing is implicit. Per fork(2): “The child inherits copies of the parent’s set of open file descriptors. Each file descriptor in the child refers to the same open file description (see open(2)) as the corresponding file descriptor in the parent. This means that the two file descriptors share open file status flags, file offset, and signal-driven I/O attributes.” The descriptor table is copied (so the child can close/open without disturbing the parent’s numbers), but each slot still points at the parent’s struct file.
The copy is dup_fd() (fs/file.c). After allocating a fresh files_struct and sizing its table to fit the parent’s used descriptors, the heart of it is this loop:
old_fds = old_fdt->fd;
new_fds = new_fdt->fd;
for (i = open_files; i != 0; i--) {
struct file *f = *old_fds++;
if (f) {
get_file(f); /* f_count++: child aliases the SAME struct file */
} else {
/* slot claimed in bitmap but not yet installed (sibling mid-open()) */
__clear_open_fd(open_files - i, new_fdt);
}
rcu_assign_pointer(*new_fds++, f); /* child slot points at parent's struct file */
}get_file(f) is the linchpin. It does atomic_long_fetch_inc_relaxed(&f->f_count) (and WARN_ONCE if the count was zero — a use-after-free tripwire). So after fork, every shared file’s f_count has gone up by one, and the child’s fd i and the parent’s fd i are the same pointer. They share f_pos. The else branch handles a narrow race: a sibling thread of the parent may have reserved a descriptor (set its open_fds bit in alloc_fd) but not yet fd_install’d the pointer; the slot is NULL, so the child clears that bit to leave the fd available rather than inheriting a half-open descriptor.
The result: this is the same offset-sharing as dup, just established en masse and across a process boundary instead of within one table.
fork-shared offset vs a fresh open
The contrast is the whole point, and it is sharpest with no status flags muddying it — a plain O_WRONLY open, so the offset is governed purely by f_pos:
Scenario A — fork (shared open file description, ONE offset):
fd = open("log", O_WRONLY); // one struct file, f_pos = 0
fork();
// parent: write(fd, "AAAA", 4); // advances the shared f_pos 0 -> 4
// child: write(fd, "BBBB", 4); // continues from the SAME f_pos 4 -> 8
// result on disk: "AAAABBBB" — one advancing cursor, no clobber
Scenario B — independent opens (TWO offsets):
// process 1: fd1 = open("log", O_WRONLY); write(fd1, "AAAA", 4); // its f_pos: 0 -> 4
// process 2: fd2 = open("log", O_WRONLY); write(fd2, "BBBB", 4); // ITS f_pos: 0 -> 4
// two struct files, two independent offsets, BOTH starting at 0
// result on disk: "BBBB" — process 2 wrote at offset 0, clobbering process 1
In Scenario A there is one struct file and one f_pos: the parent’s write advances the shared cursor to 4, and the child’s write continues from 4, so the bytes concatenate. In Scenario B there are two struct files with two independent offsets both at 0, so the second writer lands at offset 0 and overwrites the first. The only difference between the scenarios is whether the two writers point at the same struct file — which is exactly the difference between fork/dup (aliasing) and a fresh open (independence). This is why shell >> redirection relies on a single inherited open file description shared across the pipeline, and why two unrelated processes that each open() a log without coordination stomp on each other. (O_APPEND is a separate refinement layered on top: it makes each write atomically seek-to-end-of-file first, so even independent opens append safely without overwriting — but each still has its own f_pos between the implicit seeks. The shared-vs-independent-offset distinction here is about f_pos aliasing, not about O_APPEND.)
f_count: The Reference Count That Ties It Together
f_count (atomic_long_t in struct file) is “how many things hold this open file description open.” Every alias-creating operation bumps it; every close drops it; the file is destroyed only when it hits zero.
get_file(file)—atomic_long_fetch_inc_relaxed(&f->f_count); used bydup,dup2,F_DUPFD, anddup_fd(fork) to register a new alias.fput(file)— the counterpart;close(fd)ultimately calls it. It doesatomic_long_dec_and_test; when the count reaches zero it tears the file down (calls->release, drops the inode reference, frees the struct).- The lookup path also touches
f_count:__fget_files_rcu()(the locklessread/writefast path) doesatomic_long_inc_not_zero(&file->f_count)to pin the file for the duration of the operation, thenfputs it when done. This is transient pinning, not aliasing — but it is whyf_countis at least 1 plus the number of in-flight syscalls touching the file.
So after open (count 1), dup (count 2), fork (count 3 — child got a ref), the file is referenced three times. closeing all three descriptors brings it to zero and frees it; closing two of three leaves it open under the survivor. A descriptor leak is, mechanically, an f_count that never returns to zero.
Detecting Aliasing: kcmp
Because two descriptors might or might not alias the same open file description, there must be a way to ask. On Linux that is kcmp(2) with KCMP_FILE (available since Linux 3.5), defined in its man page as: “Check whether a file descriptor idx1 in the process pid1 refers to the same open file description (see open(2)) as file descriptor idx2 in the process pid2. The existence of two file descriptors that refer to the same open file description can occur as a result of dup(2) (and similar), fork(2), or passing file descriptors via a domain socket.” It returns 0 when both fds point at the same struct file. This is the only portable kernel-blessed way to distinguish “dup/fork alias” (same offset) from “two separate opens of the same inode” (independent offsets) — comparing inode numbers via fstat cannot tell them apart, since both descriptions resolve to one inode. (Note the man page also names a third aliasing route alongside dup and fork: a struct file passed between unrelated processes over a Unix-domain socket with SCM_RIGHTS lands in the receiver’s table as an alias of the sender’s open file description — shared offset and all.) On 6.12, kcmp(2) is always available; before Linux 5.12 it required the kernel to be built with CONFIG_CHECKPOINT_RESTORE (its origin was the checkpoint/restore-in-userspace, CRIU, feature), but that gate was removed, so no current CONFIG caveat applies.
Failure Modes and Misconceptions
- “Two opens of the same file share an offset.” False — each
open()is a new open file description with its ownf_pos. Sharing only happens viadup/fork/CLONE_FILES. This is the most common confusion and the source of the Scenario-B clobber above. - “After fork, parent and child have independent files.” Half-true: independent tables, shared open file descriptions. A child that
read()s an inherited fd advances the parent’s offset too. The fix when you want independence is for the child tocloseand re-open, or tolseekdeliberately — never assume the cursor is private. - The interleaved-stdio bug.
forkafter bufferedprintfwithout flushing is the textbook version: the child inherits a copy of the userspaceFILE*buffer, so both flush it, and the same bytes appear twice. That is a libc-buffer issue, distinct from (but compounded by) the shared kernel offset. Alwaysfflushbeforefork. - dup2 close errors vanish.
dup2silently closes the old occupant ofnewfd; per the man page, “the close is performed silently (i.e., any errors during the close are not reported).” Ifnewfdwas a file whose finalclosewould report a writeback error (EIOfromclose),dup2swallows it. For files where the close error matters, close explicitly first. - Forgetting O_CLOEXEC before fork+exec. Since the child inherits aliases to all the parent’s open files, a database connection or secret-bearing fd leaks into an exec’d helper unless it carries close-on-exec. The flag lives on the descriptor, so each
dup’d alias must set it independently. See File Descriptors and the fd Table for the close-on-exec bitmap mechanics.
See Also
- File Descriptors and the fd Table — the per-process table these descriptors live in; allocation, bitmaps,
CLONE_FILESsharing. - The struct file and Open File Description — the full anatomy of the
struct filewhose offset is being shared. - Threads as Tasks and the CLONE Flags —
CLONE_FILES(threads sharing the whole table) as a third aliasing route. - Open Flags and Access Modes —
O_APPEND,O_CLOEXEC, and how status flags (shared via the open file description) differ from descriptor flags. - File Locking flock and fcntl — OFD locks, which (unlike POSIX record locks) are keyed on the open file description, making them
dup/fork-aware. - Linux Filesystems and VFS MOC — the parent map (§3, Open Files, Descriptors, and the File Table).