Systems Programming · Resource Management

File Descriptor Lifetime, Inheritance, and Table Exhaustion

What a descriptor actually indexes, how the process and system tables share file descriptions, and why closing in the wrong process leaves the resource open.

A descriptor is a small non-negative integer, and the fact that it is small hides how much state stands behind it. Three levels are involved, and cleanup at one level does not imply cleanup at the others. A process-level table maps the integer to an entry; the entry points to a system-wide file description that holds the offset, the access mode, and the status flags; the description points to the inode or socket that holds the actual resource. Misunderstanding which level a given operation affects is the source of descriptors that stay open after every close call succeeded, and of offsets that move in one process when another process reads.

The three tables

The per-process table is an array indexed by the descriptor number. Each slot is either empty or holds a pointer to an open file description. The lowest-numbered empty slot is chosen when a new descriptor is allocated, which is why a program that closes standard input and then opens a file may find the file on descriptor 0 and start reading its output into the wrong place.

The system-wide table holds open file descriptions, one per open or socket call. This is where the file offset lives. Two descriptors that refer to the same description share the offset, so a read through one advances the position seen by the other. Two separate open calls on the same path produce two distinct descriptions with independent offsets, even though both refer to the same inode.

The inode level holds the object: the file’s data, the socket’s connection state, the pipe’s buffer. The inode is reference-counted by the descriptions that refer to it, and the file is unlinked from its directory when the link count reaches zero and its data is freed when the last description closes.

dup and dup2 copy the per-process entry so that two descriptor numbers refer to one description, which is why the offset is shared. fork copies the entire per-process table, so parent and child share every description they held at the moment of the fork. That sharing is the mechanism behind shell pipelines and behind the O_CLOEXEC flag’s importance.

Close semantics that surprise

close removes one entry from the per-process table. If that entry was the last reference to its description, the description is released and the inode’s reference count drops. If another descriptor in the same process or in another process still refers to the same description, the description survives and the offset stays where it was.

That is why a server that forks a worker and then closes its copy of the listening socket can still find the socket alive: the child holds a copy of the same description, and the listening socket is not released until both are closed. A graceful shutdown that waits for the listening socket to disappear will wait forever if any descendant inherited it and did not close it.

The return value of close is the other trap. POSIX specifies that the descriptor is released even when close returns an error, and the error is reported for a condition that occurred during the release, typically a write-back failure on a network filesystem. Retrying the close is therefore wrong: the descriptor number may already have been reused by another thread, and the retry would close an unrelated resource. The correct handling is to record the error, not to retry, and to treat the data as possibly lost.

The Linux man page for close documents a related hazard: a descriptor can be closed in one thread while another thread is blocked in a system call on the same descriptor. The blocked call may complete against a description that has been released, and if the number has been reused, the call may operate on the new resource. The portable discipline is to coordinate the close with the users of the descriptor rather than relying on close to interrupt them.

Descriptor exhaustion and the limits

Every process has a limit on the number of open descriptors, set by RLIMIT_NOFILE. The soft limit is what the kernel enforces for the process; the hard limit is the ceiling a process may raise its own soft limit to without privilege. A typical default soft limit on a general-purpose Linux system is 1024, which is small enough that a server handling concurrent connections will reach it. Exceeding it makes open, socket, accept, pipe, and dup fail with EMFILE, which POSIX defines as the per-process limit being reached.

The failure mode is easy to misdiagnose because it appears as an unrelated error. A program that cannot accept a new connection because it has leaked descriptors will report EMFILE from accept, and the natural reading is that the listening socket is broken. The diagnostic is to count the entries in /proc/self/fd, which lists one symbolic link per open descriptor; the count against the limit tells whether exhaustion is the cause. The lsof utility provides the same view with the file names resolved.

Distinguishing a leak from a legitimate high-water mark requires the same reachability reasoning that applies to heap blocks. A descriptor held in a global connection pool is reachable and intentional. A descriptor whose only reference was in a structure that has been freed is a leak, and the kernel will not reclaim it until the process exits. Unlike heap memory, there is no detector that can find it by scanning the address space, because the descriptor table is kernel state and not part of the process’s memory image.

Inheriting descriptors into children

fork duplicates the descriptor table, and exec preserves every descriptor that is not marked close-on-exec. The combination is a leak generator. A program that opens a log file, then runs a helper with system or exec, passes the log descriptor to the helper, which may hold it for the duration of its run. If the helper is long-lived, the descriptor remains open in a process the parent cannot see.

The fix is O_CLOEXEC on every descriptor that the process does not intend to pass to a child. The flag is set at open time, and there is a race if it is set afterwards with fcntl, because a concurrent fork in another thread can copy the table between the open and the fcntl. POSIX added O_CLOEXEC and the corresponding SOCK_CLOEXEC and FD_CLOEXEC forms so that the flag can be set atomically with the creation of the descriptor, and the atomic form is the one to use.

The same reasoning applies to the sockets a server inherits. A listener that accepts connections and hands each accepted socket to a thread pool must mark the accepted descriptors close-on-exec, or every child process inherits the connection. The kernel does not distinguish; it copies what the table holds.

Bounds on the number of descriptions

There is a second limit above the per-process one: the system-wide maximum number of open file descriptions, exposed as file-max in /proc/sys/fs. The per-process limit can be raised, but the system limit bounds the total across all processes, and exhausting it produces ENFILE rather than EMFILE. The distinction between the two errors is diagnostic: EMFILE means this process has too many, ENFILE means the machine does.

Memory is the practical bound. Each open file description occupies kernel memory, and the inode cache holds a structure per open file. A process that opens a very large number of files to read them one at a time pays for the descriptions and the inode entries even if it closes each file promptly, because the inode cache retains the metadata. That is the design behind posix_fadvise and the sequential-access hint: they tell the kernel the access pattern so that readahead can be tuned and cache pressure reduced, without changing the descriptor accounting.

Descriptors as a resource with a lifetime

The reason descriptors belong in a discussion of ownership rather than of I/O is that their lifetime has the same shape as a heap block’s: acquisition can fail, release is forgettable, and the resource is finite. The difference is that the failure to release is invisible to the process’s own memory, and the accounting is per-process and system-wide rather than per-allocator.

The cleanup discipline is the same one that applies to any handle. Release in reverse order of acquisition, as described in release order at scope exit, so that a descriptor used by a later step is not closed before that step finishes. Make the release happen on the error path as well, which is the subject of cleanup on failure. And when many short-lived resources are needed, consider whether an arena-style batch release, covered in arena allocation, fits better than individual closes. The process-exit path reclaims all of them at once, which is both a guarantee and a trap, as shutdown ordering explains.

References

  1. The Open Group Base Specifications, System Interfaces — open, dup, close, fcntl — The Open Group
  2. close(2) — Linux man-pages — Linux man-pages project
  3. setrlimit(2) — Linux man-pages — Linux man-pages project
  4. Linux Kernel — Filesystem and file descriptor documentation — The Linux Kernel documentation