Linux separates a process’s integer handle from the kernel object that stores an open instance’s state. The distinction explains offset sharing, duplication, inheritance, and many descriptor leaks.
Descriptor Tables and Open-File Descriptions #
A file descriptor is a nonnegative integer indexing the calling process’s descriptor table. A table entry references an open-file description, represented inside Linux by struct file. The open-file description records state such as:
- the current file offset;
- file status flags such as
O_APPENDandO_NONBLOCK; - the opened path, access mode, credentials, and file operations.
Each successful open() normally creates a new open-file description and installs a reference in the process table. Opening the same path twice therefore creates independent offsets:
int a = open("events.log", O_RDONLY);
int b = open("events.log", O_RDONLY); /* independent description and offset */The inode identifies a filesystem object, whereas the open-file description identifies one opening of it. Unlinking or renaming the pathname does not invalidate an existing descriptor.
Descriptor flags belong to the table entry rather than to the shared open-file description. The principal descriptor flag is FD_CLOEXEC, read or changed with fcntl(fd, F_GETFD/F_SETFD). Prefer atomic creation flags such as O_CLOEXEC, pipe2(O_CLOEXEC), and dup3(..., O_CLOEXEC) because setting the flag in a later fcntl() call races with fork() plus execve() in another thread.
Duplication, fork(), and execve()
#
dup(), dup2(), and dup3() create another descriptor referring to the same open-file description. Thus duplicated descriptors share the offset and file status flags:
int input = open("input", O_RDONLY | O_CLOEXEC);
int stdin_copy = dup(input);
read(input, buf, 16); /* advances the shared offset */
read(stdin_copy, buf, 16); /* starts after those 16 bytes */The new descriptor has its own descriptor flags. In particular, dup() and dup2() clear close-on-exec on the new descriptor; dup3() can set it atomically. dup2(oldfd, newfd) atomically closes newfd, if necessary, and reuses that number, which avoids the close-then-dup race.
After fork(), parent and child have separate descriptor tables whose inherited entries refer to the same open-file descriptions. Offset and status-flag changes remain shared across those references. After a successful execve(), descriptors remain open unless their entries have FD_CLOEXEC; the descriptor table itself survives into the new program.
Threads created with a shared file table, as POSIX threads are, see the same descriptor entries. Closing or replacing a descriptor in one thread can therefore affect another thread, and descriptor numbers may be reused immediately.
Anonymous Pipes #
pipe() creates one kernel byte stream and returns descriptors for its read and write ends:
int fd[2];
if (pipe2(fd, O_CLOEXEC) == -1)
/* handle error */;
/* fd[0] is readable; fd[1] is writable. */A conventional fork() pipeline must close unused copies in every process:
if (fork() == 0) {
close(fd[1]);
consume(fd[0]);
close(fd[0]);
_exit(0);
}
close(fd[0]);
produce(fd[1]);
close(fd[1]); /* the reader can observe EOF after every writer closes */Close discipline is part of the protocol:
read()on an empty pipe blocks while a writer still exists, unlessO_NONBLOCKis set.read()returns zero only after the buffer is empty and all descriptors referring to the write end have closed.write()to a full pipe blocks, or fails/partially succeeds underO_NONBLOCK.write()after all read ends have closed raisesSIGPIPE; if ignored or handled, the call fails withEPIPE.
Pipes are byte streams and preserve no general message boundaries. POSIX guarantees that a write of at most PIPE_BUF bytes is not interleaved with writes by other processes. Linux defines PIPE_BUF as 4096 bytes. Larger writes may be split or interleaved; nonblocking writes can also be partial, so callers must inspect return values.
O_NONBLOCK is a file status flag on the open-file description. Setting it through one duplicated descriptor changes behavior observed through every descriptor that shares that description.
FIFOs as Named Rendezvous Points #
mkfifo() creates a filesystem entry of type FIFO:
if (mkfifo("/run/example/events.fifo", 0600) == -1 && errno != EEXIST)
/* handle error */;The directory entry stores no stream contents. While the FIFO is open, the kernel maintains a pipe object and processes reach it by opening the pathname. Once open, reads, writes, atomicity, EOF, and SIGPIPE follow pipe semantics.
Opening provides rendezvous behavior:
- A blocking
open(path, O_RDONLY)waits for a writer. - A blocking
open(path, O_WRONLY)waits for a reader. - A nonblocking read-only open succeeds without a writer; reads return zero while no writer exists, or
EAGAINwhen writers exist but no data is available. - A nonblocking write-only open fails with
ENXIOuntil a reader has opened the FIFO.
Linux permits opening a FIFO with O_RDWR, even when no peer is present, but POSIX leaves that behavior undefined. Keeping both ends open can also suppress EOF or create a self-deadlock, so portable protocols should use explicit reader and writer roles.
Because a FIFO name can be replaced between checks and open(), place it in a controlled directory, validate ownership and type when trust boundaries require it, and avoid check-then-open authorization logic.
Comments
Read or comment on GitHub (sign-in required to comment)