A file descriptor is not a file. It is an index into a per-process table, which points at an
open file description, which points at an inode. Three layers, each with its own lifecycle
and reference count — and nearly every fd surprise (shared offsets, dup2 behavior, epoll
ghosts, ulimit -n) falls out of confusing those layers.
The three layers
| Layer | Scope | Created by | Holds |
|---|---|---|---|
| fd table | per-process | inherits/fork | index → pointer, flags (e.g. FD_CLOEXEC) |
open file description (OFD, struct file) | per open() | open(), socket(), accept() | file offset, open mode, refcount, epoll interest |
| inode | per file/socket | filesystem, on disk | data blocks, metadata |
open() creates a new OFD and stores a pointer to it at the lowest free fd slot. read() /
write() do not touch the fd table — they dereference the fd once and then operate on the
OFD, advancing its offset.
Why dup2 works the way it does
dup(), dup2(), and dup3() create a new fd that points at the same OFD. The OFD has
one file offset — so two dup'd fds share a position:
int fd = open("log.txt", O_WRONLY | O_CREAT, 0644);
int fd2 = dup(fd);
write(fd, "hello ", 6);
write(fd2, "world\n", 6); // appends — same OFD, same offsetThe classic shell case: cmd > out 2>&1 is dup2(1, 2) — stdout and stderr share one OFD,
so interleaved writes stay in order. Without O_APPEND, two separate open()s of the same
file give two OFDs with independent offsets — concurrent writers overwrite each other.
O_APPEND moves the offset to EOF atomically per write, which is why append-mode is the
only safe multi-writer mode.
fork inherits the table, not the objects
fork() copies the fd table; the OFDs are shared, not copied. Parent and child advance the
same offset — the classic "both processes wrote and interleaved" bug. This sharing is also
why pipes work across fork/exec: the pipe's OFD is inherited, and close() by either side
just decrements the refcount. An fd stays alive until every reference is closed.
exec and FD_CLOEXEC
execve() replaces the process image; open fds survive by default. That is why a shell's
exec 3> file survives into child commands, and why daemons leak file descriptors into
everything they spawn. FD_CLOEXEC marks an fd to close automatically on exec — which is why
modern code passes O_CLOEXEC everywhere: sockets, files, and events otherwise leak into
untrusted exec'd code.
epoll registers the OFD, not the number
This is the one that bites servers: epoll_ctl(fd, EPOLL_CTL_ADD, sockfd, ...) stores a
reference to the open file description, not the integer. Consequences:
close(sockfd)does not remove the registration — the OFD survives as long as epoll references it. Events keep firing; the fd number may even be reused by a different file that now shares... no — the events refer to the old OFD. This is the source of "epoll ghost" bugs and why servers useEPOLL_CTL_DELexplicitly.dup()of a registered fd registers the same OFD twice — duplicate events for one underlying socket.accept()ed sockets each carry their own OFD, which is why per-connection epoll interest works naturally.
Why fd limits exist
Each fd pins real kernel memory: a struct file (~256 bytes), socket buffers, and references
into dentry/inode caches. A million fds is hundreds of MB of kernel memory — plus the
accounting that makes each one findable. Limits exist because one process must not be able
to pin unbounded kernel memory:
- Per-process:
RLIMIT_NOFILE— soft limit 1024 by default, hard limit adjustable;ulimit -nand systemd'sLimitNOFILE. The historical 1024 comes fromFD_SETSIZE, the oldselect()bitmap. - Per-user / system-wide:
fs.file-max(tunable),fs.nr_open.
A process that "runs out of fds" gets EMFILE on open() — and a leaked-fd service under
connection churn will hit it, because TIME_WAIT sockets and their fds accumulate.