The Runtime Theory
Operating Systems

File Descriptors and the Open File Table

The three-layer structure behind every read(): file descriptor tables, open file descriptions, and inodes — and why dup2, epoll, and ulimit behave the way they do.

The Runtime Theory Team3 min read#file-descriptors#kernel#epoll#io
On this page

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

LayerScopeCreated byHolds
fd tableper-processinherits/forkindex → pointer, flags (e.g. FD_CLOEXEC)
open file description (OFD, struct file)per open()open(), socket(), accept()file offset, open mode, refcount, epoll interest
inodeper file/socketfilesystem, on diskdata 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:

c
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 offset

The 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 use EPOLL_CTL_DEL explicitly.
  • 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 -n and systemd's LimitNOFILE. The historical 1024 comes from FD_SETSIZE, the old select() 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.