Skip to content
MINH VO A working notebook
by an engineer in Vietnam
Foundation7 min read

File descriptors: two handles can share one offset

Compare inherited descriptors with independent open calls using a six-byte file, and explain shared offsets, close behavior and close-on-exec.

Three miniature block-built clusters connected by channels and small wooden balls

Two file descriptors can refer to the same open file description and therefore share a file offset. If you write Unix code, this distinction explains why a read in a child process can change where its parent reads next. A descriptor is a process-local handle; the open file description is a kernel object containing state for the open operation.

The Linux open manual names this distinction explicitly. Calling open creates a new open file description. Duplicating a descriptor or inheriting it through fork can create another handle to an existing description. Those operations need not have the same offset behavior.

Start with a six-byte file

Suppose a regular file contains exactly ABCDEF. A process opens it for reading, creating descriptor three with offset zero. It duplicates that descriptor to descriptor four. Both descriptors refer to one open file description.

Reading two bytes through descriptor three returns AB and moves the shared offset to two. Reading two bytes through descriptor four then returns CD and moves the same offset to four. The offset belongs to the shared open file description, so choosing a different descriptor does not rewind the file.

OperationReturned bytesShared offset afterward
Open, then duplicateNone0
Read 2 bytes using fd 3AB2
Read 2 bytes using fd 4CD4
Read 2 bytes using fd 3EF6

For comparison, open the same path twice. Each successful open creates a distinct open file description. If both start at offset zero, the first two-byte read on each returns AB. The file’s stored contents are the same, but the positions of these independent opens are separate.

Six bytes, two ways to open themView full-size image ↗

The picture uses arrows conceptually: two handles in the first case lead to one offset, while two independent opens lead to two offsets. Drawing only descriptor numbers would hide the state that determines the next read.

Fork preserves the relationship

After fork, the child has its own descriptor table. Its inherited descriptor entries refer to the same open file descriptions as the corresponding parent entries. The fork manual documents the shared offset and file status flags.

Extend the example by opening the file before the fork. Let the parent read AB, then let the child read. If the operations occur in that order and both reads request two bytes successfully, the child sees CD. A local variable containing the number three is copied into separate memory, but descriptor three in both processes reaches the shared kernel state.

The ordering assumption is necessary. Without synchronization, scheduling determines which process reads first. A demonstration that always prints the parent line first on one machine has not established a parent-first guarantee. You can coordinate with a pipe or wait for one process to finish before observing the next operation.

Also separate atomic offset updates from application-level record boundaries. Even where the operating system updates a shared offset atomically for regular-file reads, your application may perform multiple calls to parse one record. Another reader can run between those calls. If one logical parser owns a sequence of bytes, sharing its descriptor with another parser can be the wrong design.

Closing a handle does not close every handle

Closing descriptor three removes that descriptor-table entry. Descriptor four can remain usable because it still references the open file description. The kernel releases the description when the last relevant reference disappears, subject to the object type and kernel rules.

The descriptor number can be reused after close. A stale integer three may later name an unrelated socket or file. Code that stores raw descriptor numbers across asynchronous work must manage ownership carefully. A close followed by an unrelated open can turn an accidental duplicate operation into an operation on the wrong resource.

Pipes make reference ownership especially visible. A pipe reader receives end-of-file only after all write ends are closed and buffered data has been consumed. A child that accidentally inherits an unused write end can keep the reader waiting. The writer that actually produced the data may already have exited; the remaining reference still matters.

Descriptor flags and status flags differ

Close-on-exec is a descriptor flag. It controls whether that descriptor survives a successful program replacement. File status flags, such as nonblocking behavior where applicable, are associated with the open file description and can be shared by duplicated descriptors. Treating both kinds of flags as interchangeable can cause surprising changes across code that shares a handle.

For multithreaded programs, requesting close-on-exec atomically when creating a descriptor avoids a gap between creation and setting the flag. In that gap, another thread could start a child that inherits the descriptor. The open documentation explains why O_CLOEXEC exists for this race.

When independent positions are required, separate opens are one option. Positional I/O such as pread is another: it supplies an explicit offset and does not use the open file description’s current offset in the ordinary way. That choice still requires an application decision about concurrent modifications to the file’s contents. Independent offsets prevent one kind of interference; they do not create a snapshot of the data.

An inherited pipe end can delay end-of-file

A pipe makes descriptor lifetime observable without depending on a regular file’s current offset. Create a pipe, duplicate its write descriptor, then close only the original writer. A nonblocking read with no buffered bytes reports that it would block because the duplicated write end still exists. After closing the duplicate, the same empty pipe read returns an empty byte string, which signals end-of-file.

import os

read_end, write_end = os.pipe()
extra_writer = os.dup(write_end)
try:
    os.set_blocking(read_end, False)
    os.close(write_end)
    try:
        os.read(read_end, 1)
    except BlockingIOError:
        pass
    else:
        raise AssertionError("an open writer should prevent empty-pipe EOF")
    os.close(extra_writer)
    extra_writer = -1
    assert os.read(read_end, 1) == b""
finally:
    os.close(read_end)
    if extra_writer != -1:
        os.close(extra_writer)

This Unix-oriented example intentionally uses a nonblocking reader so the first observation cannot hang the exercise. It does not write any data, so buffered bytes do not obscure the lifetime rule. In an application with data still buffered, the reader consumes that data before it observes the final empty read.

A reader cannot observe empty-pipe EOF until both the original and duplicated write ends are closedView full-size image ↗

The same reference pattern can span processes after fork. A parent may finish producing output and close its writer while an unrelated child still holds an inherited copy. The reader cannot infer that no future write is possible until all relevant write references are gone. Closing unused pipe ends in every child is therefore part of the protocol, even if those children never call write.

Draw ownership before adding asynchronous work

For each descriptor, name the code responsible for closing it and the point at which ownership transfers. A helper that borrows a descriptor should not close it unless its contract says so. A helper that takes ownership must arrange cleanup on both success and failure. Retaining a raw integer in a callback does not keep the descriptor alive.

As an exercise, draw a parent, a producer child and a reader child. Give each process the descriptor copies it receives after two forks, then cross out the ends it does not need. The reader child needs its read end; the producer needs its write end; the parent may need neither after setup. Check the drawing again for a launch failure before exec. If that path leaves a writer open, the successful reader can still wait forever even though the failure happened in another process.

The Linux pipe manual documents end-of-file after all write descriptors are closed and the behavior of an empty nonblocking pipe.

Sources & further reading

  1. Linux manual: open(2)
  2. Linux manual: fork(2)
  3. Linux manual: pipe(7)
← Back to the journal
All notes

Illustration

100%