File Descriptors

What a file descriptor is

A file descriptor (fd) is a small non-negative integer that a process uses to refer to an open I/O resource: a regular file, a directory, a socket, a pipe, a terminal, an epoll instance, an eventfd, an io_uring. Every I/O system call takes one. The integer itself means nothing outside the process; it is an index into a table the kernel keeps for that process.

"Everything is a file" is really "everything is a file descriptor": read, write, close, poll, and fcntl work the same way on a disk file, a TCP connection, or a pipe. ls | sort is two processes whose stdout and stdin are the two ends of one pipe.

Three tables, not one

The most important fact about file descriptors — and the source of most surprises — is that there are three levels between the integer and the file:

graph LR subgraph p1["Process A: fd table"] a0["fd 0"] a3["fd 3"] a4["fd 4 (dup of 3)"] a5["fd 5"] end subgraph p2["Process B (forked from A): fd table"] b3["fd 3"] end subgraph oft["Kernel: open file descriptions"] o1["OFD #1<br/>offset = 2, flags = O_RDONLY"] o2["OFD #2<br/>offset = 0, flags = O_RDONLY"] end subgraph inodes["Kernel: inodes"] i1["inode 4092<br/>(the file: size, blocks, permissions)"] end a3 --> o1 a4 --> o1 b3 --> o1 a5 --> o2 o1 --> i1 o2 --> i1
Level Per Holds
File descriptor table Process fd number → pointer to an open file description; the FD_CLOEXEC flag
Open file description (OFD, "file table entry") Each open() call Current file offset, status flags (O_APPEND, O_NONBLOCK), access mode
Inode (or socket, pipe object) The file itself Size, permissions, data blocks; shared by all opens of the same file

What is shared depends on how two fds came to be:

How the second fd was created Shares the offset? Measured
dup(a) / dup2 / fcntl(F_DUPFD) Yes — same OFD After 2 reads on a, the dup's offset was 2
Inherited across fork() Yes — same OFD A 1-byte read in the child moved the parent's offset from 2 to 3
A separate open() of the same path No — new OFD, same inode The separately opened fd's offset stayed 0

This is why a parent and a child that both write to an inherited log fd don't overwrite each other's output (they share the offset), and why two independently opened fds to the same file do clobber each other unless opened with O_APPEND (which makes each write atomically seek to the end first).

Allocation: always the lowest free number

open, socket, accept, dup, and friends return the lowest unused fd number. Measured: after close(0), the next open returned fd 0. Shell redirection relies on this: to run a command with stdout going to a file, the shell closes fd 1 in the child and opens the file, which lands on fd 1 (in practice it uses dup2(fd, 1), which states the target explicitly).

The flip side is a classic concurrency bug in multi-threaded programs:

Thread A: close(7)              ← A thinks it's done with a socket
Thread B: open("000123.sst")    ← gets fd 7 (lowest free)
Thread A: write(7, response)    ← A had a stale copy of "7": writes into the SSTable!

Any code path that can use an fd number after closing it — a double close, an error path that closes and a cleanup path that closes again — can corrupt an unrelated file opened by another thread in between. Owning each fd in exactly one object that is the only thing allowed to close it removes the whole class of bug. In Go, *os.File is that owner: calling Close a second time returns os.ErrClosed instead of closing whatever file has since been given that number, and defer f.Close() right after a successful open ties the fd's lifetime to the function — see Files and Durability in Go.

The standard descriptors

fd Name Default
0 stdin Terminal input, or a pipe/file from the shell
1 stdout Terminal, buffered by libc (line-buffered on a terminal, fully buffered otherwise)
2 stderr Terminal, unbuffered

The buffering is in libc's FILE*, not in the fd: write(1, ...) goes straight to the kernel.

Inheritance and O_CLOEXEC

By default, fds survive fork() and exec(). A server that spawns a helper process leaks every open socket and file into it: the helper can read them, and — worse — the server's close() no longer closes the socket, because the helper still holds a reference to the open file description. Clients see connections that never terminate.

Open everything with O_CLOEXEC (or SOCK_CLOEXEC, accept4(..., SOCK_CLOEXEC)), which marks the fd to be closed automatically on exec. Setting it later with fcntl(fd, F_SETFD, FD_CLOEXEC) has a race window in multi-threaded programs (another thread may fork+exec in between), which is why the flag exists on the creating calls.

When does close actually release the file?

close(fd) removes the fd table entry and drops one reference to the open file description. The OFD is released only when no fd (in any process) references it; the inode's data is released only when no OFD references it and no directory entry names it.

Measured: after unlink("fd.txt") while an fd was still open, the file's link count was 0, yet pread through the open fd still returned its full contents.

Consequences:

  • Deleted files hold disk space until every fd to them is closed. A disk shows as full while visible files don't add up: the space is held by deleted-but-open files, typically a log file deleted by a rotation script while the process still writes to it. Find them with lsof +L1 (link count less than 1) or ls -l /proc/<pid>/fd | grep deleted.
  • Storage engines exploit it. LevelDB/RocksDB may delete an obsolete SSTable while an iterator still has it open; the iterator keeps reading safely, and the space is reclaimed when the table cache closes the fd. (They still track references explicitly — see Compaction and Recovery.)
  • Temporary files that clean themselves up: create, open, and immediately unlink. Linux's O_TMPFILE creates an unnamed file directly.

close can also return an error: on some file systems (NFS), deferred write errors surface at close. It must not be retried on Linux — the fd is released even when close fails, and retrying could close a number another thread has just reused.

Limits

Limit Default (typical) Check / raise
Per process, soft (RLIMIT_NOFILE) 1,024 (Linux shells), 256 (macOS) ulimit -n; raise up to the hard limit
Per process, hard 4,096–1,048,576 depending on distro/systemd ulimit -Hn; LimitNOFILE= in a systemd unit
System-wide fs.file-max, usually millions /proc/sys/fs/file-nr shows allocated / max

Exceeding the per-process limit makes open, socket, accept, and pipe fail with EMFILE. A storage engine with thousands of SSTables bounds its open files with a table cache sized by an option like max_open_files; a server with many connections needs a limit well above its connection count plus its files. The soft limit of 1,024 also exists because the old select() call cannot handle fd numbers ≥ 1,024 (FD_SETSIZE); programs using poll/epoll can safely raise it.

Leaks

An fd leak — opening without closing on some path — grows the fd count until EMFILE, at which point every new connection or file fails. It usually hides in error paths: an early return after open succeeded, or (in Go) a missing defer resp.Body.Close() that leaks the connection.

Detect it by watching the count over time:

ls /proc/<pid>/fd | wc -l                # Linux
lsof -p <pid> | wc -l                    # Linux and macOS
ls -l /proc/<pid>/fd                     # what each fd points to (socket:[...], files)

A process's open-fd count is a standard metric (process_open_fds in Prometheus client libraries); alert on it trending upward.

Real-world usage: Proxel had fd leaks in proxy connection tests due to missing defer conn.Close() in error paths. They were caught by an fd-count Prometheus metric that grew monotonically.

File descriptors beyond files

Linux exposes many kernel objects as fds so they can be waited on with the same epoll machinery as sockets:

fd type Created by Becomes readable when
eventfd eventfd() Another thread writes a counter value — a cheap cross-thread wakeup
timerfd timerfd_create() A timer expires
signalfd signalfd() A signal arrives (handled synchronously instead of in a handler)
pidfd pidfd_open() A process exits — race-free process management
epoll epoll_create1() Any registered fd is ready (epoll fds can be nested)
inotify / fanotify inotify_init1() Watched files change

An event loop can then wait for network I/O, timers, signals, and wakeups in one epoll_wait (see I/O Models and Event Loop).

Common pitfall

Closing an fd twice. The second close usually returns EBADF harmlessly in a single-threaded test, so the bug survives; in a multi-threaded server, another thread may have received the same fd number between the two calls, and the second close silently closes that thread's file or socket — which then fails or, worse, gets reused again for something else while its owner still writes to it. The symptoms (a connection dropped at random, bytes appearing in the wrong file) are far from the cause. Give every fd exactly one owner whose destructor closes it, and set the stored number to -1 immediately after closing.