Systems Programming · OS Interfaces
What a File Descriptor Is: An Index Into a Process Table
The descriptor is a small integer indexing a per-process array of pointers, and that indirection explains dup, fork, close, and the shared-offset bugs that follow from each.
A file descriptor is an integer, and nearly every surprise it produces follows from that fact. It is not a pointer to a file, not a stable name for one, and not a handle in the sense used by other operating systems. It is an index into an array that belongs to exactly one process, and the entry it selects points at an object that several descriptors may share. Once the two indirections are separated, the behaviour of dup, fork, exec, close, and the whole family of shared-offset bugs becomes derivable rather than memorised.
The three objects behind one integer
A descriptor resolves through three levels, and each level has its own lifetime.
| Level | What it is | Created by | Destroyed when |
|---|---|---|---|
| Descriptor table entry | Slot in a per-process array, plus a flags word | open, socket, pipe, accept, dup | close, or process exit |
| Open file description | Offset, status flags, access mode, reference count | The same calls, once per successful open | Last descriptor referring to it closes |
| Inode or vnode | The file itself: data location, size, ownership | Filesystem operations | Link count and reference count both reach zero |
The middle layer is where the interesting state lives. It holds the current file offset, the status flags (O_APPEND, O_NONBLOCK, O_ASYNC), the access mode chosen at open time, and a reference count. For a socket it holds the receive and send queues and the connection state. For a pipe it holds the buffer. In Linux this object is struct file; BSD-derived kernels call it a file structure. Whatever the name, there is one per successful open, not one per path and not one per process.
The consequence is the rule that answers most questions about descriptor behaviour: two separate open calls on the same path produce two descriptions with independent offsets, while two descriptors produced by dup or inherited across fork point at one description with a single shared offset. Reading a hundred bytes through one of them advances the position seen by the other.
What the table entry itself contributes
The entry adds almost nothing. It supplies the number, which is the array index, and a flags word whose only portable member is FD_CLOEXEC. Everything else belongs to the description. The access mode that makes read fail with EBADF on a write-only descriptor lives one level down, which is why dup2 cannot be used to change it: the duplicate inherits the mode of the original.
The same indirection means status flags are shared. Calling fcntl with F_SETFL to add O_NONBLOCK changes the behaviour of every descriptor that refers to that description, including one held by an unrelated part of the program. A library that receives a descriptor it did not open and sets it non-blocking has altered the caller’s object, not its own copy. The standard remedy is not to pass such descriptors around, or to open a fresh description of the same file for the library’s own use.
Because the entry carries only flags, there is no per-descriptor offset to set. lseek takes a descriptor and modifies the description, so two programs that want independent positions on one file must open it twice. The same reasoning explains why an O_APPEND write is safe across processes: the flag lives on the description, and the kernel performs the seek-and-write as one atomic step under the inode lock, so concurrent appenders do not overwrite each other’s data.
Inspection follows the same layering. fcntl(fd, F_GETFD) reads the entry’s flags word, where FD_CLOEXEC is the only bit POSIX defines; fcntl(fd, F_GETFL) reads the description’s status flags and access mode together, with the mode in the low two bits. On Linux, /proc/self/fd is a directory whose entries are named for the open numbers, and reading the link target of an entry yields the path the description was opened on — or a pseudo-path such as socket:[12345] or pipe:[67890] for objects that have no name in any filesystem.
Duplication, and where the sharing becomes visible
Four calls create a new descriptor. dup returns the lowest free number. dup2 takes a target number, closes whatever occupied it, and installs the duplicate atomically with respect to signals, which is why shell redirection is built on it. fcntl with F_DUPFD returns the lowest free number at or above an argument, and F_DUPFD_CLOEXEC does the same while setting the close-on-exec flag in one step.
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
int main(void) {
int a = open("data.bin", O_RDONLY);
int b = dup(a); /* same description, new number */
int c = open("data.bin", O_RDONLY); /* new description */
char buf[16];
read(a, buf, 4);
printf("after read on a: a=%lld b=%lld c=%lld\n",
(long long)lseek(a, 0, SEEK_CUR),
(long long)lseek(b, 0, SEEK_CUR),
(long long)lseek(c, 0, SEEK_CUR));
/* a and b report 4; c reports 0 */
close(a); close(b); close(c);
return 0;
}
The output is the whole lesson in three numbers. a and b advance together because the offset is a property of the description, and c starts at zero because the second open created a second description. A file opened twice by two processes behaves like c: separate offsets, no interference.
Why the lowest free number is load-bearing
The allocation rule is not an implementation detail. It is what makes shell redirection work. The construct 2>&1 is, at the system call level, a dup2 of descriptor 1 onto descriptor 2: both numbers afterwards refer to one description, so writes to either land in the same place with a single offset, and the interleaving of the two streams stays in the order the program wrote them.
The same rule explains a class of startup bug. A program whose standard descriptors are closed, which is common for anything started by an init system, begins with slots 0, 1, and 2 free. The next open returns 0. A later printf to descriptor 1 then goes to the wrong object, or read from descriptor 0 consumes a data file the program expected to open deliberately. Daemons handle this by opening /dev/null onto each of the three low numbers before doing anything else, which restores the convention that the standard streams exist and are harmless.
Close, reuse, and the stale descriptor
close releases the number, and the next open in that process will usually return it. In a single-threaded program this is only mildly confusing. In a multithreaded one it is a correctness and security problem: a thread holding a number that another thread closed will, after the number is reused, operate on an unrelated file or socket, with whatever permissions the process happens to have on the new target. The descriptor is a capability that can be silently retargeted.
close also has an unusual contract around interruption. POSIX leaves the state of the descriptor unspecified if close returns EINTR, which means a retry may close a descriptor that another thread has since opened. Linux avoids the ambiguity by releasing the descriptor before returning, so a retry is always wrong there, even though the return value suggests otherwise. The safe pattern is to close once, treat EINTR as success, and never retry.
Limits, and what the indirection costs
Descriptors are bounded by RLIMIT_NOFILE, a per-process limit with a soft and a hard value. Exceeding it yields EMFILE; exhausting the system-wide table yields ENFILE, a distinction that matters when diagnosing a failure, because the second is not fixed by raising a limit. The classic select interface carries a separate, unrelated bound: FD_SETSIZE, 1024 in glibc, limits the value of a descriptor usable in a set, not the number of descriptors in it, because the set is a bit vector indexed by number. A process with three open descriptors numbered 0, 1, and 5000 cannot place the third in a select set. The interfaces that avoid this are treated in readiness notification.
The cost of the design is one pointer dereference and one array lookup per operation, both of which sit in cache for any process with a normal number of open files. The benefit is that access to an object can be transferred as a number rather than as a name. A descriptor can be duplicated into another process across a socket, and the receiver then holds access to a file it cannot name and may not be able to open. That property is what makes the interface composable, and it is examined in UNIX domain sockets and descriptor passing. The same mechanism, applied to a process rather than a file, is what makes a stable handle for a running program possible at all.
Inheritance adds a third copy of the table without copying the descriptions, so a parent and child share offsets and socket state. The rules that govern which descriptors survive an exec are the subject of descriptor inheritance across fork and exec, and they follow directly from the layer at which the close-on-exec flag is stored: on the table entry, not on the description.
The indirection also sets the cost of the fork itself. Copying the table is proportional to the number of entries, not to the size of the files, so a process with a thousand open descriptors pays a thousand pointer copies and a thousand reference-count increments when it forks. That is small next to copying page tables, but it is the reason a fork-heavy program benefits from closing descriptors it no longer needs before spawning. A descriptor that is only used at startup and left open for the life of the process is paid for on every subsequent fork.
References
- The Open Group Base Specifications Issue 7, 2018 edition — System Interfaces — The Open Group
- open(2) — Linux manual page — man7.org
- The Linux Programming Interface — Michael Kerrisk, No Starch Press, 2010