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:
| 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) orls -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_TMPFILEcreates 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.